Skip to content

Technical design — MOD-081 Payment reconciliation engine

Module: MOD-081 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-157, FR-158, FR-159, FR-160 NFR scope: NFR-019 (RTO ≤ 4h / RPO ≤ 1h, Tier 1), NFR-024 (audit log immutability) Policies satisfied: PAY-002 (AUTO), REP-005 (LOG) Author: AI coding agent (Claude) Date: 2026-05-15 Jurisdiction: AU + NZ

Objective

Match every payment instruction the bank issues against the corresponding settlement confirmation from each payment rail, and raise exceptions for anything that doesn't reconcile. Close the daily settlement position. This is the first SD04 Tier C (reconciliation) module to ship, and the first module in the repo whose primary input is an S3 object (not an HTTP request or EventBridge event).

Architecture

S3 bucket: bank-payments-settlement-files-{stage}
  incoming/<rail>/<file>      ─►  ObjectCreated:Put
                              ingest.handler (Lambda)
                                       │ 1. derive rail from key prefix
                                       │ 2. fetch object
                                       │ 3. parseFile(rail, raw)
                                       │ 4. INSERT settlement_files
                                       │    (idempotent on file_reference)
                                       │ 5. publish settlement_file_received
EventBridge: bank.payments.settlement_file_received
                              match.handler (Lambda)
                                       │ 1. selectFileById
                                       │ 2. re-parse from S3
                                       │ 3. runMatch(deps, file):
                                       │    for each item:
                                       │      lookupPayment(rail, ref)
                                       │      classify(instructed, settled)
                                       │ 4. INSERT reconciliation_exceptions
                                       │ 5. publish reconciliation_exception_raised
                                       │ 6. UPDATE settlement_files.file_status
                                       │    → SETTLED | PARTIALLY_SETTLED
EventBridge schedule: rate(1 hour)     EventBridge schedule: cron(0 18 * * ? *)
       │                                                       │
       ▼                                                       ▼
escalate.handler (Lambda)                       daily-report.handler (Lambda)
       │                                                       │
       │ 1. listOpenOlderThan(threshold)                        │ 1. for each rail in V1_MATCH_RAILS:
       │ 2. for each row:                                       │    for each (currency, jurisdiction):
       │    isEscalationCandidate(detected_at, threshold)       │      listForDay + listForDayByFile
       │    → markEscalated + log exception_escalated           │      buildDailySummary
       │ 3. emitEscalationOutcome                               │      INSERT reconciliation_daily_summary
       │                                                        │      publish reconciliation_daily_summary
                                                       Postgres + EventBridge

HTTP API (dev/uat only): admin.handler
  GET    /internal/v1/payments/reconciliation/exceptions
  POST   /internal/v1/payments/reconciliation/exceptions/{id}/resolve
  POST   /internal/v1/payments/reconciliation/exceptions/{id}/reopen
  GET    /internal/v1/payments/reconciliation/daily-summary
  (k-1 build-time gate + runtime CONFIG.stage === "prod" → 404 ADMIN_ENDPOINT_DISABLED)

State machines

payments.settlement_files.file_status

State Set by
GENERATED (reserved for outbound files — v2)
SUBMITTED (reserved for outbound — v2)
ACKNOWLEDGED After file is ingested and persisted; before matching
SETTLED After matching with 0 exceptions
PARTIALLY_SETTLED After matching with ≥1 exception
REJECTED (reserved — sponsor returns whole-file rejection — v2)

payments.reconciliation_exceptions.exception_status

State Set by
OPEN Initial INSERT on classification
UNDER_REVIEW Ops marked it via admin endpoint
RESOLVED Ops marked it via admin endpoint
ESCALATED Escalation Lambda flipped it after > 2 BD open

v1 active rails (j-3 ruling)

Rail Match v1? Notes
BPAY Looks up payments.bpay_payments.sponsor_reference
NPP Looks up payments.osko_payments.end_to_end_id
RTGS ingest-only stub match handler short-circuits with log
SWIFT ingest-only stub same
DIRECT_ENTRY ingest-only stub (BECS in event-catalogue prior to amendment)
CARD ingest-only stub same

