ADR-016: Transaction history design — data model, enrichment, and UX¶
| Status | Accepted |
| Date | 2026-04-10 |
| Deciders | CTO, Head of Product, Tech Lead |
| Affects repos | bank-core, bank-app, bank-risk-platform |
Status¶
Accepted — 2026-04-10
Context¶
Transaction history is the most-used feature in any banking app. It is also the feature most often designed poorly — raw acquirer strings displayed without enrichment, pagination that loads too slowly, filters that don't work, and detail views that don't show what customers actually need.
This ADR documents the decisions for how transaction history is stored, enriched, paginated, and displayed — covering both the customer app and the back office view of the same data.
Decision¶
Transaction history uses cursor-based pagination. Merchant enrichment runs asynchronously via an EventBridge event (ADR-029, superseded by ADR-051; see ADR-051 for current EventBridge bus naming convention). Categorisation uses a Snowflake ML model (see ADR-017). Pending and settled transactions are stored and displayed separately. Cross-border transactions show both currency amounts inline.
Pagination and data loading¶
Transaction history is never loaded in full on open. The API returns a page of 20–30 transactions and a cursor token pointing to the next page. Infinite scroll triggers the next fetch as the user approaches the bottom of the list.
Why cursor-based (not offset-based): Offset pagination has a classic problem — if new transactions arrive while the user is scrolling, rows shift and the user sees duplicates or gaps. A cursor based on the transaction timestamp and ID is stable regardless of new inserts.
Search and filter: Search and filter requests go to a dedicated query endpoint against an indexed read replica of Postgres — never the primary write node. The read replica has indexes on merchant name, amount, date, category, and reference. This keeps search fast without adding load to the transaction hot path.
Back office: Back office agents querying any customer's full transaction history use the same API but with the customer ID as a path parameter and no data scope restriction on the count. The query endpoint is the same; the JWT scope determines what is returned.
Transaction record schema¶
The raw ledger entry in Postgres stores more than the customer sees. The schema includes:
| Field | Description |
|---|---|
id |
Immutable UUID |
account_id |
Which account |
amount_authorised |
Amount at authorisation |
amount_settled |
Amount at settlement (may differ — e.g. hotel pre-auth) |
currency |
Transaction currency |
fx_rate |
Exchange rate if cross-currency |
original_currency_amount |
For foreign transactions |
mcc |
Merchant category code from scheme |
merchant_raw |
Raw acquirer string (e.g. "WLWRTHS ST 0442 AUCKLAND NZ") |
merchant_canonical |
Enriched merchant name (e.g. "Woolworths NZ") |
merchant_logo_id |
Reference to logo asset |
category |
Assigned category (from ML model) |
category_confidence |
Model confidence score |
customer_category_override |
Customer's manual correction (null if not corrected) |
network_reference |
Payment network reference |
auth_code |
Authorisation code |
acquirer_id |
Acquiring bank identifier |
status |
Pending / Settled / Reversed / Failed |
pending_at |
Timestamp of authorisation |
settled_at |
Timestamp of settlement |
enriched_at |
Timestamp of merchant enrichment |
The authorisation and settlement amounts are stored separately because they frequently differ. A hotel pre-authorises a hold amount and settles at the actual room charge. A petrol station pre-authorises a fixed amount and settles at the pump total. Showing the wrong amount creates customer confusion and support load.
Merchant enrichment pipeline¶
Raw acquirer data often arrives as "WLWRTHS ST 0442 AUCKLAND NZ" — which means nothing to a customer. The merchant enrichment pipeline cleans this asynchronously:
- Raw transaction lands in Postgres immediately on authorisation
- An EventBridge event (
bank.transactions.authorised) is published (ADR-029, superseded by ADR-051; see ADR-051 for current EventBridge bus naming convention) - The enrichment service calls the merchant lookup table in Snowflake
- If a canonical match exists: clean name, logo ID, and category written back
- If no match: the ML categorisation model is called (see ADR-017)
- Enriched fields written back to Postgres via write-back API
- Customer sees the clean version within seconds of transaction posting
The customer never sees the raw acquirer string unless enrichment fails — in which case the raw string is displayed as a fallback, not a blank.
Vendor vs in-house: Providers like Ntropy or Mastercard's enrichment API map raw strings to clean names, logos, and categories. These are worth evaluating, but an in-house Snowflake ML model (ADR-017) gives the bank proprietary data advantage and avoids ongoing per-transaction API costs at scale. The enrichment step is designed as a pluggable consumer so the data source can be changed without altering the transaction schema.
Pending vs settled treatment¶
Pending transactions are authorisations that have not yet cleared. They must be: - Displayed with a distinct visual treatment (different colour, explicit "Pending" label) - Deducted from the available balance shown to the customer - Not duplicated when the pending transaction settles
When a pending transaction settles, the settled record replaces the pending record — it is not a new row. The matching step uses the network reference and auth code to link the settlement to the original authorisation. If the settlement amount differs from the authorisation amount, the settled record shows the final amount.
This matching step is the most common source of "duplicate transaction" support contacts at banks that don't implement it correctly. The Postgres schema stores both pending_at and settled_at timestamps on a single record, not two separate records.
Cross-border transaction display¶
When a customer transfers NZD to their AUD account, both accounts show the transaction:
NZD account view: - Description: "Transfer to AU Everyday" - Amount: −NZD 500.00 - Metadata: → AUD 461.65
AUD account view: - Description: "Transfer from NZ Everyday" - Amount: +AUD 461.65 - Metadata: ← NZD 500.00 · Rate: 1 NZD = 0.9231 AUD
The FX rate applied is visible in the transaction detail view. Both legs are linked by a transfer_reference field so either leg can navigate to the other.
Export and statements¶
Both are regulatory requirements and genuine customer needs (tax reporting, expense management, dispute evidence).
On-demand export: CSV and PDF available at any time from the statements section. CSV formatted for common accounting tools (Xero, MYOB, spreadsheet import). PDF formatted as a formal bank statement with account header, opening and closing balances, and full transaction list.
Electronic-only policy¶
This bank issues statements electronically only. No paper statements are produced or posted. Customers are informed of this at account opening and must acknowledge it as part of the product terms and conditions (covered in ADR-012). The in-app document centre (MOD-075) is the primary statement delivery point; email serves as the notification channel.
This position is consistent with NZ and AU regulatory guidance permitting electronic-only delivery where the customer has been given adequate notice and has consented as part of the account agreement. Any future regulatory change requiring paper delivery would require this ADR to be updated.
Monthly statements¶
A monthly transaction statement is generated automatically for every account:
| Attribute | Detail |
|---|---|
| Generation trigger | EventBridge Scheduler — 1st of each month, covering the previous calendar month |
| Format | |
| Content | Account name, account number, BSB/branch, opening balance, closing balance, full transaction list (enriched merchant names), total credits, total debits, any fees charged |
| Storage | ADR-028 customer document tier (S3, immutable once generated, 7-year retention) |
| In-app | Available immediately in document centre; notification badge incremented |
| Notification email sent on generation — contains a deep link into the app's document centre. The PDF is not attached to the email. Customers retrieve the document from within the app only. | |
| Idempotency | Statement Lambda is idempotent per account per month — re-runs do not create duplicate documents |
Annual statements¶
An annual statement is generated at the end of each financial year (31 March NZ / 30 June AU) and is distinct from the monthly statement — it covers the full year and includes content required by CCCFA (NZ) and NCC (AU):
| Attribute | Detail |
|---|---|
| Content additions vs monthly | Total interest charged (year), total fees charged (year), opening and closing credit limit (if applicable), any hardship events in the period, complaints process reminder |
| Regulatory basis | CCCFA s.26 (NZ annual disclosure); NCC s.34 (AU annual statement requirement) |
| Storage | Same ADR-028 customer document tier — retained for 7 years |
| Delivery | Same email notification + in-app document centre pattern as monthly |
Annual statements are also listed in the ADR-012 disclosure scope — the generation and delivery mechanism is this ADR; the regulatory classification and evidence requirement is ADR-012.
Snowflake¶
The same transaction data that feeds customer statements also feeds regulatory reporting. The source of truth is always Postgres; the reporting layer runs off Snowflake's copy — ensuring consistency between what the customer sees and what regulators receive.
Search requirements¶
Search must work well because it is how customers find specific transactions for disputes, expense reporting, and tax purposes.
Minimum search capabilities: - Merchant name fuzzy search ("wool" finds "Woolworths NZ") - Amount filter (exact amount or range) - Date range filter - Category filter - Status filter (pending, settled, all) - Account scope (within the selected account by default)
All filter combinations must be supported simultaneously. Search must work on the read replica — not the primary Postgres node — to prevent search load from impacting transaction processing.
Back office superset¶
The back office agent view of the same transaction includes: - All customer-visible fields - MCC code and merchant category description - Network reference and auth code - Real-time fraud score at time of transaction - AML flag status - Agent actions: reverse, open case, flag for AML review
The agent actions write to the immutable audit log before executing. An agent cannot initiate a reversal without the action being logged with their identity, timestamp, and justification.
Principles alignment¶
| Principle | Assessment | Notes |
|---|---|---|
| AP-001 KISS | ✓ | Cursor pagination is simpler and more correct than offset |
| AP-002 Data governance | ✓ | Full schema including raw and enriched fields; immutable history |
| AP-005 Customer driven | ✓ | Enriched merchant names, category, pending clarity — all reduce support load |
| AP-007 Evolution | ✓ | Enrichment is pluggable; categorisation model trains continuously |
| AP-008 Real time | ✓ | Pending transactions visible immediately; enrichment within seconds |
Perspectives¶
| Perspective | Assessment | Notes |
|---|---|---|
| Performance & Scalability | ✓ | Cursor pagination scales; read replica isolates search load |
| Usability | ✓ | Enriched data, clear pending treatment, cross-border display |
| Integration | ✓ | Enrichment pipeline is an EventBridge consumer — decoupled from transaction path |
| Support & Maintenance | ✓ | Correct pending matching eliminates most common support contact |
| Regulatory | ✓ | Statement obligations met; audit trail complete |
See perspectives.md for how to use these evaluation lenses.
Relevant viewpoints¶
- Functional viewpoint — Transaction history in customer app; agent view in back office
- System viewpoint — Transaction schema in Postgres; enrichment pipeline via EventBridge; write-back from Snowflake
- Information viewpoint — Full transaction record schema; pending vs settled state machine; FX fields
- Operational viewpoint — Enrichment failure alerting; search replica health; statement generation pipeline
See viewpoints.md for guidance on producing these viewpoints.
Signoff record¶
| Date | Name | Role | Status |
|---|---|---|---|
| 2026-04-10 | Ross Millen | CTO | Approved |
| 2026-04-10 | Ross Millen | Head of Architecture | Approved |
| 2026-04-10 | Ross Millen | Head of Data | Approved |
Capabilities¶
| Capability | Description | Relationship |
|---|---|---|
| CAP-011 | Real-time balance & transaction feed | enabled — cursor-based pagination, real-time pending/settled display |
| CAP-012 | Merchant name enrichment & logo | enabled — enrichment pipeline (EventBridge to Lambda to Snowflake to write-back) |
| CAP-013 | Spend categorisation (auto + manual) | enabled — category field with customer override defined in transaction schema |
| CAP-112 | Transaction search & filter | enabled — search against read replica with indexed fields |
| CAP-113 | Statement & transaction export | enabled — CSV and PDF export on demand |
| CAP-118 | Statement generation & download | enabled — monthly and annual statement generation pipeline defined here |
Related decisions¶
| ADR | Title | Relationship |
|---|---|---|
| ADR-012 | External disclosures system | statement delivery mechanism is this ADR; regulatory classification is ADR-012 |
| ADR-015 | Cross-border NZ/AU wallet and transfer design | cross-border transaction display depends on schema design here |
| ADR-017 | Transaction categorisation and merchant enrichment — in-house ML vs external API | ML model that provides categorisation input to enrichment pipeline |
| ADR-028 | Document storage — S3 and Postgres metadata | statements stored in S3 customer document tier |
| ADR-029 (superseded by ADR-051; see ADR-051 for current EventBridge bus naming convention) | Domain event routing via Amazon EventBridge | bank.transactions.authorised event triggers enrichment pipeline |
All ADRs
Compiled 2026-05-22 from source/entities/adrs/ADR-016.yaml