Skip to content

MOD-025 — FX rate lock & conversion

System: SD04 Payments · Repo: bank-payments Module dir: MOD-025-fx-rate-lock/ Build status: In progress · ADRs: ADR-015, ADR-029, ADR-031, ADR-048, ADR-053 Date: 2026-05-04


Purpose

MOD-025 is the customer-facing FX rate-lock service. It stands between the customer's cross-currency payment intent and the multi-currency posting engine (MOD-004), guaranteeing the customer a fixed rate for a 30–60 second window per ADR-015 and PAY-004.

Two synchronous endpoints plus one scheduled Lambda:

Lambda Trigger Purpose
Mod025LockRateHandler HTTP POST /internal/v1/fx/lock-rate FR-141 — issue a rate lock
Mod025ConfirmConversionHandler HTTP POST /internal/v1/fx/confirm FR-142 — invoke MOD-004 to commit the 4-leg conversion
Mod025ExpireLocksHandler rate(1 minute) schedule FR-143 — append EXPIRED audit rows for unused expired locks

Functional / non-functional requirements

ID Summary
FR-141 Lock the customer rate at confirmation; default 30s window
FR-142 Apply the locked rate via MOD-004 atomic 4-leg posting
FR-143 Expire the lock after the window; record expiry in the audit trail
FR-144 Immutable FX-lock log of issue / consume / expire
NFR-003 Cross-border settlement p99 ≤ 2 minutes
NFR-024 Audit log mutability = 0
NFR-025 Customer confirmation ≤ 5 seconds

Architecture

┌──────────────────┐    POST /lock-rate
│  upstream caller │──────────────────────┐
│ (MOD-051 / app)  │                      ▼
└──────────────────┘            ┌─────────────────────┐
                                │ LockRateHandler     │
                                │  (FR-141)           │
                                │                     │
                                │ 1. resolveRate      │──► payments.fx_rates  (read; MOD-085 owns)
                                │ 2. deriveQuote      │
                                │ 3. INSERT fx_locks  │──► payments.fx_locks
                                │ 4. INSERT audit     │──► payments.fx_lock_audit (ISSUED)
                                │ 5. PutEvents        │──► bank-payments bus → fx_rate_locked
                                └─────────────────────┘

POST /confirm
┌──────────────────┐
│ ConfirmHandler   │
│  (FR-142)        │
│                  │     SDK invoke
│ 1. SELECT FOR    │──► MOD-004 FxConversionHandler
│    UPDATE lock   │      (atomic 4-leg journal in accounts.postings)
│ 2. assertUsable  │     ◄── conversion_id, source_posting_id, target_posting_id
│ 3. invokeFxConv  │
│ 4. UPDATE used_at│──► payments.fx_locks
│ 5. INSERT audit  │──► payments.fx_lock_audit (CONSUMED)
│ 6. PutEvents     │──► fx_conversion_completed
└──────────────────┘

rate(1 minute)
┌──────────────────┐
│ ExpireLocks      │
│  (FR-143)        │
│                  │
│ SELECT expired   │──► payments.fx_locks WHERE used_at IS NULL AND expires_at <= now()
│ INSERT EXPIRED   │──► payments.fx_lock_audit
└──────────────────┘

Tables owned by MOD-025

The payments schema is owned across SD04. MOD-021 introduces it; we add:

Table Migration Mutability Purpose
payments.fx_locks V001 Mutable (used_at) One row per issued lock. Used_at is set when the conversion lands.
payments.fx_lock_audit V002 + V003 trigger Append-only (ADR-048 Cat 1) Lifecycle log: ISSUED, CONSUMED, EXPIRED.

payments.fx_rates is owned by MOD-085 (bank-risk-platform). MOD-025 reads it to find a fresh mid-market rate; it does not own DDL. The V900 dev/UAT seed migration inserts canonical NZD↔AUD rows when MOD-085 is not yet deployed in the target stage.

Wiki update requested at handoff