Intra-bank transfers (MOD-141) settle instantly via MOD-001 within the same Postgres transaction — no settlement file ever lands, so they're out of scope for MOD-081 entirely.

k-rulings applied

Ruling Implementation
j-1 No MOD-001 posting Match writes exceptions + updates file_status. No mod001-client.ts. No cross-domain Lambda invoke grant required. Reversal-on-short-settle is a v2 follow-on gated on MOD-064 (ops queue) shipping.
j-2 exception_type enum Migration V002 CHECK + Zod ExceptionTypeEnum + event JSON schema all use the data-model set (UNMATCHED_OUTBOUND / SHORT_SETTLED / OVER_SETTLED / DUPLICATE / MISSING_RETURN / UNMATCHED_INBOUND). Event catalogue requires amendment — see "Wiki amendments" below.
j-3 rail enum Migrations + Zod + event JSON schema use the data-model set (NPP / RTGS / SWIFT / DIRECT_ENTRY / BPAY / CARD). Event catalogue field renamed scheme → rail.
j-4 Bucket ownership infra/s3.ts provisions bank-payments-settlement-files-{stage} with SSE-AWS-KMS, public access block, versioning, 90-day Glacier lifecycle on incoming/. SSM output published at /bank/{stage}/mod-081/settlement-bucket-name.
j-5 No MOD-064 Escalation is a structured exception_escalated log + EMF metric. reconciliation_exception_raised event publishes for whichever consumer materialises — MOD-064 when it ships.
j-6 NFR-006 removed Documented in module YAML wiki amendment (interest-accrual SLA belongs to SD01).
j-7 Daily summary New payments.reconciliation_daily_summary table (V003 migration), append-only with ADR-048 Cat 1 immutability trigger. New bank.payments.reconciliation_daily_summary event added to catalogue. MOD-042 CDC replicates to Snowflake for treasury analytics.
k-1 Admin endpoints Build-time skip in infra/functions.ts and infra/api.ts for prod. Runtime CONFIG.stage === "prod" → 404 ADMIN_ENDPOINT_DISABLED defence in depth.

Database tables

V001 — payments.settlement_files — per SD04 data model (rail enum per j-3, trace_id NOT NULL, file_reference UNIQUE). REP-005 grants: SELECT/INSERT/UPDATE.

V002 — payments.reconciliation_exceptions — per SD04 data model (exception_type enum per j-2, payment_id soft FK / settlement_file_id hard FK, trace_id NOT NULL). REP-005 grants: SELECT/INSERT/UPDATE.

V003 — payments.reconciliation_daily_summary — new table for j-7. Append-only: grants SELECT/INSERT only, no UPDATE/DELETE. ADR-048 Cat 1 immutability trigger blocks UPDATE/DELETE at the DB layer. UNIQUE(summary_date, rail, currency, generated_at).

V900 — dev/uat seed pre-populating two SETTLED files (yesterday + 2 days ago) so the daily-report Lambda has something to aggregate.

SSM outputs

Path Value
/bank/{stage}/mod-081/api/base-url API Gateway URL (dev/uat only)
/bank/{stage}/mod-081/ingest-lambda/arn ingest Lambda ARN
/bank/{stage}/mod-081/match-lambda/arn match Lambda ARN
/bank/{stage}/mod-081/escalate-lambda/arn escalate Lambda ARN
/bank/{stage}/mod-081/daily-report-lambda/arn daily-report Lambda ARN
/bank/{stage}/mod-081/settlement-files-table payments.settlement_files
/bank/{stage}/mod-081/reconciliation-exceptions-table payments.reconciliation_exceptions
/bank/{stage}/mod-081/reconciliation-daily-summary-table payments.reconciliation_daily_summary
/bank/{stage}/mod-081/settlement-bucket-name bank-payments-settlement-files-{stage}

