Skip to content

Technical design — MOD-021 Payment limit & velocity controller

Module: MOD-021 — Payment limit & velocity controller System: SD04 — Payments Processing Platform Repo: bank-payments FR scope: FR-125, FR-126, FR-127, FR-128 NFR scope: NFR-020, NFR-024, NFR-025 Policies satisfied: PAY-005 (GATE), AML-005 (ALERT), CON-005 (AUTO) Author: AI agent (Claude Opus 4.7) Date: 2026-05-02 Dependencies: MOD-002 (Deployed, de846f1a), MOD-103 (Built), MOD-104 (Built)


Objective

MOD-021 enforces per-customer payment limits and velocity controls on the synchronous pre-payment path. It is invoked by MOD-020 (when MOD-020 is built) once per payment instruction, and returns PASS / FAIL / APPROVAL_REQUIRED within a ≤20 ms p99 budget. It also serves the back-office and customer-facing limit-management API (FR-126), publishes bank.payments.limit_breach_detected and bank.payments.approval_required to the bank-payments EventBridge bus, and owns the payments schema bootstrap in bank_payments (Neon).

This document covers the as-built source tree. Acceptance is measured against the wiki's policy/FR/NFR set and verified by the test suites below.


Architecture

                         API Gateway HTTP API
       POST /internal/v1/limits/check   ─▶ Mod021LimitsCheckHandler  (hot path)
                                              ├─ idempotency lookup
                                              ├─ fetchActiveLimits   ── payments.payment_limits
                                              ├─ fetchPriorTotals    ── payments.payments  (FR-127 filter)
                                              ├─ resolveLimits + decide
                                              ├─ EventBridge publish ── bank-payments bus
                                              │     · limit_breach_detected (on FAIL)
                                              │     · approval_required     (on APPROVAL_REQUIRED)
                                              ├─ idempotency store
                                              └─ EMF + structured log
       POST /internal/v1/limits         ─▶ Mod021LimitsSetHandler    (FR-126 audit-trailed)
       GET  /internal/v1/limits/{id}    ─▶ Mod021LimitsListHandler


  EventBridge bank-payments bus
                                  ┌─ Mod021LimitBreachToNotificationRule ─▶ MOD-063 (FR-128)
       limit_breach_detected ─────┤
                                  └─ (existing consumers: MOD-020 future, MOD-022 future, MOD-016 future)

       approval_required    ─────▶ MOD-062 (workflow orchestration, Built)

Three Lambda handlers, one HTTP API, two EventBridge rules (one same-bus target binding plus the rule for limit_breach), four CloudWatch alarms, one CloudWatch dashboard, six SSM parameter outputs.


Key design decisions

Decision: velocity reads from payments.payments, not core.transaction_log

The MOD-021.yaml dependency on MOD-002 is loose phrasing; FR-127 mandates counting payment attempts (including VALIDATION_FAILED and REVERSED, but not CANCELLED). The data shape that satisfies that is payments.payments in the same bank_payments database — every customer-initiated payment is recorded there at MOD-020's pre-validation step regardless of outcome. Reading from core.transaction_log (bank-core) would undercount because the log only contains committed postings, missing FAILED/REVERSED attempts.

Source confirmation: SD04-payments.md payments.payments.status enum includes SUBMITTED, VALIDATION_FAILED, REVERSED, etc. The DB query lives in src/services/limit-store.ts and applies status NOT IN ('CANCELLED').

This was confirmed with the orchestrator before build (Q1).

Decision: schema extensions to the SD04 data model

The wiki's SD04-payments data model defines payment_limits with a four-value limit_type CHECK and no channel column. FR-125 requires both rolling-30-day and per-channel enforcement. The migration adds:

  1. limit_type CHECK accepts ROLLING_30_DAY (FR-125 explicit) and APPROVAL_THRESHOLD (CAP-090 detection surface, owned by MOD-062 thereafter).
  2. New channel column, text NOT NULL DEFAULT 'ALL' CHECK (channel IN ('APP','API','OPEN_BANKING','AGENT','ALL')) — same axis as the interface contract.

These are additive — they extend the data model without breaking the published contract. The wiki's SD04-payments.md should be updated to reflect them; flagged in the handoff.

Decision: APPROVAL_THRESHOLD lives inside payment_limits, not a separate table