The SD04 data model page (bank-wiki/source/pages/design/system/data-models/SD04-payments.md) should add payments.fx_lock_audit to the table register. Schema below mirrors the migrations.

payments.fx_lock_audit (
  id uuid PK,
  fx_lock_id uuid NOT NULL,
  party_id uuid NOT NULL,
  event_type text NOT NULL CHECK (event_type IN ('ISSUED','CONSUMED','EXPIRED')),
  from_currency char(3) NOT NULL,
  to_currency char(3) NOT NULL,
  locked_rate numeric(16,8) NOT NULL,
  mid_rate numeric(16,8) NOT NULL,
  spread_bps int NOT NULL,
  from_amount numeric(18,2) NOT NULL,
  to_amount numeric(18,2) NOT NULL,
  rate_source text NOT NULL,
  rate_source_id uuid,
  -- ISSUED
  locked_at timestamptz, expires_at timestamptz,
  -- CONSUMED
  consumed_at timestamptz, payment_id uuid, conversion_id uuid,
  source_posting_id uuid, target_posting_id uuid,
  -- EXPIRED
  expired_at timestamptz,
  trace_id uuid NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
)

Triggers (ADR-048 Cat 1):
  trg_fx_lock_audit_no_update / no_delete / no_truncate

EventBridge contract

Published on bus bank-payments-{stage}, source bank.payments:

Detail-type Schema Consumers
fx_rate_locked schemas/bank.payments.fx_rate_locked.json MOD-022 audit, MOD-082 hedging
fx_conversion_completed schemas/bank.payments.fx_conversion_completed.json MOD-022, MOD-082, MOD-042 CDC

Schemas are uploaded to the bank-events-{stage} registry by the SST deploy step. AJV draft-04 validation runs in-process before each PutEvents — schema-rejected payloads never enter the bus.

MOD-025 consumes no events. The conversion path is synchronous (Lambda invoke into MOD-004), and the lock-rate read is a synchronous SELECT on payments.fx_rates.

SSM parameter contract

Reads

Path From
/bank/{stage}/iam/lambda/bank-payments/arn MOD-104
/bank/{stage}/eventbridge/bank-payments/arn MOD-104
/bank/{stage}/observability/adot-nodejs-layer-arn MOD-076
/bank/{stage}/sns/alerts/arn MOD-104
/bank/{stage}/neon/pooler-host MOD-103
/bank/{stage}/neon/direct-host MOD-103 (Flyway)
/bank/{stage}/mod-004/fx-conversion-lambda/arn MOD-004

Secrets: - bank-neon/{stage}/bank_payments/app_user — Lambda runtime connection - bank-neon/{stage}/bank_payments/migrate_user — Flyway

Writes

Path Value
/bank/{stage}/mod-025/fx-api/url API Gateway base URL
/bank/{stage}/mod-025/lock-rate/url full POST URL
/bank/{stage}/mod-025/confirm-conversion/url full POST URL
/bank/{stage}/mod-025/lock-rate-lambda/arn Lambda ARN
/bank/{stage}/mod-025/confirm-conversion-lambda/arn Lambda ARN
/bank/{stage}/mod-025/expire-locks-lambda/arn Lambda ARN
/bank/{stage}/mod-025/fx-locks-table payments.fx_locks
/bank/{stage}/mod-025/fx-lock-audit-table payments.fx_lock_audit

Cross-repo dependency: BankPaymentsRole lambda:InvokeFunction

MOD-025 invokes MOD-004's FxConversionHandler synchronously via the AWS Lambda SDK. BankPaymentsRole as provisioned by MOD-104 today carries no lambda:InvokeFunction grant. The grant request is filed in docs/handoffs/MOD-104-fx-convert-invoke-grant.handoff.md.

The FR-142 integration test (tests/integration/fr-142-conversion.test.ts) will be RED until the grant lands. SST deploy itself succeeds; only the runtime invoke path is gated. No code change is needed once the grant is applied — the next CI run on the same SHA will turn green.

