Technical design — MOD-071 Payment initiation¶
Module: MOD-071 System: SD08 Customer App & Back Office Platform Repo: bank-app FR scope: FR-353, FR-354, FR-355, FR-356 NFR scope: NFR-009, NFR-020, NFR-024, NFR-025 Policies satisfied: PAY-001 (GATE), CON-005 (AUTO) Author: SD08 build agent (Claude) Date: 2026-05-19
Objective¶
Customer-facing entry point for all outgoing payment types (domestic / NPP / SWIFT / BPAY / scheduled). Two-step preview→confirm flow with explicit confirmation tap (PAY-001 GATE); step-up auth for high-value or first-time payees (FR-355); FX/fee transparency on confirmation (CON-005 AUTO); every initiation event audited immutably (FR-356).
Routes through MOD-020 /internal/v1/payments/validate synchronously.
MOD-071 mints payment_id + idempotency_key at preview creation
(m-7/m-8); MOD-020 then writes the canonical payments.payments row.
Internal architecture¶
HTTP API (Cognito JWT authoriser):
POST /payments/preview → preview-payment → INSERT app.payment_previews
INSERT PREVIEWED event
MOD-050 disclosure check (FX)
evaluateStepUp(amount, payee)
POST /payments/confirm → confirm-payment → verify step-up token (if reqd)
INSERT CONFIRMED event
INSERT SUBMITTED event
SigV4 → MOD-020 /validate
INSERT RESULTED event
UPDATE preview.status=CONFIRMED
mark payee.first_use_completed
GET /payments/preview/{id} → get-preview → preview + ordered event log
GET /payees → list-payees
POST /payees → create-payee
DELETE /payees/{id} → delete-payee (soft)
GET /payments/scheduled → list-scheduled
POST /payments/scheduled → create-scheduled
PATCH /payments/scheduled/{id} → update-scheduled (pause/resume)
DELETE /payments/scheduled/{id} → delete-scheduled (cancel)
GET /direct-debits → list-direct-debits (v1 STUB — m-2)
EventBridge schedule (cron(0 19 ? * * *) = 07:00 NZST):
→ scheduled-payment-sweeper → listDueForSweep()
for each ACTIVE row:
INSERT synthetic preview
INSERT PREVIEWED/CONFIRMED/SUBMITTED
MOD-020 /validate
INSERT RESULTED
advanceAfterRun (next_run_at or COMPLETED)
11 HTTP Lambdas + 1 scheduled Lambda + 1 HTTP API + 1 cron schedule + 4 Postgres tables (1 immutable Cat 1, 3 mutable).
Key design decisions¶
Decision: preview/confirm two-step (PAY-001 GATE)¶
MOD-071's /confirm path mandatorily references a preview row;
direct submission paths return 422. Negative test in
tests/policy/pay-001-gate-static.test.ts scans every handler in
src/handlers/ for validateWithMod020 calls — only confirm-payment
and the scheduled sweeper are allowlisted (m-9: the sweeper is the
agent-only batch path; customer auth was captured at original
scheduled_payments INSERT).
Decision: payment_id minted at preview INSERT (m-8)¶
SD04 precedent (MOD-141/119/120/122). Lets the event log reference a
stable payment_id from PREVIEWED onwards, before MOD-020 sees it.
The payments.payments row in bank-payments is created by MOD-020
during /validate with the same payment_id (no FK across DBs).
Decision: deterministic idempotency_key (m-7)¶
sha256(preview_id::text || ':' || party_id::text). Stored UNIQUE
on app.payment_previews; passed to MOD-020 as the idempotency_key.
MOD-020's UNIQUE(idempotency_key, party_id) dedups across retries.
Decision: step-up via MOD-068 (m-5/m-6)¶
FR-355: amount > threshold (default NZD/AUD 500, env var) OR first-
time payee triggers step-up. Threshold is env-var-only in v1; per-
customer config deferred to v1.1 (would extend app.customer_sessions
or add app.payment_settings).
Token plumbing: STEP_UP_FN_ARN env var (Pulumi-injected from
/bank/{env}/mod068/step-up/fn-arn) — invoke MOD-068's step-up
Lambda with {action:"verify", token}. Token comes in via the
x-step-up-token header on /confirm. Same pattern as MOD-072.
Decision: FX disclosure check via MOD-050 (m-4)¶
For INTERNATIONAL_WIRE, the preview calls MOD-050's
POST /disclosures/check. If MOD-050 returns "not required" (e.g.
because FX_TERMS_AND_CONDITIONS isn't in the catalog yet) the
flow proceeds — degrades gracefully like MOD-077's MOD-040 stub.
When disclosure_required = true on the preview, /confirm refuses
with 409 DISCLOSURE_REQUIRED until the client acknowledges via
MOD-050 and creates a new preview.
Decision: immutable event log (FR-356, ADR-048 Cat 1)¶
app.payment_initiation_events carries one row per state transition.
Trigger name: trg_payment_initiation_events_immutable. Fields
include session_id (FK to app.customer_sessions),
device_fingerprint (sha256 hex from x-device-fingerprint
header — m-10), payment_id (populated from CONFIRMED onwards),
and mod_020_decision + mod_020_failure_reason on RESULTED rows.
Decision: sweeper writes RESULTED on MOD-020 failure (m-9)¶
The scheduled-payment-sweeper writes the same PREVIEWED → CONFIRMED → SUBMITTED → RESULTED sequence as the interactive path. Crucially RESULTED is always written, even when MOD-020 returns 5xx — the audit row carries the failure_reason so the customer-facing "why didn't my scheduled payment run" question is answerable.
Decision: no EventBridge publishes (m-12)¶
MOD-020 owns bank.payments.payment_initiated on bank-payments bus.
Downstream consumers (MOD-077, MOD-063) subscribe to that bus
directly. MOD-071 publishes nothing — avoids a parallel signal.
Decision: OSKO excluded from scheduling (m-1)¶
OSKO is real-time only. The app.scheduled_payments.payment_type
CHECK excludes it. The UI's PaymentTypeSelector still surfaces OSKO
for ad-hoc preview→confirm flows.
External dependencies¶
- Database:
bankNeon DB (ADR-064),appschema. - WRITE:
app.payment_previews,app.payment_initiation_events,app.payees,app.scheduled_payments. - READ via FK:
app.customer_sessions(MOD-068). - MOD-020 (bank-payments):
POST /internal/v1/payments/validatevia IAM/SigV4 with BankAppRole. - Pending grant — m-3: MOD-020's resource policy will need BankAppRole listed. File-if-needed handoff to bank-platform after first dev deploy 401/403, same posture as MOD-070 → MOD-002.
- MOD-050:
POST /disclosures/checkfor FX disclosure ack. - MOD-068: step-up Lambda via direct invoke
(
STEP_UP_FN_ARNenv var from/bank/{env}/mod068/step-up/fn-arn). - EventBridge schedule (default bus) — daily cron for sweeper.
SSM outputs¶
| Output | SSM path | Consumers |
|---|---|---|
| Payment API base URL | /bank/{env}/mod071/payment-api/url |
MOD-069 app shell |
| Payment API ID | /bank/{env}/mod071/payment-api/id |
Authoriser attachment |
| 12 Lambda ARNs | /bank/{env}/mod071/{handler}/fn-arn |
Operational metrics |
| Payment events log group | /bank/{env}/mod071/payment-events-log/group-{arn,name} |
MOD-076 SIEM |
SSM consumed¶
| Path | Origin |
|---|---|
/bank/{env}/iam/lambda/bank-app/arn |
MOD-104 |
/bank/{env}/observability/adot-layer-arn |
MOD-076 |
/bank/{env}/kms/operational/arn |
MOD-104 |
/bank/{env}/mod-020/validate-payment/url |
MOD-020 (bank-payments) |
/bank/{env}/mod050/disclosure-api/url |
MOD-050 |
/bank/{env}/mod068/step-up/fn-arn |
MOD-068 |
Performance approach¶
- Preview path: 1 DB INSERT + (FX only) 1 MOD-050 call. ≤200 ms p99.
- Confirm path: 4 audit-event INSERTs + 1 MOD-020 sync call (≤200 ms per MOD-020's own SLA). FR-353 ≤500 ms submit / ≤5 s feedback budgets achievable.
- ADOT layer attached to every Lambda for X-Ray traces.
Error handling¶
Standard error envelope. Codes added in src/lib/errors.ts:
MISSING_FIELD, INVALID_FIELD, INVALID_AMOUNT,
INVALID_PAYMENT_TYPE, INVALID_PAYEE, PREVIEW_NOT_FOUND,
PREVIEW_EXPIRED (410), PREVIEW_NOT_PENDING (409),
STEP_UP_REQUIRED/STEP_UP_INVALID (401), DISCLOSURE_REQUIRED
(409), MOD_020_REJECTED (422), MOD_020_UNAVAILABLE/MOD_050_UNAVAILABLE
(503 retryable).
Test approach¶
| Tier | Location | Count |
|---|---|---|
| Unit | tests/unit/ | trace, logger, errors, idempotency, thresholds, payment-type-map — 6 suites |
| Contract | tests/contract/ | mod-020-validate-request shape |
| Policy | tests/policy/ | PAY-001 GATE structural + CON-005 AUTO source scan |
| FR integration | tests/integration/fr/ | FR-353 payee CRUD, FR-354 preview lifecycle, FR-355 scheduled lifecycle, FR-356 immutability runtime |
| Post-deploy smoke | tests/verify-deployment.mjs | 401/403 on POST /payments/preview |
v2 follow-ups¶
- MOD-114 wiring for direct debit mandate list / pause / cancel (CAP-009 stub).
- Per-customer step-up threshold (FR-355 "customer's configured
low-risk threshold") via
app.payment_settings. - Real FX rate + fee quote from a pricing engine on preview INSERT
(currently
fx_rate/fee_amountleft null; the response shape exposes the fields so the v2 swap is additive). - Sweeper session rotation — the session_id used to anchor scheduled- payment audit currently uses a static "audit_session_id" carried in the request_payload; v2 mints a system-actor session.
- Add
app.idempotency_keystable consultation in the confirm path if MOD-020's response is lost in transit (currently a retry re-POSTs /confirm and MOD-020 dedups; tighter end-to-end requires local idempotency).