Upstream SSM dependencies

All MOD-104-owned paths use @bank-platform/mod-104-contracts/ssm typed builders per ADR-063: - mod104.iam.lambdaRoleArn(stage, "bank-payments") - mod104.sns.alertsArn(stage) - mod104.eventbridge.busArn(stage, "bank-payments")

Plus: - /bank/{stage}/observability/adot-nodejs-layer-arn (MOD-076 — hardcoded; contract not published yet) - Neon secret bank-neon/{stage}/bank_payments/app_user (MOD-103) - Schema registry bank-events-{stage} (MOD-043)

Reserved concurrency (k-10)

Lambda dev uat prod
ingest unbounded 15 50
match unbounded 10 30
escalate unbounded 2 5
daily-report unbounded 2 5
admin unbounded 5 n/a (Lambda absent in prod)

Wiki amendments required

The orchestrator's rulings (j-1 through j-7) imply the following amendments to bank-wiki. These are flagged for the orchestrator to apply on the bank-wiki side — MOD-081 ships green against the amended specs, but the wiki documentation hasn't caught up yet.

  1. bank-wiki/source/entities/modules/MOD-081.yaml:
  2. Change dependencies: MOD-001 to optional: true with reason "v2 — ledger write-back for reversal on short-settle".
  3. Remove requirements: NFR-006 (interest-accrual SLA — wrong system).
  4. bank-wiki/source/pages/design/system/event-catalogue.md:
  5. bank.payments.settlement_file_received: rename field scheme → rail; update enum to NPP / RTGS / SWIFT / DIRECT_ENTRY / BPAY / CARD.
  6. bank.payments.reconciliation_exception_raised: change exception_type enum to UNMATCHED_OUTBOUND / SHORT_SETTLED / OVER_SETTLED / DUPLICATE / MISSING_RETURN / UNMATCHED_INBOUND; add new fields payment_id (nullable), instructed_amount (nullable), variance (nullable), rail.
  7. Add new event bank.payments.reconciliation_daily_summary per the schema in MOD-081-payment-reconciliation/schemas/bank.payments.reconciliation_daily_summary.json.
  8. bank-wiki/source/pages/design/system/data-models/SD04-payments.md:
  9. Add the payments.reconciliation_daily_summary table definition (see V003 migration).

v1 known limitations

  • No reversal posting — short-settle / over-settle exceptions are recorded but no ledger entry is posted. Treasury action is manual via the admin endpoint. v2 work post-MOD-064 ships.
  • AU public holidays not in business-day calc — only Saturday/Sunday roll forward (same caveat as MOD-119's cut-off-time). When MOD-064 ops queue ships, extend business-day.ts with a calendar lookup.
  • Settlement file format is JSON for all rails — real BPAY is pacs.002 XML, NPP is camt.054 XML, SWIFT is MT9xx. The ADR-044 stub emits JSON for dev/uat. Format dispatch is per-rail (file-parsers/<rail>-parser.ts) so a real-format parser can be slotted in without touching the match engine.
  • No MOD-064 ops queue — exception routing is event-only.
  • No partial-file recovery — if match fails mid-file, the file_status doesn't transition cleanly. Lambda EventBridge retries handle the common case; sustained match failures require ops intervention.

Test surface

Tier Files Coverage
Unit business-day (6), exception-classifier (8+), match-engine (5+), report-builder (4+), 3 parsers (3 each), types (6), errors (4), amount, logger, emf ≥80% on pure modules
Contract 3 EB schemas + request shapes one per published event + admin requests
Policy PAY-002 AUTO, REP-005 LOG, admin-prod-disabled (k-1), observability-adr-031 one per policies_satisfied row + cross-cutting
Integration FR-157 ingest+match, FR-158 classification, FR-159 daily-summary, FR-160 escalation, idempotency, observability, audit-chain one per FR + chain
Smoke tests/verify-deployment.mjs SSM + Lambda + alarms + schemas + daily-report invoke