Skip to content

Technical design — MOD-141 Intra-bank transfer engine

Module: MOD-141 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-625, FR-626, FR-627, FR-628 NFR scope: (inherited Tier-1 from SD04 — RTO/RPO via Neon, ≥99.95% availability) Policies satisfied: PAY-001 (GATE), AML-007 (AUTO), PAY-002 (LOG), CON-005 (AUTO) Author: AI coding agent (Claude) Date: 2026-05-08

Objective

The first SD04 rail integration. MOD-141 executes a customer-initiated transfer between two accounts at the same institution as a single atomic double-entry posting via MOD-001 — no external rail involvement, immediate, no settlement risk, no cut-off windows. It is the proving ground for the MOD-020 → MOD-001 → MOD-022 chain that every subsequent rail (MOD-119/120/122/124/135/136) will follow.

Architecture

HTTP API
   POST /internal/v1/payments/intra-bank/transfer ─► Mod141TransferHandler
                                              transfer-orchestrator
                                        sync invoke MOD-020 validate-payment
                                                  │   (payment_type='INTERNAL')
                                          on AUTHORISED → sync invoke MOD-001 post-posting
                                                            (DEBIT source, CREDIT destination)
                                                  UPDATE intra_bank_transfers status

Audit chain (no MOD-141 emission):
  MOD-020 publishes        ─► payment_initiated/_validated/_failed   ─┐
                                                                      ├─► MOD-022 audit-consumer
  MOD-001 publishes        ─► bank.core.posting_completed             ─┘   ─► payment_events SETTLEMENT_CONFIRMED
                                                                              ─► re-emit bank.payments.payment_completed

Lifecycle

payments.intra_bank_transfers.status:

State Set by
PENDING INSERT at handler entry
POSTED UPDATE on MOD-001 success (with posting_id)
FAILED UPDATE on any failure (validation FAIL, PENDING_AUTH (k-7), MOD-001 reject, infra error). failure_reason preserved verbatim

Settlement linkage (k-5)

Every transfer mints a payment_id upfront. That UUID: - Is stored on payments.intra_bank_transfers.payment_id - Is forwarded to MOD-020 (which INSERTs payments.payments with the same id) - Is forwarded to MOD-001 (which carries it through to posting_completed.payment_id once the bank-core schema bump lands per MOD-001-posting-completed-payment-id-extension.handoff.md)

Until that schema bump lands, MOD-022's audit-consumer falls back to the idempotency_key for settlement linkage (k-1 from MOD-022). MOD-141 threads a single idempotency_key through MOD-020 and MOD-001 unchanged, so the fallback path resolves cleanly.

Validation parity (FR-627 + k-2 partial deferral)

MOD-141 calls MOD-020 with payment_type='INTERNAL' and direction='INTERNAL'. MOD-020 runs all five gate checks (BALANCE, ACCOUNT_STATUS, SANCTIONS, FRAUD, VELOCITY) which transitively run MOD-003 / MOD-013 / MOD-023 / MOD-021. Sanctions screening is not bypassed — AML-007's "intra-bank not exempt" rule is enforced.

Deferred sub-features (k-2): MOD-023's FRAUD scorer has two sub-features that require an external BSB+account_number — the scam-payee DB lookup and the counterparty-new check. For intra-bank both accounts are known-internal, so these are architecturally inapplicable. MOD-141 omits the destination_bsb/destination_account_number fields when calling MOD-020; MOD-020's check-orchestrator returns FRAUD-PASS with reason='no_payee_destination_supplied' for those sub-features. The remaining FRAUD sub-features (device anomalies, velocity, amount deviation, hour-of-day) still run.

A follow-up handoff at docs/handoffs/MOD-023-intra-bank-scoring-type.handoff.md requests v2 work on MOD-023 to make the deferral explicit (payment_classification='INTRA_BANK') and surface skipped features as null in the FR-136 audit row.

Atomic double-entry (FR-626)

MOD-141 calls MOD-001 with a single PostingRequest carrying both legs in one entries: [DEBIT, CREDIT] tuple. MOD-001's processPosting runs both INSERTs inside a single Postgres BEGIN/COMMIT (per its design doc). The atomicity guarantee comes from there — MOD-141 does not, and structurally cannot, produce a half-posted state.

Both entries pass gl_account_code='2100' (per k-3 — named constant DEFAULT_CUSTOMER_DEPOSITS_GL_CODE in src/config.ts). v2 work: per-account-product GL lookup via SD01.

Routing detection (FR-625 — deferred)

Per k-1: MOD-141 trusts the caller-supplied destination_account_id as already resolved-internal. MOD-001's ACCOUNT_NOT_ACTIVE / ACCOUNT_CLOSED error codes are the runtime backstop. The routing detection responsibility ("resolve destination → internal vs external rail → route") belongs to a future routing orchestrator that wraps MOD-141 + MOD-119/120/122/124/etc. v1 has only MOD-141 deployed; the router is implied future work. Documented here as out-of-scope; no follow-up handoff filed.

Step-up handling (k-7)

MOD-020 returns decision='PENDING_AUTH' when MOD-023 says STEP_UP. v1 of MOD-141 maps that to: - intra_bank_transfers.status='FAILED' - failure_reason='STEP_UP_REQUIRED' - HTTP 422 to caller

