Skip to content

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_reversed emission 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 UUID 00000000-0000-0000-0000-000000000120. The SST infra reads /bank/prod/mod-120/npp-clearing-account-id in prod and threads it into the env.
  • BPAY_CLEARING_GL_CODE '2210' (NPP/Osko clearing) and DEFAULT_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.

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.ts overlays 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 by STAGE !== '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.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 NPP API. The sync-rejection path works identically; the async settlement and inbound-credit paths use the production webhook receiver /osko/inbound with the k-9 x-sponsor-secret header check.

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 NULL with no FK (cross-module ownership; payments.payments row 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_id and reversal_posting_id for cross-domain audit reconstruction.
  • Partial index idx_osko_payments_first_time_payee_lookup makes 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 DISPUTED status 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