Technical design — MOD-120 PayID and Osko integration¶
Module: MOD-120 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-541, FR-542, FR-543, FR-544 NFR scope: NPP 24/7/365 availability; FR-542 ≤ 2s p95 PayID resolution; FR-543 ≤ 5s p95 inbound credit posting Policies satisfied: PAY-001 (GATE), PAY-005 (AUTO), PAY-009 (AUTO), CON-005 (AUTO), AML-005 (LOG) Author: AI coding agent (Claude) Date: 2026-05-08 Jurisdiction: Australia only
Objective¶
The third SD04 rail integration and the second to interact with an external scheme (per ADR-005, via the sponsor bank Cuscal). Customers register PayIDs (mobile / email / ABN proxies) for receiving Osko payments, resolve other parties' PayIDs to a confirmed display name, submit irrevocable real-time Osko payments via NPP, and receive inbound NPP credits with prompt notification.
The MOD-020 → MOD-001 → MOD-022 chain exercised by MOD-141 and MOD-119 is reused verbatim. Surfaces unique to MOD-120:
- HTTP PayID lifecycle endpoints (register / list / patch / deregister) with synchronous sponsor-directory provisioning (k-13).
- HTTP PayID resolve endpoint with cache-first + sponsor fallback —
returns Scam-Safe Accord context (
is_first_time_payee, threshold). - HTTP Osko send endpoint with handler-level k-3 name confirmation enforcement, server-side first-time-payee + high-value-ack gate (k-4).
- Production sponsor-webhook receiver
/osko/inbound(k-9 secret check). - dev/uat-only admin endpoints (
_admin/inbound-credit,_admin/complete,_admin/return) gated by build-time + runtime guards (k-1). bank.payments.payment_reversedemission on Osko returns (OSKO_RETURN / NPP_SCHEME — k-2, k-9 wiki amendment).
Architecture¶
HTTP API (internal)
POST /internal/v1/payments/payid/register ─► payid-management.handler
GET /internal/v1/payments/payid/me ─► payid-management.handler
PATCH /internal/v1/payments/payid/{id} ─► payid-management.handler
DELETE /internal/v1/payments/payid/{id} ─► payid-management.handler
POST /internal/v1/payments/payid/resolve ─► payid-resolve.handler
POST /internal/v1/payments/osko/send ─► osko-send.handler
POST /internal/v1/payments/osko/inbound ─► osko-inbound.handler (prod webhook, k-9)
(dev/uat only)
POST /internal/v1/payments/osko/_admin/inbound-credit ─► osko-inbound.handler ─┐
POST /internal/v1/payments/osko/_admin/complete/{id} ─► osko-admin.handler ─┤ k-8: SAME services
POST /internal/v1/payments/osko/_admin/return/{id} ─► osko-admin.handler ─┘ v2 webhook receivers will use
osko-send.handler:
1. Zod-validate request (k-2)
2. Idempotency replay
3. Re-resolve PayID via local cache → sponsor (k-3 NAME_CONFIRMATION_MISMATCH at handler)
4. First-time-payee + high-value-ack check (k-4 — server-blocking, no client trust)
5. INSERT osko_payments at PENDING + end_to_end_id (UUID) + trace_id
6. send-orchestrator (k-4 state machine):
a. PENDING → SUBMITTING + posting_id (MOD-001 PAYMENT debit DEBIT customer / CREDIT NPP_CLEARING)
b. sponsor HTTPS submit (5s budget, ADR-044 stub or real Cuscal)
├── 2xx → SUBMITTING → PROCESSING + sponsor_reference
└── reject → MOD-001 REVERSAL (idempotency_key+':reversal')
→ SUBMITTING → FAILED + SPONSOR_REJECTED|SPONSOR_TIMEOUT
→ on REVERSAL fail: REVERSAL_FAILED_AFTER_SPONSOR_REJECT (rare hanging-debit signal)
osko-inbound.handler:
1. k-9 webhook auth (production /inbound only — x-sponsor-secret vs SSM)
2. k-1 prod 404 guard for the dev/uat _admin/inbound-credit variant
3. delegate to processInbound: INSERT INBOUND row → MOD-001 credit posting
(DEBIT NPP_CLEARING / CREDIT recipient) → UPDATE COMPLETED → publish event
osko-admin.handler (dev/uat only):
1. k-1 build-time gate: Lambda not created when stage='prod'
2. k-1 runtime guard: stage==='prod' → 404 + ADMIN_ENDPOINT_DISABLED
3. _admin/complete delegates to processComplete ─┐
4. _admin/return delegates to processReturn ─┴ k-8 reusable processors
processReturn (return-processor.ts):
1. Validate row state (must be SUBMITTING|PROCESSING|COMPLETED).
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 (OSKO_RETURN / NPP_SCHEME) — MOD-063 picks up.
Audit chain (no MOD-120 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-120 publishes payment_reversed (Osko returns only) → MOD-022 v2 + MOD-063
State machine (k-4)¶
payments.osko_payments.status:
| State | Set by |
|---|---|
PENDING |
Initial INSERT |
SUBMITTING |
After MOD-001 debit posting commits, before sponsor call |
PROCESSING |
Sponsor accepted (2xx) — NPP clearing in flight |
COMPLETED |
Sponsor settlement confirmation (admin endpoint v1; webhook v2) |
RETURNED |
Sponsor-initiated return; reversal posting committed |
FAILED |
Validation rejection, posting error, sponsor reject/timeout (with reversal), or REVERSAL_FAILED_AFTER_SPONSOR_REJECT |
DISPUTED |
(Reserved) v2 / MOD-053 dispute workflow |
INBOUND payments skip PENDING/SUBMITTING; insert at PROCESSING and move to COMPLETED on credit-posting success or FAILED on posting error.
Name confirmation (k-3, CON-005)¶
payid-resolver.ts returns the authoritative display_name from
the local cache (registered PayIDs) or sponsor directory. The
/payid/resolve endpoint surfaces this to the client UI as the value
the customer must see verbatim. The /osko/send handler re-resolves
the PayID at submit time and rejects 422 with
error_code='NAME_CONFIRMATION_MISMATCH' when
request.confirmed_display_name !== resolved.display_name.
The orchestrator (send-orchestrator.ts) does NOT re-check (would be
redundant) and does NOT accept any skipNameMatch / forceConfirmed
override token — verified by the con-005-auto.test.ts policy test.
First-time-payee + high-value gate (k-4)¶
first-time-payee.ts runs:
SELECT COUNT(*) FROM payments.osko_payments
WHERE direction='OUTBOUND' AND from_account_id=$1 AND payid_value=$2 AND status NOT IN ('FAILED')
The osko-send handler combines the result with
amount > HIGH_VALUE_THRESHOLD_AUD (default '1000.00'). When both
true, requires request.acknowledged_high_value === true or rejects
422 HIGH_VALUE_ACK_REQUIRED. A partial index
(idx_osko_payments_first_time_payee_lookup) makes the lookup O(log n).
The Scam-Safe Accord 5s friction warning is the client UI's responsibility; the server enforcement is the irrevocable gate at submit time so a thinned client (or an attacker bypassing the UI) cannot send high-value to a brand-new payee silently.
NPP clearing account (k-5)¶
Both legs of the OUTBOUND debit posting (and INBOUND credit posting)
reference real accounts.accounts(id) rows. The customer's
from_account_id is caller-supplied; the clearing leg goes to an
internal NPP/Osko clearing GL account configured via env var:
NPP_CLEARING_ACCOUNT_ID— fail-fast at Lambda cold start in prod if unset. dev/uat ships with sentinel placeholder UUID00000000-0000-0000-0000-000000000120. The SST infra reads/bank/prod/mod-120/npp-clearing-account-idin prod and threads it into the env.BPAY_CLEARING_GL_CODE'2210'(NPP/Osko clearing) andDEFAULT_CUSTOMER_DEPOSITS_GL_CODE'2100'(customer leg).
Handoff filed at
docs/handoffs/MOD-001-npp-clearing-account-seed.handoff.md for
bank-core to seed dev/uat. Production seeded out-of-band by treasury.
NPP is 24/7/365 — no cut-off (k-15)¶
Unlike BPAY (MOD-119), NPP has no business-day cut-off and no
value_date concept. The cut-off-time module from MOD-119 is NOT
imported anywhere in MOD-120. The policy test
tests/policy/no-cut-off-import.test.ts enforces this: it scans
src/ and infra/ for any reference to cut-off-time,
evaluateCutOff, cut_off_time_aest, or CUT_OFF_TIME_AEST and
fails the build if any appear.
Sponsor-bank stub strategy (ADR-044, k-1)¶
MOD-120 reads the sponsor NPP base URL from
/bank-payments/{stage}/clearing/npp/base-url:
- dev/uat — points at MOD-157's stub.
- Today MOD-157's NPP stub is the placeholder happy-path;
MOD-120's
sponsor-client.tsoverlays pattern-driven simulation on the response per ADR-044 §3:mobile:+61400-FAIL-*→ SPONSOR_REJECTED on submit;mobile:+61400-RTRN-*→ accepted then biller-side return;mobile:+61400-PASS-*/ default → accepted. - The async settlement and return paths are driven by internal
admin endpoints in v1 —
_admin/complete,_admin/return,_admin/inbound-credit— gated bySTAGE !== 'prod'at BUILD time (the Lambda doesn't exist in prod) AND at handler entry (defensive 404 if somehow invoked). - File
MOD-157-npp-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 NPP API. The
sync-rejection path works identically; the async settlement and
inbound-credit paths use the production webhook receiver
/osko/inboundwith the k-9x-sponsor-secretheader check.
Sponsor webhook auth (k-9)¶
The production /osko/inbound route requires an x-sponsor-secret
header matching process.env.SPONSOR_WEBHOOK_SECRET. The SST infra
reads /bank/prod/payments/sponsor/npp/webhook-secret from SSM at
deploy time. dev/uat ships with the placeholder dev-stub-secret
(no SSM path in non-prod stages). Missing/mismatched header → 401
with error_code='WEBHOOK_AUTH_FAILED'.
The dev/uat /osko/_admin/inbound-credit route bypasses this check
(it's the test entry point) but enforces stage !== 'prod' so a
production deploy can never expose an unauthed inbound path.
payment_reversed event (k-2, k-9 wiki amendment)¶
MOD-120 emits bank.payments.payment_reversed on Osko returns only.
Schema (post-2026-05-08 wiki amendment that added Osko-specific enum
values):
reversal_reason: "OSKO_RETURN"reversed_by: "NPP_SCHEME"trace_id(ADR-031)
Sponsor-rejected reversals (the SUBMITTING → FAILED reversal) are
internal failure handling and do NOT publish payment_reversed —
that bus event is reserved for scheme-side returns where the customer
needs notification (FR-544).
MOD-022 v2 will pick up payment_reversed for the audit chain.
MOD-063 (Deployed) consumes the catalogue event for FR-544's
plain-language notification dispatch.
payments.osko_payments + payments.payid_registrations schemas (V001 + V002)¶
Both per the SD04 wiki amendment landed pre-build (k-6 ruling). Highlights:
- Uppercase status enums consistent with rest of SD04
(
payid_registrations: ACTIVE / SUSPENDED / DEREGISTERED;osko_payments: PENDING / SUBMITTING / PROCESSING / COMPLETED / 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 (k-7 defence-in-depth replay guard). - UNIQUE
(end_to_end_id)standalone (k-14 NPP-spec). trace_id uuid NOT NULL(ADR-031).confirmed_display_name,name_confirmed,name_confirmed_at— CON-005 / Scam-Safe Accord audit fields.is_first_time_payee,acknowledged_high_value— Scam-Safe state.posting_idandreversal_posting_idfor cross-domain audit reconstruction.- Partial index
idx_osko_payments_first_time_payee_lookupmakes the k-4 first-time-payee lookup O(log n).
V900 seeds dev/uat with mobile:+61400-PASS-0001, FAIL-0001,
RTRN-0001, email:test.user@example.test, and a SUSPENDED PayID
per ADR-044 §3.
v1 known limitations¶
- No real async sponsor flows in prod yet. Production v1 is
submit-only — settlement + inbound-credit + return paths require
the v2 webhook receivers (file the
MOD-157-npp-async-stub-extension.handoff.md). - No real-time Cuscal NPP integration in dev/uat — MOD-157 stubs the directory + submit + callback flows. Production gets the real scheme.
- No MOD-081 reconciliation integration (MOD-081 not built). Settlement-file reconciliation is v2.
- 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. - Hardcoded GL codes —
'2100'customer-deposits +'2210'NPP-clearing. v2: per-account-product GL lookup via SD01. - Sender's account_id for first-time-payee resolution is caller-trusted on the resolve endpoint — v2 derives from the API Gateway authorizer claims.
SSM outputs published by MOD-120¶
| Path | Value |
|---|---|
/bank/{stage}/mod-120/api/base-url |
API Gateway URL |
/bank/{stage}/mod-120/payid-management-lambda/arn |
management Lambda ARN |
/bank/{stage}/mod-120/payid-resolve-lambda/arn |
resolve Lambda ARN |
/bank/{stage}/mod-120/osko-send-lambda/arn |
send Lambda ARN |
/bank/{stage}/mod-120/osko-inbound-lambda/arn |
inbound webhook Lambda ARN |
/bank/{stage}/mod-120/osko-payments-table |
payments.osko_payments |
/bank/{stage}/mod-120/payid-registrations-table |
payments.payid_registrations |
/bank-payments/{stage}/clearing/npp/callback-url |
(v1: not-yet-wired stub; v2: real Lambda function URL for MOD-157 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/npp/base-url |
sponsor (MOD-157 stub in dev/uat; real in prod) |
/bank/{stage}/mod-120/npp-clearing-account-id |
NPP clearing GL account (k-5; prod-only required) |
/bank/prod/payments/sponsor/npp/webhook-secret |
k-9 webhook secret (prod only) |
Reserved concurrency (k-10)¶
| Stage | payid-management | payid-resolve | osko-send | osko-inbound | osko-admin |
|---|---|---|---|---|---|
| dev | unbounded | unbounded | unbounded | unbounded | n/a (Lambda exists) |
| uat | 10 | 30 | 30 | 15 | n/a (Lambda exists) |
| prod | 30 | 100 | 100 | 50 | n/a (Lambda doesn't exist) |
Test surface¶
| Tier | Files | Coverage |
|---|---|---|
| Unit | first-time-payee, payid-resolver, send-orchestrator (9 — k-4 happy + reversal + gate-rejection branches), reason-mapper, errors, amount, trace, logger, emf, types | ≥80% line + function on the gated pure modules |
| Contract | payment_reversed schema (k-2/k-9 OSKO_RETURN / NPP_SCHEME), request/response shapes | one per published event + one per request |
| Policy | PAY-001 GATE, PAY-005 AUTO, PAY-009 AUTO, CON-005 AUTO, AML-005 LOG, admin-prod-disabled (k-1/k-8/k-9), no-cut-off-import (k-15) | one per policies_satisfied row |
| Integration | FR-541 register/list, FR-542 resolve+send (k-3 mismatch + k-4 high-value-ack), FR-543 inbound (k-9 unauth), FR-544 return, idempotency, observability, audit-chain | one per FR + idempotency + chain |
| Smoke | tests/verify-deployment.mjs |
SSM + Lambda + alarms + schemas + payid-resolve invoke + admin-Lambda-absent-in-prod check |