The customer-facing UI re-presents the strong-customer-auth flow (MOD-068) and the caller retries with a fresh idempotency_key. v2 work: a step-up retry surface inside MOD-141 that allows resuming with the same key after the customer completes auth.

Idempotency (k-10)

A single idempotency_key threads end-to-end: - Stored against payments.idempotency_keys (module_id='MOD-141') - DB-level UNIQUE on payments.intra_bank_transfers.idempotency_key (defence-in-depth replay guard per k-6) - Forwarded to MOD-020 as the validate-payment idempotency_key - Forwarded to MOD-001 as the post-posting idempotency_key - Used as MOD-001's validation_reference (k-4)

A re-invocation with the same key returns the cached TransferResponse from payments.idempotency_keys and writes no new rows.

payments.intra_bank_transfers schema (V001 + k-6 ruling)

Column Type Notes
id uuid PK default gen_random_uuid()
payment_id uuid NOT NULL k-6: minted by MOD-141; no FK because the payments.payments row is created by MOD-020 in the same chain
idempotency_key text NOT NULL UNIQUE — defence-in-depth replay guard
source_account_id uuid NOT NULL DEBIT leg
destination_account_id uuid NOT NULL CREDIT leg; caller-supplied resolved-internal
amount numeric(18,2) NOT NULL CHECK (>0)
currency char(3) NOT NULL ISO 4217
channel text NOT NULL CHECK enum APP/API/BACK_OFFICE/BATCH (uppercase per k-6)
jurisdiction char(2) NOT NULL CHECK NZ/AU
narrative text optional payment reference
requested_at timestamptz NOT NULL caller-supplied
status text NOT NULL DEFAULT 'PENDING' CHECK enum PENDING/POSTED/FAILED
posting_id uuid set on POSTED; FK is application-layer (cross-DB)
failure_reason text set on FAILED
trace_id uuid NOT NULL ADR-031
created_at timestamptz NOT NULL DEFAULT now()
updated_at timestamptz NOT NULL DEFAULT now() touch trigger

UNIQUE constraint per k-6 ruling: UNIQUE (idempotency_key) alone — not compound with source_account_id. A compound key would allow a replay with the same key but a different source account to create a second row, which is a correctness bug.

Indexes: (payment_id), (source_account_id), partial (status) WHERE status NOT IN ('POSTED').

Cross-domain Lambda invoke grant (k-9)

MOD-141 needs lambda:InvokeFunction on bank-core-mod-001-*. BankPaymentsRole doesn't have it today.

Filed at docs/handoffs/MOD-104-cross-domain-invoke-grant-combined.handoff.md as a combined handoff covering both: - MOD-020's outstanding grants (bank-core-mod-003-* and bank-kyc-mod-013-*sanctions* — never landed since the original handoff) - MOD-141's new grant (bank-core-mod-001-*)

Until the grants land, MOD-141's posting calls return 503 / status=FAILED with failure_reason='MOD001_INVOKE_FAILED', and MOD-020's BALANCE + SANCTIONS checks fail-closed BLOCK. The dependency alarms fire immediately on traffic. Full integration is gated on bank-platform redeploying MOD-104.

SSM outputs published by MOD-141

Path Value
/bank/{stage}/mod-141/api/base-url API Gateway URL
/bank/{stage}/mod-141/transfer/url Full transfer endpoint
/bank/{stage}/mod-141/transfer-lambda/arn Lambda ARN
/bank/{stage}/mod-141/intra-bank-transfers-table payments.intra_bank_transfers

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}/mod-020/validate-payment-lambda/arn sync invoke target (gate)
/bank/{stage}/mod-001/lambda/arn sync invoke target (post-posting; cross-domain k-9)

Events published / consumed

Published: none — MOD-141 emits no bus events directly. The audit chain is owned by MOD-020 + MOD-001 + MOD-022.

Consumed: none — MOD-141 is HTTP-only.

Reserved concurrency (k-8)

Stage transfer
dev unbounded
uat 30
prod 100

v1 known limitations

  • No notification (FR-628 second target) — credit notification via MOD-063 is not wired in v1; MOD-063 doesn't exist yet (Tier F). v2: when MOD-063 ships, the payment_completed event MOD-022 re-emits drives the customer notification.
  • No routing detection (FR-625) — caller trust + MOD-001 backstop. Future routing orchestrator owns this.
  • No step-up retry surface (k-7) — STEP_UP_REQUIRED is a hard fail in v1; UI re-runs the customer flow with a new idempotency_key. v2: step-up token + retry endpoint.
  • Hardcoded GL code (k-3) — both legs use '2100'. v2: per-account-product GL lookup via SD01.
  • Partial FR-627 fraud parity (k-2) — scam-payee + new-payee sub-features deferred (architecturally inapplicable to intra-bank). Follow-up handoff filed for MOD-023.

Test surface

Tier Files Coverage
Unit transfer-orchestrator (k-1, k-2, k-4, k-5, k-7 branches), errors, amount, trace, logger, emf, types, config ≥80% line + function on the gated pure modules
Contract request-response shape one test family per published wire shape
Policy PAY-001 GATE, AML-007 AUTO, PAY-002 LOG, CON-005 AUTO one per policies_satisfied row
Integration FR-626 (atomic execution), FR-627 (validation parity), FR-628 (perf ceiling), idempotency, observability, audit-chain one per FR + idempotency + chain
Smoke tests/verify-deployment.mjs SSM + Lambda + alarms + invoke