Technical design — MOD-022 Payment audit trail¶
Module: MOD-022 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-129, FR-130, FR-131, FR-132 NFR scope: NFR-013, NFR-019, NFR-024 Policies satisfied: PAY-002 (LOG), PAY-003 (LOG), REP-005 (LOG) Author: AI coding agent (Claude) Date: 2026-05-08
Objective¶
The immutable audit log for every SD04 payment lifecycle event. MOD-022
owns payments.payment_events (Cat-1 immutability per ADR-048) and
materialises the full state history for every payment: from
SUBMITTED → VALIDATION_PASSED|VALIDATION_FAILED → SETTLEMENT_CONFIRMED
| REVERSAL_CONFIRMED. Every settlement-relevant transition is
reconstructable from this single table — and via the
details.posting_id linkage, traceable end-to-end into the SD01
ledger (accounts.postings).
In v1 the consumer captures the FR-129 coarse set:
SUBMITTED, VALIDATION_PASSED, VALIDATION_FAILED, SETTLEMENT_CONFIRMED,
REVERSAL_CONFIRMED. Per-check granularity (BALANCE_CHECK,
LIMIT_CHECK, etc., which the SD04 schema's CHECK constraint already
permits) is reserved for v2 — it would require either MOD-020
publishing per-check events or carrying the CheckResult array on
payment_initiated/payment_validated.
Architecture¶
EventBridge — bank-payments bus
payment_initiated ─┐
payment_validated ─┤
payment_failed ─┤
├──► Mod022AuditConsumerHandler ──► payment_events INSERT
│
EventBridge — bank-core bus
posting_completed ─┘ ──► payment_events INSERT
──► UPDATE payments.payments status
──► publish bank.payments.payment_completed
(suppressed for REVERSAL — k-2)
API Gateway HTTP API
GET /internal/v1/payments/{payment_id}/events ─► Mod022AuditQueryHandler
└─► SELECT … FROM payment_events
Consumer routing logic¶
src/services/event-router.ts is a pure function over the typed
inbound detail. It produces a PaymentEventRowDraft plus an optional
EmitPaymentCompleted instruction:
| Inbound detail-type | event_type written | event_status | Re-emit? |
|---|---|---|---|
bank.payments.payment_initiated |
SUBMITTED | INFO | no |
bank.payments.payment_validated |
VALIDATION_PASSED | PASS | no |
bank.payments.payment_failed |
VALIDATION_FAILED | FAIL | no |
bank.core.posting_completed (PAYMENT/FX_CONVERSION) |
SETTLEMENT_CONFIRMED | PASS | yes — bank.payments.payment_completed |
bank.core.posting_completed (REVERSAL) |
REVERSAL_CONFIRMED | INFO | no — k-2 v2 deferral |
bank.core.posting_completed (ACCRUAL/ADJUSTMENT) |
filtered (no row) | — | — |
Settlement linkage contract (k-1)¶
bank.core.posting_completed does not yet carry payment_id directly
— that's the data-model gap addressed by the
MOD-001-posting-completed-payment-id-extension.handoff.md handoff.
v1 of MOD-022 implements dual-path resolution:
- Primary — if
detail.payment_idis present on the inbound event, use it directly. (Available once bank-core ships the schema bump and rail callers populate the field.) - Fallback — otherwise
SELECT id FROM payments.payments WHERE idempotency_key = $1. The rail caller's idempotency_key, when used identically against MOD-001 and MOD-020, gives us a deterministic linkage even before the schema bump lands.
If neither path resolves a payment row, the consumer logs WARN
(settlement_payment_not_found) and drops — typically this means an
out-of-order delivery before the rail-side INSERT has committed; EB's
retry window resolves it. The Mod022SettlementNotFoundAlarm
surfaces a pattern of these (rail-caller k-1 contract drift).
Rail-author guidance (apply to MOD-119/120/122/124/135/136/141 and
any future rail integration): when calling MOD-001's
POST /internal/v1/postings, set payment_id to the
payments.payments.id AND set idempotency_key to the same value
you used when calling MOD-020's validate-payment. Both fields enable
the linkage; the dual-path handles the transition window where some
producers carry payment_id and others don't.
payments.payment_events schema (V001 + V002)¶
| Column | Type | Notes |
|---|---|---|
| id | uuid PK default gen_random_uuid() | |
| payment_id | uuid NOT NULL → payments.payments(id) | FK is intra-DB; same Neon bank_payments |
| event_type | text NOT NULL CHECK enum | SD04 schema set; v1 uses 5 of the values per k-6 |
| event_status | text NOT NULL CHECK ∈ | |
| event_timestamp | timestamptz NOT NULL DEFAULT now() | microsecond precision |
| source_module | text NOT NULL | MOD-020, MOD-001 etc. |
| actor | text | staff ID, module ID, external system |
| channel | text NOT NULL DEFAULT 'SYSTEM' CHECK ∈ | k-5 first-class column |
| ip_address | inet | k-5 first-class column — operator-initiated only |
| trace_id | uuid | k-5 ADR-031 propagation |
| source_event_id | text NOT NULL | k-7 EventBridge dedupe via UNIQUE index |
| details | jsonb NOT NULL DEFAULT '{}' | structured event-specific payload |
| created_at | timestamptz NOT NULL DEFAULT now() |
Indexes:
- (payment_id, event_timestamp) — FR-131 hot path
- (event_type) — case-mgmt filters
- (event_timestamp DESC) — time-window queries
- UNIQUE (source_event_id) — k-7 EB-redelivery dedupe; consumer
uses ON CONFLICT (source_event_id) DO NOTHING
Grants: bank_payments_app_user gets SELECT + INSERT only.
bank_payments_readonly gets SELECT.
V002 installs the Cat-1 immutability trigger
trg_payment_events_immutable_{update,delete,truncate} raising
insufficient_privilege (SQLSTATE 42501) on any mutation attempt.
Defence in depth on top of grants.
payments.payments updates (k-1)¶
When posting_completed arrives with PAYMENT/FX_CONVERSION
posting_type and the payment row resolves, the consumer also
transitions:
UPDATE payments.payments
SET status = 'SETTLED',
settled_at = COALESCE(settled_at, now()),
updated_at = now()
WHERE id = $1::uuid
AND status NOT IN ('SETTLED','REVERSED','CANCELLED');
REVERSAL posting_type analogously transitions to status='REVERSED'.
The terminal-status guard prevents a stale event from clobbering a
later state.
v1 known limitations¶
- Per-check granularity (k-6) — v1 records the FR-129 coarse set
only. v2 adds BALANCE_CHECK / LIMIT_CHECK / SANCTIONS_CHECK /
FRAUD_CHECK / ACCOUNT_STATUS_CHECK rows; that requires MOD-020 to
carry the CheckResult array on
payment_initiated(or to publish per-check events). bank.payments.payment_reversedre-emission (k-2) — the REVERSAL_CONFIRMED audit row is written, but the bus event is deferred to v2 because no reversal-initiating module exists yet (MOD-119/120/122 returns are Tier D). The rail-initiating module will own the bus event in v2.- Retention (FR-130) — 7/10-year retention is satisfied at the SD-architecture level via the CDC pipeline (MOD-042) and S3 lifecycle policies (MOD-104). The Postgres table has no TTL; rows age naturally with the database. This is a deliberate architectural choice per ADR-003.
- Out-of-order delivery — if
posting_completedarrives before the rail caller'spayments.paymentsINSERT has committed, the consumer drops the event with WARN. EB's retry window (24h default) covers most cases; thesettlement_not_foundalarm surfaces sustained drift.
SSM outputs published by MOD-022¶
| Path | Value |
|---|---|
/bank/{stage}/mod-022/api/base-url |
API Gateway URL |
/bank/{stage}/mod-022/audit-query/url |
Full audit-query endpoint |
/bank/{stage}/mod-022/audit-consumer-lambda/arn |
Consumer Lambda ARN |
/bank/{stage}/mod-022/audit-query-lambda/arn |
Query Lambda ARN |
/bank/{stage}/mod-022/payment-events-table |
payments.payment_events |
Upstream SSM dependencies¶
| Path | Used for |
|---|---|
/bank/{stage}/iam/lambda/bank-payments/arn |
BankPaymentsRole |
/bank/{stage}/eventbridge/bank-payments/arn |
same-bus consume + publish |
/bank/{stage}/eventbridge/bank-core/arn |
cross-bus consume target (k-9 grant in place) |
/bank/{stage}/observability/adot-nodejs-layer-arn |
OTel layer |
/bank/{stage}/sns/alerts/arn |
alarm SNS topic |
/bank/{stage}/neon/pooler-host |
DB connection |
Events published¶
| Detail-type | Bus | When |
|---|---|---|
bank.payments.payment_completed |
bank-payments | Settlement of a non-reversal posting (k-2). Schema includes trace_id per ADR-031. |
Events consumed¶
| Detail-type | Bus | Rule name |
|---|---|---|
payment_initiated |
bank-payments | bank-payments-mod-022-payments-lifecycle-{stage} |
payment_validated |
bank-payments | (same rule, multi-detail-type pattern) |
payment_failed |
bank-payments | (same rule) |
posting_completed |
bank-core | bank-payments-mod-022-core-posting-completed-{stage} |
Cross-bus rule grant (k-9)¶
The bank-core PutRule grant filed by MOD-082
(docs/handoffs/processed/2026-05-05/MOD-104-bank-core-cross-bus-grant.handoff.md)
uses the resource pattern bank-payments-mod-*. MOD-022's rule
matches. No new MOD-104 handoff required for the cross-bus rule.
Reserved concurrency (k-8)¶
| Stage | audit-consumer | audit-query |
|---|---|---|
| dev | unbounded | unbounded |
| uat | 20 | 10 |
| prod | 50 | 30 |
Test surface¶
| Tier | Files | Coverage |
|---|---|---|
| Unit | event-router (k-1, k-2 dual-path + reversal), errors, amount, trace, logger, emf, types | ≥80% line + function |
| Contract | published payment_completed (with trace_id), inbound consumer detail shapes | one per published event + one per consumed event family |
| Policy | PAY-002 LOG, PAY-003 LOG, REP-005 LOG | one per policies_satisfied row (immutability + lineage) |
| Integration | FR-129 (lifecycle events land), FR-131 (audit query), FR-132 (actor + ip + channel as first-class fields), idempotency (UNIQUE source_event_id), NFR-024 (DB rejects mutations), observability | one per FR + dedupe + immutability runtime gate |
| Smoke | tests/verify-deployment.mjs |
SSM + Lambda + EventBus + schemas + alarms + 404 audit-query invoke |