Skip to content

Technical design — MOD-020 Pre-payment validation suite

Module: MOD-020 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-120, FR-121, FR-122, FR-123, FR-124 NFR scope: NFR-021, NFR-022 Policies satisfied: PAY-001 (GATE), AML-007 (GATE), PAY-005 (GATE), CLQ-002 (CALC) Author: AI coding agent (Claude) Date: 2026-05-08

Objective

The absolute payment gate. Every rail-integration module (MOD-119/120/122/124/135/136/141 etc.) calls MOD-020's POST /internal/v1/payments/validate synchronously before submitting a payment to the rail. There is no bypass: the methodology and CLAUDE.md architectural constraint #1 codify this.

The gate runs five checks concurrently, persists the canonical payments.payments row, and publishes one of three terminal events (payment_initiated always, plus either payment_validated or payment_failed) on the bank-payments bus.

Architecture

API Gateway HTTP API
   POST /internal/v1/payments/validate ─► Mod020ValidatePaymentHandler

bank-core EventBridge bus
   account_status_changed ─► Mod020AccountStatusConsumerHandler
                              └─► payments.account_status_cache UPSERT

Mod020ValidatePaymentHandler:
   1. Zod validate request
   2. Idempotency replay short-circuit (payments.idempotency_keys, MODULE_ID="MOD-020")
   3. Open transaction:
      a. INSERT payments.payments at status=VALIDATION_PENDING (skipped on dry_run)
      b. runChecks (Promise.allSettled — five concurrent checks, each timeout 175ms):
         · BALANCE         — sync invoke MOD-003     (fail-closed BALANCE_UNAVAILABLE)
         · ACCOUNT_STATUS  — local SELECT cache     (cache miss → PASS, k-6)
         · SANCTIONS       — sync invoke MOD-013     (fail-closed SANCTIONS_ERROR)
         · FRAUD           — sync invoke MOD-023     (fail-closed FRAUD_BLOCK)
         · VELOCITY        — sync invoke MOD-021     (fail-closed LIMIT_EXCEEDED)
      c. evaluateGate(checks) — pure priority resolver (k-1, k-9)
      d. UPDATE payments.payments status + failure_reason + fraud_score (skipped on dry_run)
      e. INSERT payments.idempotency_keys row
   4. Post-commit:
      · publishPaymentInitiated   (always, non-dry-run)
      · publishPaymentValidated   (AUTHORISED) OR
        publishPaymentFailed      (VALIDATION_FAILED)
        — neither for PENDING_AUTH (v1; MOD-022 will introduce step-up audit)
   5. Respond

The five checks — ordering, timeouts, fail-closed (k-1 / k-7)

All five checks fire on Promise.allSettled so total wall time is the slowest check, not the sum. Each cross-module Lambda invoke is wrapped in withTimeout at perCheckTimeoutMs (default 175ms). Every check emits an EMF metric (validate_check_total with dimensions check_name, outcome) and the orchestrator returns five CheckResult rows regardless of outcome — FR-136-equivalent audit trail for the gate.

# Check Source Timeout Fail-closed code
1 BALANCE MOD-003 sync invoke 175ms BALANCE_UNAVAILABLE
2 ACCOUNT_STATUS local payments.account_status_cache SELECT (in-pool) INVALID_ACCOUNT
3 SANCTIONS MOD-013 sync invoke 175ms SANCTIONS_ERROR
4 FRAUD MOD-023 sync invoke 175ms FRAUD_BLOCK
5 VELOCITY MOD-021 sync invoke 175ms LIMIT_EXCEEDED

Priority order (k-1 ruling)

The gate evaluator walks the result array in priority order to pick the wire failure_reason. reason_codes carries the full set — useful for the case-mgmt queue and customer-facing copy.

  1. SANCTIONSSANCTIONS_MATCH | SANCTIONS_PENDING_REVIEW | SANCTIONS_ERROR (AML-007 regulatory primacy)
  2. ACCOUNT_STATUSINVALID_ACCOUNT (CLOSED / RESTRICTED / FROZEN)
  3. FRAUDFRAUD_BLOCK (PAY-005)
  4. BALANCEINSUFFICIENT_BALANCE | BALANCE_UNAVAILABLE (PAY-001)
  5. VELOCITYLIMIT_EXCEEDED (PAY-001 velocity)

