Skip to content

Technical design — MOD-070 Transaction history & search

Module: MOD-070 System: SD08 Customer App & Back Office Platform Repo: bank-app FR scope: FR-349, FR-350, FR-351, FR-352 NFR scope: NFR-005, NFR-013, NFR-017 Policies satisfied: CON-005 (AUTO), GOV-006 (LOG) Author: SD08 build agent (Claude) Date: 2026-05-18

Objective

MOD-070 is the customer-facing view of every posted transaction across every account a party holds. It maintains a per-party projection in the consolidated bank Postgres database (ADR-064, schema app), fed by two cross-bus EventBridge consumers — bank.core.posting_completed (bank-core bus, MOD-002 immutable log) for raw rows, and bank.transactions.categorised (bank-risk-platform bus, MOD-041) for enrichment. The Lambda-backed HTTP API exposes pagination, full-text search, detail drill-down, and CSV/PDF export.

The projection model is deliberate: MOD-002 is the source of truth (append-only ledger with hash verification); MOD-070 keeps a denormalised read model in app.transaction_view because (a) FR-349's 500 ms first-page SLA is not achievable through MOD-002's per-account REST API; (b) FR-350's full-text search across 12 months requires a GIN tsvector index that the source log doesn't carry. SD08 is a consumer, not an authority, for transaction data — every write to the projection traces back to a MOD-002 event.

Internal architecture

                         ┌───── bank-core bus ─────────────────────────┐
                         │ bank.core.posting_completed                  │
                         │   → mod-070-posting-completed-rule           │
                         │       → consume-posting-completed Lambda     │
                         │           upsertFromPosting()                │
                         │             INSERT app.transaction_view      │
                         │             ON CONFLICT DO UPDATE            │
                         └──────────────────────────────────────────────┘

                         ┌── bank-risk-platform bus ───────────────────┐
                         │ bank.transactions.categorised                │
                         │   → mod-070-categorised-rule                 │
                         │       → consume-categorised Lambda           │
                         │           applyEnrichment()                  │
                         │             UPDATE merchant/category cols    │
                         └──────────────────────────────────────────────┘

Customer HTTP API (Cognito JWT authoriser):
  GET    /transactions                       list-transactions
  GET    /transactions/{posting_id}          get-transaction
  POST   /transactions/export                request-export
                                              listForExport()
                                              build CSV / PDF
                                              S3 PutObject (KMS-encrypted)
                                              recordExport() — append to
                                                app.transaction_exports
                                                (ADR-048 Cat 1 immutable)
                                              presigned GET URL (15 min)

Five Lambdas + one HTTP API + two cross-bus EventBridge rules + one CloudWatch log group + two Postgres tables.

Key design decisions

Decision: projection table in app.transaction_view, not the source

CLAUDE.md §"SD08 is a consumer, not an authority" forbids SD08 writing to SD01 tables. A per-party projection fed by events is the canonical pattern. The table is named transaction_view (singular) to signal projection semantics — it's not app.transactions, which would misrepresent ownership.

fts_vector is GENERATED ALWAYS AS STORED so the categorised consumer's UPDATE automatically keeps it in sync without trigger plumbing. English text config (NZ/AU customer base); multi-lingual stemming is an additive ALTER + reindex if needed later.

Decision: two independent consumers, not a single fused stream

The posting_completed and transactions_categorised events arrive on different buses, owned by different SDs, with different latency profiles. Fusing them in MOD-070 (e.g. holding the first event until the second arrives) couples the projection to the slower stream and introduces correctness risk on out-of-order delivery. Instead:

  • posting_completed → INSERT projection row with enrichment cols NULL.
  • transactions_categorised → UPDATE the same row's enrichment cols.

The UPDATE is a no-op (RETURNING 0 rows) if the projection row hasn't landed yet — the next categorised event for the same posting_id will update once the row exists, and FR-351's fallback-to-raw covers the intervening UI gap. The applyEnrichment() function returns a boolean the consumer logs (projection_enriched vs projection_enrichment_skipped).

Decision: cold-fetch path designed but not wired in v1

src/lib/mod-002-client.ts is a complete IAM/SigV4 client for MOD-002's GET /internal/v1/transaction-log/{account_id} endpoint — the cold-fetch path for backfilling the projection on a party's first history view. Per j-2 ruling, IAM/SigV4 with BankAppRole is the correct auth model.