Configuration

Env var Default Purpose
LOCK_WINDOW_SECONDS 30 FR-141 lock window
SPREAD_BPS 50 Customer spread off mid (CON-005 disclosed)
MAX_RATE_AGE_SECONDS 600 PAY-004 stale-rate guard
MOD004_FX_CONVERT_LAMBDA_ARN (from SSM) MOD-004 invoke target

Policy mapping

Policy Mode How satisfied
PAY-004 LOG Append-only payments.fx_lock_audit records ISSUED / CONSUMED / EXPIRED with rate, spread, source. V003 trigger blocks UPDATE/DELETE/TRUNCATE. Test: tests/policy/pay-004-log.test.ts.
CON-005 GATE Lock-rate response carries mid_rate, locked_rate, spread_bps, rate_source. Resolver refuses to issue when no fresh rate (RATE_UNAVAILABLE). No-bypass token scan. Test: tests/policy/con-005-gate.test.ts.
CLQ-004 CALC Conversion event carries rate_applied + amounts; arithmetic invariant tested. Test: tests/policy/clq-004-calc.test.ts.

Test approach

Coverage gate is unit-only (vitest.config.ts) over the pure modules: errors, logger, trace, emf, amount, rate-resolver, spread-calculator. Handlers, services that touch db / EventBridge / Lambda invoke, and idempotency are exercised against deployed dev in the integration suite.

Tier Files Notes
unit tests/unit/* ≥80% line + function coverage gate
contract tests/contract/* Event JSON Schemas + Zod request shapes
policy tests/policy/* One per policies_satisfied row
integration tests/integration/fr-141 / fr-142 / fr-143 / fr-144 / idempotency / observability-fields RUN_INTEGRATION=1 in CI dev env
smoke tests/verify-deployment.mjs SSM resolves, Lambdas live, schemas registered, alarms present, synthetic invoke

Idempotency

Per the methodology pattern, both HTTP handlers cache by (idempotency_key, "MOD-025") in payments.idempotency_keys (24h TTL). Lock-rate replay returns the same lock_id. Confirm replay returns the same conversion_id/source_posting_id/target_posting_id. The store uses ON CONFLICT DO NOTHING, so a publish-success then store-fail can double-fire — consumers must dedupe on event_id (always fresh per PutEvents) and / or lock_id / conversion_id (deterministic).

Event types (MOD-025 logger registry)

rate_lock_issued, rate_lock_failed, rate_unavailable, rate_stale,
fx_conversion_started, fx_conversion_completed, fx_conversion_failed,
lock_consumption_failed, lock_expired, expirer_run_completed,
idempotency_replay, trace_id_missing_from_upstream, validation_failed,
internal_error

Observability

  • ADOT NodeJS layer attached via /bank/{stage}/observability/adot-nodejs-layer-arn.
  • Structured logger emits ADR-031 mandatory fields (trace_id, correlation_id, module_id, jurisdiction, event_type, party_id, level, timestamp; error_code/retryable on errors).
  • EMF metrics: rate_lock_total, rate_lock_duration_ms, fx_conversion_total, fx_conversion_duration_ms, fx_lock_expired_total.
  • Alarms (5): lock-rate Errors / p99, confirm-conversion Errors / p99, expirer Errors. All page the /bank/{stage}/sns/alerts/arn topic (MOD-104).
  • Dashboard: bank-{stage}-MOD-025.

Known follow-ups (handoff)

  1. SD04 data model wiki page must add payments.fx_lock_audit.
  2. MOD-104 cross-repo grant: BankPaymentsRole needs lambda:InvokeFunction on the MOD-004 FxConversionHandler ARN.
  3. MOD-022 (payment audit trail) is not built; the bank.payments.fx_rate_locked and fx_conversion_completed consumers will land when MOD-022 ships.
  4. MOD-082 hedging trigger consumer will land when MOD-082 ships (parallel build candidate).