STEP_UP and PENDING_AUTH (k-1)

MOD-023 returns decision ∈ {PASS, STEP_UP, BLOCK}. STEP_UP becomes outcome="STEP_UP" on the FRAUD CheckResult. If no other check FAIL'd or ERROR'd, the gate verdict is decision=PENDING_AUTH — caller must run strong-customer-auth before retrying. If any other check FAIL/ERROR'd, that takes precedence (a hard reject is more decisive than a soft step-up).

SANCTIONS_PENDING_REVIEW = BLOCK (k-9)

MOD-013 returning MATCH_PENDING (operator review required) is mapped to outcome="FAIL" / failure_code="SANCTIONS_PENDING_REVIEW", NOT a step-up. AML-007 requires the payment to be blocked until the analyst clears the match.

payments.payments — canonical schema (V001)

V001 of this module creates payments.payments. MOD-023 / MOD-025 / MOD-082 already contain to_regclass guards because they referenced this table before MOD-020 was built. Those guards are now dead code but not blocking; cleanup is non-essential.

Cross-domain identifiers (party_id, from_account_id, to_account_id, sanctions_result_id) are uuid columns without FK constraints because they target other Neon DBs (bank_core, bank_kyc). The single same-DB FK is fx_rate_lock_id → payments.fx_locks(id).

UNIQUE (idempotency_key, party_id) is enforced at the DB level so duplicate submissions can never produce two rows.

payments.account_status_cache — local cache (V002, k-6)

Column Type Notes
id uuid PK default gen_random_uuid()
account_id uuid NOT NULL UNIQUE bank-core account id
account_status text NOT NULL enum ACTIVE / RESTRICTED / CLOSED / FROZEN / DORMANT
last_event_id text NOT NULL bank-core event_id (idempotency token for the consumer)
updated_at timestamptz NOT NULL DEFAULT now()
created_at timestamptz NOT NULL DEFAULT now()

The Mod020AccountStatusConsumerHandler upserts this table on every bank.core.account_status_changed event. Idempotency: the inbound event_id is stored as last_event_id; redelivery of the same event_id is a no-op.

The validate hot path performs a single SELECT account_status FROM payments.account_status_cache WHERE account_id = $1 on the same pool connection used to insert the payment row — no separate round-trip.

