Technical design — MOD-119 BPAY payment integration¶
Module: MOD-119 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-537, FR-538, FR-539, FR-540 NFR scope: ADR-005 NFR-025 (≤ 5s domestic-payment p99); inherited Policies satisfied: PAY-001 (GATE), PAY-005 (AUTO), PAY-009 (AUTO), CON-005 (AUTO), REP-005 (LOG) Author: AI coding agent (Claude) Date: 2026-05-08 Jurisdiction: Australia only
Objective¶
The second SD04 rail integration and the first to interact with an external scheme (per ADR-005, via the sponsor bank). Customers pay registered AU billers (utilities, council rates, ATO, insurance, etc.) through the BPAY scheme. v1 covers biller lookup with daily sponsor-directory refresh (FR-537), CRN validation against the biller's declared format (FR-538), payment submission + posting + settlement (FR-539), and biller-side returns processing with credit- back + plain-language notification (FR-540).
The MOD-020 → MOD-001 → MOD-022 chain exercised by MOD-141 is reused verbatim. New surfaces unique to MOD-119:
- HTTP biller-lookup endpoint with cache-first + sponsor fallback.
- Daily scheduled biller-directory refresh.
- HTTPS sponsor-bank submission with 5s timeout + reverse-on-failure.
- Async settlement and return processing (v1: admin-driven simulation).
bank.payments.payment_reversedemission on biller returns.
Architecture¶
HTTP API
GET /internal/v1/payments/bpay/billers/{biller_code} ─► biller-lookup.handler
POST /internal/v1/payments/bpay/submit ─► submit.handler
(dev/uat only)
POST /internal/v1/payments/bpay/_admin/settle/{id} ─► admin-settle.handler ─┐
POST /internal/v1/payments/bpay/_admin/return/{id} ─► admin-return.handler ─┤ k-8: SAME services
│ v2 webhook receivers will use
EventBridge schedule rate(1 day) │
── Mod119BillerDirectoryRefreshRule ─► biller-refresh.handler │
│
submit.handler: │
1. Zod-validate request │
2. Biller cache lookup + active check + amount bounds │
3. CRN validation (Luhn / regex / fixed-length / none) — k-3 PRE-MOD-020 │
4. Idempotency replay │
5. Cut-off evaluation (k-12 — Australia/Sydney + weekend roll) │
6. INSERT bpay_payments at PENDING │
7. submit-orchestrator (k-4): │
a. MOD-020 validate │
b. MOD-001 PAYMENT debit (DEBIT customer, CREDIT clearing) — atomic │
c. UPDATE PENDING → SUBMITTING │
d. sponsor HTTPS submit (5s budget, ADR-044 stub or real) │
├── 2xx → UPDATE SUBMITTING → SUBMITTED + sponsor_reference │
└── reject → MOD-001 REVERSAL (idempotency_key+':reversal') │
→ UPDATE → FAILED + SPONSOR_REJECTED|SPONSOR_TIMEOUT │
│
admin-settle / admin-return delegate to: settle-processor.ts / return-processor.ts ◄┘
return-processor:
1. Validate row state (must be SUBMITTED|SETTLED).
2. MOD-001 REVERSAL (idempotency_key+':return:'+code) — credit customer, debit clearing.
3. UPDATE → RETURNED + reversal_posting_id + reason_code + reason_text.
4. publish bank.payments.payment_reversed (BPAY_RETURN / BPAY_SCHEME) — MOD-063 picks up.
Audit chain (no MOD-119 emission of generic events):
MOD-020 publishes payment_initiated/_validated/_failed → MOD-022 audit-consumer →
payment_events SUBMITTED/VALIDATION_*
MOD-001 publishes posting_completed → MOD-022 audit-consumer → SETTLEMENT_CONFIRMED
MOD-119 publishes payment_reversed (BPAY returns only) → MOD-022 v2 + MOD-063
State machine (k-4)¶
payments.bpay_payments.status:
| State | Set by |
|---|---|
PENDING |
Initial INSERT |
SUBMITTING |
After MOD-001 debit posting commits, before sponsor call |
SUBMITTED |
Sponsor accepted (2xx) |
SETTLED |
Sponsor settlement confirmation (admin endpoint v1; webhook v2) |
RETURNED |
Biller-initiated return; reversal posting committed |
FAILED |
Validation rejection, posting error, sponsor reject/timeout (with reversal), or REVERSAL_FAILED_AFTER_SPONSOR_REJECT (rare hanging-debit signal) |
DISPUTED |
(Reserved) v2 / MOD-053 dispute workflow |
CRN validation (FR-538, k-3)¶
crn-validator.ts is a pure module called at handler entry, before
any cross-module call. Four algorithms:
- LUHN — modulus-10 check digit; CRN must be digits-only (≥ 2 digits)
- REGEX — match the biller's
crn_regex - FIXED_LENGTH — exact length match + (optional) regex
- NONE — accept any non-empty string
CRN failure produces 422 + error_code='INVALID_CRN' + the biller-specific
reason — no debit, no MOD-020 invocation, no sponsor call.
Cut-off handling (k-12)¶
cut-off-time.ts is pure; computes wall-clock AEST via
Intl.DateTimeFormat('Australia/Sydney') (DST-aware). Past cut-off →
value_date = next AEST business day. Saturday/Sunday submissions
roll forward to Monday irrespective of clock time. AU public holidays
are out of v1 scope (documented v2 follow-up).
Sponsor-bank stub strategy (ADR-044, k-1)¶
MOD-119 reads the sponsor BPAY base URL from
/bank-payments/{stage}/clearing/bpay/base-url:
- dev/uat — points at MOD-157's stub.
- Today MOD-157's BPAY stub is a placeholder happy-path
(
outcome: "accepted"); MOD-119'ssponsor-client.tsoverlays pattern-driven simulation on the response:FAIL-*biller_code → SPONSOR_REJECTED,PASS-*/RETURN-*→ accepted. - The async settlement and return paths are driven by internal
admin endpoints in v1 —
POST /_admin/settle/{id}+POST /_admin/return/{id}— gated bySTAGE !== 'prod'at BUILD time (the Lambdas don't exist in prod) AND at handler entry (defensive 404 if somehow invoked). - File
MOD-157-bpay-async-stub-extension.handoff.mdrequests v2 work to drive the same flows through MOD-157's async-callback-firer instead. - prod — points at the real sponsor bank's BPAY API. The
sync-rejection path works identically; the async settlement /
return webhooks need MOD-119's
callback-urlLambda (v2 work) so the sponsor can push events to us. v1 of MOD-119 in prod is submit-only — settlement + returns require the v2 webhook receivers.
BPAY clearing account (k-5)¶
Both legs of the MOD-001 debit posting reference real
accounts.accounts(id) rows. The customer's from_account_id is
caller-supplied; the credit leg goes to an internal BPAY clearing
GL account configured via env var:
BPAY_CLEARING_ACCOUNT_ID— fail-fast at Lambda cold start if unset. The SST infra reads/bank/{stage}/mod-119/bpay-clearing-account-idand threads it into the env.BPAY_CLEARING_GL_CODE— default'2200'. Distinct fromDEFAULT_CUSTOMER_DEPOSITS_GL_CODE = '2100'used for the customer leg.
For dev/uat the deployer points the SSM at any seeded bank-core
account; the real BPAY clearing GL account is provisioned out-of-band
in prod (handoff filed at
docs/handoffs/MOD-001-bpay-clearing-account-seed.handoff.md). v2
work standardises this for all Tier D rails.
Settlement linkage (reuses MOD-022's k-1 chain)¶
Same convention as MOD-141: the idempotency_key threads through
MOD-020 + MOD-001 unchanged, and a fresh payment_id is minted at
submit handler entry. MOD-022's audit-consumer falls back to
idempotency_key to resolve the parent payments.payments row when
bank.core.posting_completed lacks payment_id (the bank-core
schema bump is filed but pending).
payment_reversed event (k-9)¶
MOD-119 emits bank.payments.payment_reversed on biller returns
only. Schema (post-2026-05-08 amendment):
reversal_reason: "BPAY_RETURN"reversed_by: "BPAY_SCHEME"trace_id(ADR-031)
Sponsor-rejected returns (the SUBMITTING → FAILED reversal) are
internal failure handling and do NOT publish payment_reversed —
that bus event is reserved for biller-side returns where the
customer needs notification (FR-540).
MOD-022 v2 will pick up payment_reversed for the audit chain.
MOD-063 (Deployed) consumes the catalogue event for FR-540's
plain-language notification dispatch.
payments.bpay_payments + payments.bpay_biller_cache schemas (V001 + V002)¶
Both per the SD04 wiki amendment landed pre-build (k-6 ruling). Highlights:
- Uppercase status enums consistent with rest of SD04 (PENDING / SUBMITTING / SUBMITTED / SETTLED / RETURNED / DISPUTED / FAILED).
payment_id uuid NOT NULLwith no FK (cross-module ownership;payments.paymentsrow created by MOD-020 in the same chain).- UNIQUE
(idempotency_key)standalone. trace_id uuid NOT NULL(ADR-031).bpay_biller_cache.crn_format CHECK ∈ {LUHN, REGEX, FIXED_LENGTH, NONE}.bpay_biller_cache.source CHECK ∈ {SPONSOR_DIRECTORY, DEV_SEED}to distinguish ADR-044 dev seed rows from real sponsor data.
V900 seeds dev/uat with PASS- / FAIL- / RETURN-* test billers per ADR-044 §3 plus realistic CRN-format examples (FIXED_LENGTH, REGEX, LUHN, accepted_amounts).
v1 known limitations¶
- No real async sponsor flows in prod yet. Production v1 is submit-only — settlement + returns will land when the v2 webhook receivers are built (MOD-157 BPAY stub extension).
- Daily biller-directory refresh is a no-op against MOD-157's v1 stub — the schedule fires; the handler tolerates 404/501; the cache stays at V900-seeded data. Production gets real sponsor data.
- No MOD-081 reconciliation integration (MOD-081 not built). v1
records
batch_idslot onbpay_paymentsready for MOD-081 to populate when it ships. - No MOD-053 dispute workflow (MOD-053 not built). The
DISPUTEDstatus enum value exists for back-office tooling but no v1 code path sets it. - No public-holiday-aware cut-off — only Saturday/Sunday roll forward. AU public holidays are v2 work.
- Hardcoded GL codes —
'2100'customer-deposits +'2200'BPAY-clearing. v2: per-account-product GL lookup via SD01.
SSM outputs published by MOD-119¶
| Path | Value |
|---|---|
/bank/{stage}/mod-119/api/base-url |
API Gateway URL |
/bank/{stage}/mod-119/submit/url |
Full submit endpoint |
/bank/{stage}/mod-119/billers/url |
Biller lookup endpoint template |
/bank/{stage}/mod-119/submit-lambda/arn |
submit Lambda ARN |
/bank/{stage}/mod-119/biller-lookup-lambda/arn |
lookup Lambda ARN |
/bank/{stage}/mod-119/biller-refresh-lambda/arn |
refresh Lambda ARN |
/bank/{stage}/mod-119/bpay-payments-table |
payments.bpay_payments |
/bank/{stage}/mod-119/bpay-biller-cache-table |
payments.bpay_biller_cache |
/bank-payments/{stage}/clearing/bpay/callback-url |
(v1: not-yet-wired stub; v2: real Lambda function URL for MOD-157 webhook callbacks) |
Upstream SSM dependencies¶
| Path | Used for |
|---|---|
/bank/{stage}/iam/lambda/bank-payments/arn |
BankPaymentsRole |
/bank/{stage}/observability/adot-nodejs-layer-arn |
OTel layer |
/bank/{stage}/sns/alerts/arn |
alarm SNS topic |
/bank/{stage}/neon/pooler-host |
DB connection |
/bank/{stage}/eventbridge/bank-payments/arn |
publish bus |
/bank/{stage}/mod-020/validate-payment-lambda/arn |
gate |
/bank/{stage}/mod-001/lambda/arn |
post-posting (cross-domain; combined MOD-104 grant) |
/bank-payments/{stage}/clearing/bpay/base-url |
sponsor (MOD-157 stub in dev/uat; real in prod) |
/bank/{stage}/mod-119/bpay-clearing-account-id |
BPAY clearing GL account (k-5) |
Reserved concurrency (k-11)¶
| Stage | submit | biller-lookup | biller-refresh | admin-* |
|---|---|---|---|---|
| dev | unbounded | unbounded | n/a | n/a (Lambda exists) |
| uat | 30 | 15 | n/a | n/a (Lambda exists) |
| prod | 100 | 50 | n/a | n/a (Lambda doesn't exist) |
Test surface¶
| Tier | Files | Coverage |
|---|---|---|
| Unit | crn-validator (14 cases — Luhn + regex + fixed-length + none), cut-off-time (8 — AEST/weekend/cut-off), reason-mapper, submit-orchestrator (9 — k-4 happy + reversal + gate-rejection branches), errors, amount, trace, logger, emf, types | ≥80% line + function on the gated pure modules |
| Contract | payment_reversed schema (k-9), submit request shape | one per published event + one per request |
| Policy | PAY-001 GATE (5), PAY-005 AUTO (4), PAY-009 AUTO (4), CON-005 AUTO (3), REP-005 LOG (4) | one per policies_satisfied row |
| Integration | FR-537 biller lookup (3), FR-538 CRN validation (3), FR-539 submit (2), FR-540 return (1), idempotency, observability, admin-prod-disabled | one per FR + idempotency + chain |
| Smoke | tests/verify-deployment.mjs |
SSM + Lambda + alarms + schemas + biller-lookup invoke |