Skip to content

Technical design — MOD-137 Agency banking adapter

Module: MOD-137 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-609 (file ingestion + account matching + quarantine), FR-610 (posting + customer notification), FR-611 (AML threshold flag), FR-612 (settlement reconciliation) NFR scope: NFR-012 (Postgres write latency p99 ≤ 10 ms), NFR-019 (RTO/RPO Tier 1), NFR-024 (audit immutability) Policies satisfied: PAY-001 (GATE), AML-005 (AUTO), CON-001 (AUTO), PAY-002 (LOG) Author: AI coding agent (Claude) Date: 2026-05-20 Jurisdictions: AU + NZ

Objective

Agency banking lets customers perform cash transactions (deposits, withdrawals, balance enquiries) at third-party outlets — Australia Post primary, NZ Post and "other" networks configurable. Integration is file-based: the agency network drops daily batch files into S3 (operationally via SFTP-gateway upstream of S3); MOD-137 parses, matches each row to a live customer account, validates account status + available balance for withdrawals, posts via MOD-001, flags threshold-crossing cash transactions for AML reporting (FR-611), and reconciles the processed batch against the network's settlement file (FR-612).

Architecture

S3-drop ingestion (k-1 / k-6 — single bucket, two prefixes)
  s3://bank-payments-agency-files-{stage}/
     agency-files/transactions/{NETWORK}/{batch-date}/{file_ref}.csv   ─► batch-ingest
     agency-files/settlements/{NETWORK}/{batch-date}/{file_ref}.settlement.csv  ─► settlement-ingest

batch-ingest (k-9 idempotent on sha256(bucket:key))
  │ 1. parseAgencyFile (network-driven router; balance-enquiry filtered at parse)
  │ 2. insertReceived (file_reference UNIQUE — file-level idempotency)
  │ 3. insertUnmatched per parsed item
  │ 4. BATCH_RECEIVED audit + publish agency_batch_received
  │ 5. fire-and-forget invoke batch-processing
batch-processing (async fan-out)
  │  per item:
  │   1. account-matcher.findAccount (k-2 — cross-schema SELECT on core.accounts)
  │   2. validateItem (FR-609 — account-active + MOD-003 balance for withdrawals)
  │      → QUARANTINE → markQuarantined + TRANSACTION_QUARANTINED audit + publish
  │                     agency_item_quarantined
  │      → PASS continues
  │   3. detectAmlThreshold (FR-611 — runs alongside posting, never gates it)
  │   4. MOD-001 PAYMENT posting:
  │        DEPOSIT     → CREDIT customer / DEBIT clearing GL (2270)
  │        WITHDRAWAL  → DEBIT  customer / CREDIT clearing GL
  │   5. markPosted + TRANSACTION_POSTED audit
  │   6. if AML flagged → TRANSACTION_AML_FLAGGED audit + publish
  │                       agency_aml_threshold_flagged
  │  after all items:
  │   7. markSettled + BATCH_SETTLED audit + publish agency_batch_settled
settlement-ingest (FR-612 / k-3 — MOD-137 v1 owner)
  │ 1. parseSettlement (small CSV: file_reference + per-type counts + totals)
  │ 2. selectByFileReference
  │ 3. totalsForBatch (per-type processed totals from agency_transactions)
  │ 4. reconcile()  matched → markReconciled + BATCH_RECONCILED audit
  │                 mismatch → markExceptions + BATCH_EXCEPTIONS audit +
  │                            publish agency_reconciliation_discrepancy

Customer HTTP API (read-only)
  GET   /internal/v1/payments/agency/batch/{batch_id}
  GET   /internal/v1/payments/agency/batch?limit=N

Admin (dev/uat only — k-1)
  POST  /internal/v1/payments/agency/_admin/inject-batch
  POST  /internal/v1/payments/agency/batch/{batch_id}/_admin/advance-processing
  POST  /internal/v1/payments/agency/batch/{batch_id}/_admin/inject-settlement

State machine — payments.agency_batch_files.status:
  RECEIVED → PROCESSING → SETTLED → RECONCILED | EXCEPTIONS

State machine — payments.agency_transactions.status:
  UNMATCHED → MATCHED → POSTED | QUARANTINED

k-rulings applied (pre-build wiki amendments)