Cache miss = PASS (k-6): if the row is absent, the conservative interpretation is "unknown" — but in v1 we treat unknown as PASS because no account_status_changed event for this account has ever been observed, meaning it has not transitioned out of ACTIVE since launch. (When MOD-104's account-bootstrap event lands, this becomes moot.)

Fail-closed defaults (k-7)

On any cross-module Lambda invoke timeout / 5xx / FunctionError, the SDK client throws a ProviderError or TransientInfra. The orchestrator catches and produces a CheckResult with outcome="ERROR" and the appropriate *_UNAVAILABLE | *_ERROR failure_code. The gate-evaluator treats ERROR identically to FAIL — priority order applies as normal — so the bank cannot move money when its risk gates are degraded.

The fail-closed dependency rate is alarmed at 10 ERRORs / 5min on validate_check_total{outcome=ERROR}.

NFR-022 200ms p99 budget

Sequential invocation infeasible for five cross-module calls. Resolved via parallel invocation — total wall time bounded by the slowest check, not the sum. Per-check timeout 175ms keeps the budget even under p99 latency on the upstream Lambdas. Reserved concurrency is mandatory in prod for both MOD-020 and all four downstream Lambdas (MOD-003, MOD-013, MOD-021, MOD-023) per k-8.

Stage Reserved concurrency (validate-payment) Reserved concurrency (account-status-consumer)
dev unbounded unbounded
uat 100 20
prod 200 30

v1 known limitations

  • No CoP / payee verification — MOD-144 will introduce Confirmation of Payee in Tier F. v1 relies on MOD-023's scam-DB stub for payee risk.
  • No retry queue for failed publishespayment_initiated / validated / failed publish failures are logged + alarmed; the payments row is committed before publish so the source of truth is the DB. MOD-022 will introduce the audit trail and reconciler.
  • No step-up loopPENDING_AUTH returns the decision; the caller (rail module) must request the customer step-up via MOD-068 and re-call validate with a new idempotency_key. v2 may make MOD-020 return a step_up_token linked to a stored partial state.
  • AppConfig threshold tuning is a v2 concern. v1 has no thresholds because the gate is binary (FAIL / PASS / STEP_UP from MOD-023, which has its own thresholds).

Cross-domain Lambda invoke grants

BankPaymentsRole does not have lambda:InvokeFunction on cross-domain (bank-core, bank-kyc) Lambdas at default. MOD-020 filed docs/handoffs/MOD-104-cross-domain-invoke-grant-mod020.handoff.md requesting: - lambda:InvokeFunction on arn:aws:lambda:ap-southeast-2:{account}:function:bank-core-mod-003-* - lambda:InvokeFunction on arn:aws:lambda:ap-southeast-2:{account}:function:bank-kyc-mod-013-*

Same-domain (MOD-021, MOD-023) use the existing intra-bank-payments grant — no new permission needed.

Cross-bus rule grant (k-10)

The bank-core PutRule grant filed by MOD-082 (docs/handoffs/processed/2026-05-05/MOD-104-bank-core-cross-bus-grant.handoff.md) uses the resource pattern bank-payments-mod-*. MOD-020's rule name bank-payments-mod-020-core-account-status-changed-${stage} matches the wildcard. No new MOD-104 handoff required.

SSM outputs published by MOD-020

Path Value
/bank/{stage}/mod-020/api/base-url API Gateway URL
/bank/{stage}/mod-020/validate-payment/url Full validate-payment endpoint
/bank/{stage}/mod-020/validate-payment-lambda/arn Lambda ARN (sync caller path)
/bank/{stage}/mod-020/account-status-consumer-lambda/arn Lambda ARN (consumer)
/bank/{stage}/mod-020/payments-table payments.payments
/bank/{stage}/mod-020/account-status-cache-table payments.account_status_cache

Upstream SSM dependencies

Path Used for
/bank/{stage}/iam/lambda/bank-payments/arn BankPaymentsRole
/bank/{stage}/eventbridge/bank-payments/arn publish bus
/bank/{stage}/eventbridge/bank-core/arn cross-bus consume target
/bank/{stage}/observability/adot-nodejs-layer-arn OTel Lambda layer
/bank/{stage}/sns/alerts/arn alarm SNS topic
/bank/{stage}/neon/pooler-host DB pooler host
/bank/{stage}/mod-003/balance-query-lambda/arn sync invoke target (BALANCE check)
/bank/{stage}/kyc/sanctions/function-arn sync invoke target (SANCTIONS check; MOD-013's domain-prefixed convention)
/bank/{stage}/mod-021/limits-check-lambda/arn sync invoke target (VELOCITY check)
/bank/{stage}/mod-023/score-payment-lambda/arn sync invoke target (FRAUD check)

Events published

Detail-type Bus When
payment_initiated bank-payments Always (non-dry-run) before publishing terminal event
payment_validated bank-payments AUTHORISED only
payment_failed bank-payments VALIDATION_FAILED only

PENDING_AUTH does not publish a terminal event in v1 — MOD-022 will introduce step-up audit semantics.

Events consumed

Detail-type Bus Source module
account_status_changed bank-core MOD-003 (publisher)

Observability

  • Structured logs (ADR-031): trace_id, correlation_id, module_id=MOD-020, jurisdiction, event_type, party_id, level, error_code, retryable, timestamp
  • EMF metrics: validate_payment_total, validate_payment_duration_ms, validate_check_total, validate_check_duration_ms, account_status_cache_update_total
  • ADOT Lambda layer attached for OTel traces → CloudWatch + X-Ray

Test surface

Tier Files Coverage
Unit gate-evaluator (priority order, STEP_UP, MATCH_PENDING), errors, amount, trace, logger, emf, timeout, types, event-publisher, schema-validator ≥80% line + function
Contract published-events (3 schemas), account-status-consumer (Zod) one per event
Policy PAY-001 GATE, AML-007 GATE, PAY-005 GATE, CLQ-002 CALC one per policies_satisfied row
Integration FR-120 validate, FR-121 account state, FR-124 dry-run, idempotency, observability one per FR + idempotency + log shape
Smoke tests/verify-deployment.mjs SSM + Lambda + EventBus + schemas + alarms + dry-run invoke