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_completedevent 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 |