Skip to content

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:

  1. Raw transaction lands in Postgres immediately on authorisation
  2. An EventBridge event (bank.transactions.authorised) is published (ADR-029, superseded by ADR-051; see ADR-051 for current EventBridge bus naming convention)
  3. The enrichment service calls the merchant lookup table in Snowflake
  4. If a canonical match exists: clean name, logo ID, and category written back
  5. If no match: the ML categorisation model is called (see ADR-017)
  6. Enriched fields written back to Postgres via write-back API
  7. 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 PDF
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
Email 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

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