Skip to content

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:

  1. Primary — if detail.payment_id is present on the inbound event, use it directly. (Available once bank-core ships the schema bump and rail callers populate the field.)
  2. 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_reversed re-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_completed arrives before the rail caller's payments.payments INSERT has committed, the consumer drops the event with WARN. EB's retry window (24h default) covers most cases; the settlement_not_found alarm 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