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/arntopic (MOD-104). - Dashboard:
bank-{stage}-MOD-025.
Known follow-ups (handoff)¶
- SD04 data model wiki page must add
payments.fx_lock_audit. - MOD-104 cross-repo grant: BankPaymentsRole needs
lambda:InvokeFunctionon the MOD-004 FxConversionHandler ARN. - MOD-022 (payment audit trail) is not built; the
bank.payments.fx_rate_lockedandfx_conversion_completedconsumers will land when MOD-022 ships. - MOD-082 hedging trigger consumer will land when MOD-082 ships (parallel build candidate).