Ruling Implementation
k-1 S3-drop ingestion (no SFTP gateway in v1) bank-payments-agency-files-{stage} bucket owned by MOD-137; ObjectCreated:Put on agency-files/transactions/ triggers batch-ingest; SFTP gateway sits operationally upstream and is out of MOD-137's scope.
k-2 Direct Postgres SELECT on core.accounts account-matcher.ts queries core.accounts(id, bsb, account_number, status) via the same Neon pool — soft FK, no Lambda hop, no cross-domain IAM grant. Cross-schema read grant required for bank_payments_app_user.
k-3 MOD-137 owns FR-612 in v1 reconciliation.ts pure module classifies per-type counts + totals; settlement-ingest runs it after batch SETTLED. MOD-081 adds AGENCY to V1_MATCH_RAILS in v2. MOD-081 marked optional in MOD-137.yaml.
k-4 EB event for AML reporting bank.payments.agency_aml_threshold_flagged carries terminal_id + agent_outlet_code (FR-611 required location metadata). Consumed by SD03 MOD-016 (typology engine) + SD06 MOD-037 (regulatory reporting via CDC). Not ifti_cmir_queue — that's the cross-border surface.
k-5 SSM-driven AML thresholds /bank/{stage}/mod-137/aml/deposit-threshold-{aud,nzd} per stage. Loaded at cold start, env-injected. Default 10000.00 in dev/uat; treasury seeds prod via SSM. Operator-adjustable without code change.
k-6 Two S3 prefixes + two Lambdas Single bucket, two distinct lambdaFunctions entries on the BucketNotification (transactions/ → batch-ingest, settlements/ → settlement-ingest). dependsOn the lambda:Permission per the MOD-114/135/136 race-fix.
k-7 Per-transaction notification FR-610 verbatim; MOD-063 catch-all on bank.payments.* consumes agency_batch_settled. Agency volumes are modest — no fan-out concerns.
k-8 Wiki amendments delivered bank-wiki commit 6770ad39 added the 3 tables + 5 events to SD04 data model + event catalogue before this build.
k-9 Dual idempotency (a) file_reference UNIQUE on payments.agency_batch_files. (b) Powertools makeIdempotent on the S3 handler keyed on sha256(bucket:key) via payments.idempotency_records.
k-10 Uppercase enums agency_network, transaction_type, status, event_type, entity_type all UPPERCASE per SD04 convention.
k-11 MOD-103 + MOD-104 deps Added to MOD-137.yaml dependencies as optional: false (platform foundations).

SSM outputs

Path Type Notes
/bank/{stage}/mod-137/api/base-url String API Gateway URL (read-only routes + admin in dev/uat)
/bank/{stage}/mod-137/batch-ingest-lambda/arn String S3-event triggered
/bank/{stage}/mod-137/batch-processing-lambda/arn String Async fan-out
/bank/{stage}/mod-137/settlement-ingest-lambda/arn String S3-event triggered
/bank/{stage}/mod-137/batch-status-lambda/arn String HTTP read-only
/bank/{stage}/mod-137/admin-lambda/arn String dev/uat only (k-1)
/bank/{stage}/mod-137/agency-files-bucket-name String k-1 / k-6 ingestion bucket
/bank/{stage}/mod-137/agency-batch-files-table String payments.agency_batch_files
/bank/{stage}/mod-137/agency-transactions-table String payments.agency_transactions
/bank/{stage}/mod-137/agency-batch-events-table String payments.agency_batch_events (immutable)

Upstream SSM consumed

Path Source Use
/bank/{stage}/iam/lambda/bank-payments/arn MOD-104 Role
/bank/{stage}/observability/adot-nodejs-layer-arn MOD-076 OTel
/bank/{stage}/sns/alerts/arn MOD-104 Alarms
/bank/{stage}/eventbridge/bank-payments/arn MOD-104 Publish bus
/bank/{stage}/neon/pooler-host + bank-neon/{stage}/payments_app_user MOD-103 DB
/bank/{stage}/mod-001/lambda/arn MOD-001 Cross-domain MOD-001 PAYMENT (grant in place from MOD-119/120/122/114/135/136)
/bank/{stage}/mod-003/balance-query-lambda/arn MOD-003 Per-withdrawal balance gate (FR-609)
/bank/{stage}/mod-137/aml/deposit-threshold-{aud,nzd} (treasury seed; prod-only) k-5
/bank/{stage}/mod-137/agency-clearing-account-id (treasury seed; prod-only) Agency clearing GL account

