Technical design — MOD-136 BPAY biller registration & inbound BPAY¶
Module: MOD-136 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-605, FR-606, FR-607, FR-608 NFR scope: NFR-012 (Postgres write latency p99 ≤ 10 ms), NFR-019 (RTO/RPO Tier 1), NFR-024 (audit immutability) Policies satisfied: PAY-001 (AUTO), PAY-002 (LOG), REP-005 (LOG), CON-005 (AUTO — j-1 ruling) Author: AI coding agent (Claude) Date: 2026-05-19 Jurisdiction: Australia only
Objective¶
The complement to MOD-119: where MOD-119 handles outbound BPAY (our customers paying bills), MOD-136 handles inbound BPAY (our business-banking customers receiving bill payments registered as BPAY billers via the sponsor bank).
The flow is back-office driven: ops register a business customer as a biller via the sponsor → sponsor returns a BPAY code (typically 3–5 business days) → biller status flips to ACTIVE. Settlement files from the sponsor arrive daily on S3 (j-6 mirror of MOD-114) → per row: CRN validation → MOD-001 credit posting → MOD-063 notification within 60 s (FR-607). MOD-081 reconciliation deferred to v2 per j-5; v1 reconciliation is owned inside MOD-136.
Architecture¶
Back-office HTTP API (internal)
POST /internal/v1/payments/bpay-inbound/billers ─► biller-registration (FR-605)
GET /internal/v1/payments/bpay-inbound/billers?party_id={uuid}
GET /internal/v1/payments/bpay-inbound/billers/{biller_id}
PATCH /internal/v1/payments/bpay-inbound/billers/{biller_id}/activate
PATCH /internal/v1/payments/bpay-inbound/billers/{biller_id}/status
(dev/uat only — k-1, j-10)
POST /internal/v1/payments/bpay-inbound/_admin/inject-settlement-file
POST /internal/v1/payments/bpay-inbound/_admin/billers/{biller_id}/advance
S3-drop ingestion (j-6)
s3://bank-payments-bpay-inbound-files-{stage}/incoming/bpay/*.json
│ ObjectCreated:Put
▼
settlement-ingest.handler
│
│ per row (inbound-processor):
│ 1. selectByBpayCode — find the biller
│ 2. INSERT bpay_inbound_payments at RECEIVED
│ 3. FR-605 biller-status gate
│ → PENDING|SUSPENDED|CANCELLED → RETURNED
│ 4. FR-606 CRN validation
│ → fail → CRN_INVALID + RETURNED
│ 5. j-3 INSERT synthetic payments.payments
│ 6. MOD-001 credit (DEBIT clearing / CREDIT biller)
│ 7. Mark POSTED + publish bpay_inbound_received
│ (or POSTING_FAILED + RETURNED + bpay_inbound_returned)
│
▼ after all rows:
FR-608 reconciliation (j-5 — MOD-136-owned for v1)
per-row reconciliation_status MATCHED|UNMATCHED
+ INBOUND_RECONCILED batch audit row
State machine — payments.bpay_inbound_billers.status (j-2 table name):
PENDING_REGISTRATION → ACTIVE (FR-605 — sponsor confirmed code; j-10 staff PATCH or dev admin advance)
→ CANCELLED|SUSPENDED (manual ops)
State machine — payments.bpay_inbound_payments.status:
RECEIVED → CRN_VALID → POSTED (happy path)
→ POSTING_FAILED → RETURNED
→ CRN_INVALID → RETURNED
j-rulings applied (pre-build wiki amendments)¶
| Ruling | Implementation |
|---|---|
| j-1 CON-005 (not CON-001) | Wiki MOD-136.yaml retagged. con-005-auto.test.ts asserts the publish wiring + no-suppress tokens. |
j-2 bpay_inbound_billers (not bpay_billers) |
Distinct from MOD-119's bpay_biller_cache (outbound directory). |
j-3 Synthetic payments.payments row |
payments-row-writer.ts inserts one row per inbound credit with direction='INBOUND', payment_type='BPAY'. MOD-022's audit-consumer keys SETTLEMENT_CONFIRMED on this row when bank.core.posting_completed fires. |
| j-4 Three new EB events | bpay_biller_registered / bpay_inbound_received / bpay_inbound_returned. AJV-validated; MOD-136 owns the EB Schema Registry upload. |
| j-5 MOD-136 owns FR-608 recon in v1 | reconciliation.ts pure module classifies per-row + per-batch outcomes; settlement-ingest calls it after the per-row loop. v2 lifts to MOD-081. |
| j-6 S3-drop ingestion | bank-payments-bpay-inbound-files-{stage} bucket owned by MOD-136; ObjectCreated:Put → settlement-ingest Lambda. dependsOn the lambda:Permission per the MOD-114 fix. |
| j-7 CRN-validator copied from MOD-119 | crn-validator.ts is a verbatim copy. v2 cleanup: shared @bank-payments/bpay-crn package. |
| j-8 Uppercase CRN enum | LUHN / REGEX / FIXED_LENGTH / NONE (matches MOD-119 + SD04 standard). |
| j-9 dev/uat admin pattern (k-1) | Build-time skip in infra/functions.ts + infra/api.ts; runtime 404 ADMIN_ENDPOINT_DISABLED. v2 staff-authed (IAM or JWT) prod endpoint. |
| j-10 Activation flow | Prod = staff PATCH /activate once sponsor confirms; dev/uat = admin /_admin/billers/{id}/advance (auto-generates bpay_code + sponsor_confirmation_ref if not supplied). |
| k-1 Admin gated | Three-layer (build-time skip + api.ts skip + runtime 404). |
| k-5 Clearing account | Per-environment SSM in prod; placeholder 0...000000136 in dev/uat. |
SSM outputs¶
| Path | Type | Notes |
|---|---|---|
/bank/{stage}/mod-136/api/base-url |
String | API Gateway URL |
/bank/{stage}/mod-136/biller-registration-lambda/arn |
String | |
/bank/{stage}/mod-136/settlement-ingest-lambda/arn |
String | |
/bank/{stage}/mod-136/admin-lambda/arn |
String | dev/uat only (k-1) |
/bank/{stage}/mod-136/bpay-inbound-files-bucket-name |
String | j-6 ingestion bucket |
/bank/{stage}/mod-136/bpay-inbound-billers-table |
String | payments.bpay_inbound_billers |
/bank/{stage}/mod-136/bpay-inbound-payments-table |
String | payments.bpay_inbound_payments |
/bank/{stage}/mod-136/bpay-inbound-events-table |
String | payments.bpay_inbound_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 credit posting (grant in place from MOD-119/120/122/114) |
/bank/{stage}/mod-136/bpay-inbound-clearing-account-id |
(treasury seed; prod-only) | BPAY inbound clearing GL |
Events¶
Published (on bank-payments bus):
- bank.payments.bpay_biller_registered — biller status → ACTIVE.
- bank.payments.bpay_inbound_received — credit posted (FR-607).
- bank.payments.bpay_inbound_returned — quarantined (FR-606 / FR-605 / posting failure).
Schemas in schemas/ (draft-04, AJV-validated). MOD-136 owns the EB
Schema Registry upload.
Consumed: none directly. MOD-022 + MOD-063 catch-all rules on
bank.payments.* already cover them.
Postgres tables¶
Owned:
- payments.bpay_inbound_billers (V001) — registration registry.
- payments.bpay_inbound_payments (V002) — per-row settlement ledger.
- payments.bpay_inbound_events (V003) — append-only audit
(ADR-048 Cat 1 immutability trigger).
Written cross-table:
- payments.payments — synthetic row per inbound credit (j-3,
payment_type='BPAY', direction='INBOUND').
- payments.idempotency_records (MOD-021 V007) — Powertools store.
CloudWatch alarms¶
| Name | Trigger |
|---|---|
bank-{stage}-MOD-136-biller-registration-errors |
≥ 1 Lambda Error in 3 of 5 min |
bank-{stage}-MOD-136-settlement-ingest-errors |
≥ 1 Lambda Error in 3 of 5 min |
bank-{stage}-MOD-136-settlement-ingest-p99-latency |
p99 ≥ 30 s |
bank-{stage}-MOD-136-inbound-returned-rate |
≥ 20 RETURNED in 1 h (FR-606 signal) |
bank-{stage}-MOD-136-recon-unmatched-rate |
≥ 5 unmatched in 2 h (FR-608 escalation) |
v1 known limitations¶
- Real BPAY inbound file format — v1 accepts a JSON envelope. v2 swaps the file-parser to the sponsor's real format.
- MOD-081 reconciliation deferred (j-5) — v2 lifts FR-608 into MOD-081's V1_MATCH_RAILS = [BPAY-inbound, …].
- Production back-office HTTP authoriser — v1 has no IAM/JWT staff authoriser; the biller-registration endpoints are wired but presumed back-office-network-restricted. v2 adds an IAM authoriser.
- Hard-coded GL codes —
2100(customer-deposits) +2250(BPAY inbound clearing). v2 derives fromaccounts.account_products. - CRN-validator code duplication (j-7) — copied from MOD-119
verbatim. v2 lifts to a shared
@bank-payments/bpay-crnpackage. - Reconciliation escalation — v1 marks unmatched + audits the batch; ops review unmatched rows manually. v2 builds an automated escalation that flips reconciliation_status → ESCALATED after the FR-608 "next business day" window expires.