Per the orchestrator's Q3 resolution, MOD-021 does detection + hold + event emission for high-value payments; MOD-062 owns the approval state machine. The simplest realisation: APPROVAL_THRESHOLD is just another row in payment_limits with a special limit_type. The velocity decide() function checks it first and returns APPROVAL_REQUIRED (distinct from FAIL) when breached. No new table, no additional schema surface.

Decision: no in-memory limit cache

CON-005 AUTO requires "no delay between setting and enforcement". The straightforward way to honour that is to not cache. Every limits-check call SELECTs from payments.payment_limits against a covering partial index (idx_payment_limits_party_active). The query is a single-key index scan — sub-ms even at high cardinality. The cost is well under the 20 ms p99 budget.

The CON-005 policy test in tests/policy/con-005-auto.test.ts includes a no-bypass token scan that fails the build if any cache-related identifier is added (e.g. LIMIT_CACHE_TTL).

Decision: idempotency on a shared payments.idempotency_keys table

Following the methodology pattern, MOD-021 introduces payments.idempotency_keys to the schema with PK on (key, module_id). Future SD04 modules (MOD-020, MOD-022, etc.) reuse the same table.

Decision: same-bus rule wires MOD-063 directly

FR-128 customer notification routing is a same-bus EventBridge rule from bank.payments.limit_breach_detected to MOD-063's notification Lambda. Because BankPaymentsRole already has events:PutRule on the bank-payments bus (per MOD-104), no IAM widening is needed. The rule is idempotent and tolerates MOD-063's SSM ARN being unset on a fresh stage (it logs a deferred-target warning and skips the binding).

Decision: payment_limits rows are append-only-by-convention, not by trigger

Limit lifecycle is implemented as: insert new row, set effective_to on the previous active row. There is no UPDATE-of-limit_amount. This preserves history (FR-126 audit trail context) without adding an immutability trigger that would block legitimate effective_to updates. The audit table limit_change_audit is the immutable surface (V004 trigger).


Schema extensions

Object Change vs SD04 data model Rationale
payment_limits.limit_type CHECK Adds ROLLING_30_DAY, APPROVAL_THRESHOLD FR-125 explicit; CAP-090 detection
payment_limits.channel New column text NOT NULL DEFAULT 'ALL' FR-125 per-channel enforcement
idx_payment_limits_party_active Index keys extended to include channel, limit_type Hot-path index scan covers the limit_resolver lookup
payments.limit_change_audit New table — not in the SD04 data model FR-126 mandates the audit trail; surface owned by MOD-021
payments.idempotency_keys New table — methodology standard Shared SD04 idempotency store

The wiki SD04-payments.md should be updated; flagged in the handoff.


SSM outputs (consumer contract)

All under arn:aws:ssm:ap-southeast-2:{account}:parameter. Path convention /bank/{env}/mod-021/....

SSM path Value Consumed by
/bank/{env}/mod-021/limits-check-lambda/arn Velocity check Lambda ARN MOD-020 (when built) — sync sub-call on payment validation
/bank/{env}/mod-021/limits-admin-api/url API Gateway HTTP base URL MOD-074 (back-office customer 360), MOD-078 (customer-facing controls)
/bank/{env}/mod-021/limits-set-lambda/arn Set/update Lambda ARN Audit dashboards
/bank/{env}/mod-021/limits-list-lambda/arn List Lambda ARN Audit dashboards
/bank/{env}/mod-021/payment-limits-table payments.payment_limits MOD-042 CDC pipeline
/bank/{env}/mod-021/limit-change-audit-table payments.limit_change_audit MOD-042 CDC pipeline
/bank/{env}/mod-021/idempotency-keys-table payments.idempotency_keys MOD-022, MOD-020 (when built) — shared idempotency surface

Postgres schema (owned by this module)

Table Purpose Mutability
payments.payment_limits Per-customer, per-(payment_type, channel, limit_type) limit configuration Mutable (effective_to set on close)
payments.limit_change_audit Append-only audit of limit changes (FR-126) Immutable (V004 trigger)
payments.idempotency_keys Shared SD04 idempotency store, 24-hour TTL Mutable (DELETE on TTL cleanup)

DB-enforced invariants: V004 immutability trigger on limit_change_audit. CHECK constraints per ADR-048 on enum-domain columns.


Events published

Event Source Detail-Type Schema Consumers
bank.payments.limit_breach_detected bank.payments limit_breach_detected schemas/bank.payments.limit_breach_detected.json MOD-020 (block), MOD-022 (audit), MOD-016 (structuring), MOD-063 (notification — FR-128)
bank.payments.approval_required bank.payments approval_required schemas/bank.payments.approval_required.json MOD-062 (approval workflow), MOD-022 (audit), MOD-063 (second-approver notification)