v1 ships the consumer-fed projection only. The cold-fetch path is deferred to v1.1 once we measure how often new customers ask for historical data older than the consumer subscription window. MOD_002_API_BASE_URL is wired into the Lambda env vars so the cold-fetch can be wired without an infra change.

Decision: keyset pagination, opaque cursor

(posting_date, posting_id) keyset, base64url-encoded. The cursor decode is strict — INVALID_CURSOR (422) on any decode failure rather than silently falling back to first page (would mask client bugs). Standard SD08 pattern; same encoding shape used implicitly across MOD-072/MOD-053.

The covering index idx_txn_view_party_posting matches the keyset sort shape (party_id, posting_date DESC, posting_id DESC) and satisfies NFR-013's 5 ms read budget at any realistic projection size.

Decision: sync export with presigned URL (j-5 ruling)

FR-352 says "generating and delivering within 30 seconds". The request-export Lambda has a 30 s timeout, builds the CSV/PDF in-memory, uploads to S3, presigns a 15-minute GET URL, and returns it in the response body. For typical customer transaction volumes (≤ ~10k rows over 12 months) this fits comfortably.

v2 path if large-account testing shows P99 budget pressure: hand off to Step Functions, return an export_id immediately, expose a status endpoint, deliver the URL via in-app notification.

Decision: export bucket reuse with mod070/exports/ prefix

/bank/{env}/s3/documents/name (provisioned by MOD-104, not MOD-073-owned) under the mod070/exports/{party_id}/{yyyymmdd}/ prefix. SSE-KMS with /bank/{env}/kms/pii/arn. Same pattern MOD-073 uses for document content — the bucket is shared SD08 infrastructure.

Decision: GOV-006 LOG via ADR-048 Cat 1 immutability trigger

app.transaction_exports has a BEFORE UPDATE OR DELETE OR TRUNCATE trigger that raises EXCEPTION. The INSERT path is the only mutation the table tolerates. The runtime test in tests/integration/fr/FR-352-export-audit.test.ts plants a row and asserts that UPDATE and DELETE both raise.

External dependencies

  • Database: bank on Neon (provisioned by MOD-103, schema app).
  • WRITE: app.transaction_view, app.transaction_exports.
  • No reads of other domains' tables (SD08 calls APIs / subscribes to events for cross-domain data).
  • EventBridge:
  • Consumes bank.core.posting_completed on the bank-core bus.
  • Consumes bank.transactions.categorised on the bank-risk-platform bus.
  • Publishes nothing.
  • S3 + KMS:
  • Writes export files to /bank/{env}/s3/documents/name under mod070/exports/.
  • SSE-KMS with /bank/{env}/kms/pii/arn.
  • MOD-002 read API (cold-fetch v1.1):
  • Resolved via /bank/{env}/mod-002/api/base-url.
  • Auth: IAM/SigV4 with BankAppRole.
  • Secrets Manager: bank-neon/{env}/app_app_user — Neon pooled connection (ADR-064).
  • SSM (read):
  • /bank/{env}/eventbridge/bank-app/arn
  • /bank/{env}/eventbridge/bank-core/arn + /dlq-arn
  • /bank/{env}/eventbridge/bank-risk-platform/arn + /dlq-arn
  • /bank/{env}/iam/lambda/bank-app/arn
  • /bank/{env}/observability/adot-layer-arn
  • /bank/{env}/s3/documents/name
  • /bank/{env}/kms/pii/arn
  • /bank/{env}/kms/operational/arn
  • /bank/{env}/mod-002/api/base-url

SSM outputs table

Output SSM path Consumers
Transaction API base URL /bank/{env}/mod070/transaction-api/url MOD-074 back-office customer 360; MOD-077 dashboard insight feed; MOD-148 DSAR workflow
Transaction API ID /bank/{env}/mod070/transaction-api/id API Gateway authoriser attachment (MOD-068)
list-transactions Lambda ARN /bank/{env}/mod070/list-transactions/fn-arn Operational metrics target
get-transaction Lambda ARN /bank/{env}/mod070/get-transaction/fn-arn Operational metrics target
request-export Lambda ARN /bank/{env}/mod070/request-export/fn-arn Operational metrics target
consume-posting-completed Lambda ARN /bank/{env}/mod070/consume-posting-completed/fn-arn Operational metrics target
consume-categorised Lambda ARN /bank/{env}/mod070/consume-categorised/fn-arn Operational metrics target
Export audit log group ARN /bank/{env}/mod070/transaction-exports-log/group-arn MOD-076 (SIEM tail subscription)
Export audit log group name /bank/{env}/mod070/transaction-exports-log/group-name Same

