Skip to content

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_reversed emission 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).

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's sponsor-client.ts overlays 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 by STAGE !== '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.md requests 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-url Lambda (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-id and threads it into the env.
  • BPAY_CLEARING_GL_CODE — default '2200'. Distinct from DEFAULT_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 NULL with no FK (cross-module ownership; payments.payments row 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_id slot on bpay_payments ready for MOD-081 to populate when it ships.
  • No MOD-053 dispute workflow (MOD-053 not built). The DISPUTED status 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