Schemas are uploaded to the EventBridge Schema Registry (bank-events-{env}) by infra/schemas.ts on every deploy.


Event types in structured logs

Registered in src/lib/logger.ts (EVENT_TYPES):

  • limit_check_passed, limit_check_failed, limit_breach_detected, approval_required
  • limits_listed, limits_changed, limits_change_failed
  • idempotency_replay
  • trace_id_missing_from_upstream (ADR-031 standard WARN)
  • validation_failed, internal_error

Test approach

Tier Location What it covers
Unit tests/unit/ Pure logic — amount math, error vocab, trace, logger ADR-031 fields, EMF, limit-resolver specificity, velocity decide() boundaries
Contract tests/contract/ JSON Schema validation of both published events; Zod request/response shapes vs interface-contracts.md
Integration tests/integration/ One per FR (125/126/127/128), NFR-020/024/025, observability fields, idempotency. Skip cleanly via skipIfNoDb / skipIfNoAws guards
Policy tests/policy/ PAY-005 GATE (negative + bypass-token scan), AML-005 ALERT (latency assertion), CON-005 AUTO (immediate-effect + cache-token scan)

Run with pnpm test:unit (no AWS) or pnpm test:integration (against deployed dev).

Coverage gate: vitest threshold ≥ 80% lines / functions, ≥ 75% branches on the unit-testable surface (src/lib/* + src/services/{velocity,limit-resolver}.ts). Handler and DB-touching service files are exercised by the integration suite, not the unit gate.


Acceptance criteria status

To be filled in by CI on first deploy. Until then:

FR / Policy Mode Tests Status
FR-125 tests/integration/fr-125-limits.test.ts; tests/unit/velocity.test.ts Implemented
FR-126 tests/integration/fr-126-audit.test.ts Implemented
FR-127 tests/integration/fr-127-failed-counted.test.ts Implemented
FR-128 tests/integration/fr-128-event.test.ts; infra/event-rules.ts MOD-063 wiring Implemented
NFR-020 tests/integration/nfr-020-availability.test.ts; alarms.ts MetricAlarm Implemented
NFR-024 tests/integration/nfr-024-audit-immutability.test.ts; V004 trigger Implemented
NFR-025 tests/integration/nfr-025-latency.test.ts; alarms.ts p99 alarm Implemented
PAY-005 GATE tests/policy/pay-005-gate.test.ts (negative + no-bypass) Implemented
AML-005 ALERT tests/policy/aml-005-alert.test.ts (latency assertion) Implemented
CON-005 AUTO tests/policy/con-005-auto.test.ts (no-cache + immediacy) Implemented

Operational notes

  • Deploy: pnpm install --frozen-lockfile && pnpm typecheck && pnpm test:unit --coverage && pnpm run deploy --stage <env> from the module dir.
  • Migrations apply BEFORE deploy via the flyway CI job in .github/workflows/mod-021.yml. Direct host from SSM (/bank/{env}/neon/direct-host), credentials from Secrets Manager (bank-neon/{env}/bank_payments/bank_payments_migrate_user).
  • Local dev: pnpm test:unit runs without AWS or Postgres; integration and policy suites skip with a clear marker if AWS or Neon are not reachable.
  • Removing the SCHEMA_REGISTRY_NAME env var or the EventBridge bus ARN SSM parameter will fail closed — no payload reaches the bus. Required for both PAY-005 GATE and AML-005 ALERT.

  • Wiki spec: bank-wiki/source/entities/modules/MOD-021.{yaml,md}
  • Handoff: docs/handoffs/MOD-021-complete.handoff.md
  • Interface contract: bank-wiki/source/pages/design/system/interface-contracts.md §SD04 internal — MOD-020 → MOD-021
  • Event catalogue: bank-wiki/source/pages/design/system/event-catalogue.md (bank.payments.limit_breach_detected)
  • Methodology / standards: observability-standard.md, error-handling-standard.md, schema-registry.md, seed-consumers-guide.md
  • ADRs in effect: ADR-001, ADR-024, ADR-025, ADR-029, ADR-030, ADR-031, ADR-038, ADR-042, ADR-043, ADR-044, ADR-045, ADR-048, ADR-051, ADR-052