Skip to content

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: bank Neon DB (ADR-064), app schema.
  • 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/validate via 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/check for FX disclosure ack.
  • MOD-068: step-up Lambda via direct invoke (STEP_UP_FN_ARN env 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_amount left 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_keys table 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).