Security and data handling

  • All HTTP routes are Cognito-JWT-authenticated. withSession() resolves party_id, jurisdiction, mfa_completed from custom claims (ADR-065). No DB lookup at request time.
  • get-transaction returns 404 for a posting that doesn't belong to the calling party — prevents enumeration of other parties' postings via posting_id guessing.
  • Export files are SSE-KMS-encrypted with the documents/PII KMS key. Presigned URLs expire after 15 minutes.
  • The export audit (GOV-006) records party_id, user_id, full filter jsonb, format, row count, S3 key, and trace_id on every export — append-only.
  • No PII in projection rows beyond cross-domain UUIDs and money amounts (party_id, account_id, posting_id are opaque). Logger emits party_id only when available; never logs raw_description (which can carry merchant text).

Performance approach

  • NFR-013 (read p99 ≤5 ms): covering index idx_txn_view_party_posting matches the FR-349 keyset shape exactly. FTS goes via GIN index idx_txn_view_fts.
  • NFR-017 (TTI ≤2 s on 4G): UI bundle hygiene — components are tree- shakeable, no heavy dependencies. Pagination caps response size.
  • The projection grows linearly with transaction volume. Future partition strategy (by party_id hash, or by month) is an additive ALTER if size becomes a concern.
  • pdfkit generation is CPU-bound — Lambda memory at 512 MB gives ~5,000 row PDFs in well under the 30 s budget.

Error handling

  • Async consumer paths — posting_completed and categorised Lambdas return cleanly on malformed events (logged as validation_failed); EventBridge ack avoids redrive of unfixable payloads. Postgres errors re-raise → EventBridge retry-then-DLQ.
  • Sync HTTP paths — standard error envelope per error-handling- standard.md. Error codes: MISSING_FIELD, INVALID_FIELD, DATE_RANGE_INVALID, DATE_RANGE_TOO_LARGE, INVALID_CURSOR, INVALID_QUERY, UNAUTHORIZED, NOT_FOUND, EXPORT_TIMEOUT, EXPORT_FAILED, MOD_002_UNAVAILABLE, DB_TIMEOUT, INTERNAL_ERROR.
  • Cross-bus rule provisioning — first deploy fails until the bank-platform IAM grants land on bank-core + bank-risk-platform buses. Expected; see handoff.

Event types emitted in structured logs

Registered in src/lib/logger.ts:

  • transactions_listed — successful list response
  • transaction_detail_served — successful detail response
  • transaction_export_requested — export started
  • transaction_export_completed — export uploaded + URL issued
  • transaction_export_failed — failure path
  • projection_upserted — consumer wrote a row
  • projection_enriched — categorised consumer updated enrichment
  • projection_enrichment_skipped — out-of-order categorised (no row yet)
  • cold_fetch_* — reserved for v1.1 MOD-002 backfill path
  • mod_002_call_failed — reserved for v1.1
  • rbac_denied / session_invalid / validation_failed / internal_error
  • trace_id_missing_from_upstream — observability standard WARN

Test approach

Tier Location Count
Unit tests/unit/ trace, logger, errors, cursor, tsquery, csv, enrichment — 7 suites
Contract tests/contract/ Event-schema validation (posting_completed + categorised)
Policy tests/policy/ CON-005 AUTO source scan + GOV-006 LOG migration shape
FR integration tests/integration/fr/ FR-349, FR-350, FR-351, FR-352 — 4
Post-deploy smoke tests/verify-deployment.mjs Auth-gate check

CON-005 AUTO is satisfied by (a) source-level scan in con-005-auto-static.test.ts for forbidden suppression tokens (hide_fee, mask_fx, suppress_amount etc.) — none present; (b) structural test in tests/unit/enrichment.test.ts asserting the response shape always exposes fee, fx_rate, base_currency_amount, running_balance whether their values are null or set.

GOV-006 LOG is satisfied by (a) static scan of V002 declaring the ADR-048 Cat 1 trigger function; (b) runtime test in FR-352-export-audit.test.ts planting a row and asserting UPDATE and DELETE both raise the immutability exception.

v2 follow-ups

  • Wire the MOD-002 cold-fetch path for backfill on first-history-view (lib/mod-002-client.ts is ready).
  • Step Functions async export for ranges >12 months.
  • Back-office /internal/transactions endpoint when MOD-074 lands.
  • Snowflake-driven category breakdown widget (MOD-041 v_category_summary_daily) — likely a separate component in MOD-077.