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.
bank-wiki/source/entities/modules/MOD-081.yaml:- Change
dependencies: MOD-001tooptional: truewith reason "v2 — ledger write-back for reversal on short-settle". - Remove
requirements: NFR-006(interest-accrual SLA — wrong system). bank-wiki/source/pages/design/system/event-catalogue.md:bank.payments.settlement_file_received: rename fieldscheme → rail; update enum toNPP / RTGS / SWIFT / DIRECT_ENTRY / BPAY / CARD.bank.payments.reconciliation_exception_raised: changeexception_typeenum toUNMATCHED_OUTBOUND / SHORT_SETTLED / OVER_SETTLED / DUPLICATE / MISSING_RETURN / UNMATCHED_INBOUND; add new fieldspayment_id(nullable),instructed_amount(nullable),variance(nullable),rail.- Add new event
bank.payments.reconciliation_daily_summaryper the schema inMOD-081-payment-reconciliation/schemas/bank.payments.reconciliation_daily_summary.json. bank-wiki/source/pages/design/system/data-models/SD04-payments.md:- Add the
payments.reconciliation_daily_summarytable 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.tswith 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 |