Events

Published (on bank-payments bus, all 5 distinct per k-4): - bank.payments.agency_batch_received — file landed + parsed; per-item processing queued. - bank.payments.agency_batch_settled — all items terminal. - bank.payments.agency_item_quarantined — account-not-found / inactive / insufficient-balance / posting-failed (FR-609). - bank.payments.agency_aml_threshold_flagged — AML-005 / FR-611. Carries terminal_id + agent_outlet_code. Consumed by SD03 MOD-016 + SD06 MOD-037. - bank.payments.agency_reconciliation_discrepancy — FR-612 mismatch flagged to ops.

Schemas in schemas/ (draft-04, AJV-validated).

Consumed: none directly. MOD-022 + MOD-063 catch-all rules on bank.payments.* already cover all 5 events.

Postgres tables

Owned: - payments.agency_batch_files (V001) — mutable batch metadata. - payments.agency_transactions (V002) — per-line transaction rows. - payments.agency_batch_events (V003) — append-only audit (ADR-048 Cat 1 immutability).

Soft FKs (cross-DB / cross-schema, no enforced FK): - payments.agency_transactions.account_id → SD01 core.accounts(id) (k-2 cross-schema read). - payments.agency_transactions.posting_id → SD01 accounts.postings(id).

Idempotency: - payments.agency_batch_files.file_reference UNIQUE (k-9 file-level). - payments.idempotency_records (MOD-021 V007) — Powertools store for the S3 handler (sha256(bucket:key)).

CloudWatch alarms

Name Trigger
bank-{stage}-MOD-137-batch-ingest-errors ≥ 1 Lambda Error in 3 of 5 min
bank-{stage}-MOD-137-batch-processing-errors ≥ 1 Lambda Error in 3 of 5 min
bank-{stage}-MOD-137-settlement-ingest-errors ≥ 1 Lambda Error in 3 of 5 min
bank-{stage}-MOD-137-batch-status-errors ≥ 1 Lambda Error in 3 of 5 min
bank-{stage}-MOD-137-batch-processing-p99-latency p99 ≥ 12 min (Lambda budget guard)
bank-{stage}-MOD-137-quarantine-rate ≥ 20 QUARANTINED in 1 h (FR-609 quality signal)
bank-{stage}-MOD-137-recon-discrepancy-rate ≥ 3 discrepancies in 2 h (FR-612 escalation)
bank-{stage}-MOD-137-aml-flag-rate ≥ 50 AML flags in 1 h (visibility — adjust to baseline)

v1 known limitations

  • SFTP gateway is out of scope (k-1) — v1 assumes files land directly in S3. v2 may add an SFTP→S3 gateway as a separate module (or operational concern).
  • MOD-081 reconciliation deferred (k-3) — v1 reconciliation lives inside settlement-ingest. v2 lifts FR-612 into MOD-081's V1_MATCH_RAILS = [..., AGENCY].
  • Single CSV envelope for AU + NZ + OTHER — v2 splits parsers if the formats diverge. k-2 leaves OTHER falling back to the AU envelope with caller-supplied currency.
  • Account-matching by exact (BSB, account_number) — no fuzzy match in v1. Unmatched items quarantine for ops to manually resolve.
  • Balance enquiries are dropped at parse — the SD04 schema CHECK (amount > 0) on agency_transactions would forbid storing them. Counted on the batch row + logged via parse-summary, not modelled as rows. v2 may add an agency_balance_enquiries audit-only table.
  • Hard-coded GL codes2100 (customer-deposits) + 2270 (agency clearing). v2 derives from accounts.account_products per-account.
  • Customer-portal authoriser absent — read-only routes are wired but presumed back-office-network-restricted. v2 adds an IAM/JWT authoriser.
  • AML threshold cents-only — the threshold values are loaded as fixed-point decimals. Currency-pair-aware (AUD vs NZD) but no exchange-rate normalisation — i.e. an AUD 9999 cross-border transfer wouldn't be flagged. Out of scope for agency banking (always domestic).