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:
bankon Neon (provisioned by MOD-103, schemaapp). - 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_completedon the bank-core bus. - Consumes
bank.transactions.categorisedon the bank-risk-platform bus. - Publishes nothing.
- S3 + KMS:
- Writes export files to
/bank/{env}/s3/documents/nameundermod070/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()resolvesparty_id,jurisdiction,mfa_completedfrom custom claims (ADR-065). No DB lookup at request time. get-transactionreturns 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_idonly when available; never logsraw_description(which can carry merchant text).
Performance approach¶
- NFR-013 (read p99 ≤5 ms): covering index
idx_txn_view_party_postingmatches the FR-349 keyset shape exactly. FTS goes via GIN indexidx_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_idhash, 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 responsetransaction_detail_served— successful detail responsetransaction_export_requested— export startedtransaction_export_completed— export uploaded + URL issuedtransaction_export_failed— failure pathprojection_upserted— consumer wrote a rowprojection_enriched— categorised consumer updated enrichmentprojection_enrichment_skipped— out-of-order categorised (no row yet)cold_fetch_*— reserved for v1.1 MOD-002 backfill pathmod_002_call_failed— reserved for v1.1rbac_denied/session_invalid/validation_failed/internal_errortrace_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/transactionsendpoint when MOD-074 lands. - Snowflake-driven category breakdown widget (MOD-041
v_category_summary_daily) — likely a separate component in MOD-077.