Skip to content

Technical design — MOD-082 Nostro & FX treasury management

Module: MOD-082 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-161, FR-162, FR-163, FR-164 NFR scope: NFR-013, NFR-019, NFR-024 Policies satisfied: CLQ-002 (CALC), PAY-002 (GATE), PAY-008 (AUTO) Author: AI coding agent (Claude) Date: 2026-05-04

Objective

MOD-082 maintains the bank's correspondent-bank ("nostro") account positions in real time. Every settlement / FX conversion event flows into the position-updater Lambda via EventBridge and reduces or increases the relevant payments.nostro_positions row within ≤1 minute (FR-161). It exposes a synchronous balance-check API that MOD-020 calls before allowing any outbound cross-border payment (PAY-002 GATE). It snapshots positions daily for FR-163 reporting and records every manual treasury adjustment in an immutable audit log (FR-164).

The module is treasury-facing — invisible to customers — and complements MOD-025's customer-facing FX surface.

Architecture

API Gateway HTTP API
   POST /internal/v1/nostro/check        ─▶ Mod082CheckBalanceHandler
   GET  /internal/v1/nostro/positions    ─▶ Mod082PositionsQueryHandler
   POST /internal/v1/nostro/adjustments  ─▶ Mod082AdjustmentHandler

EventBridge bank-payments
   fx_conversion_completed   ─┐
   fx_rate_locked            ─┼─▶ Mod082PositionUpdaterHandler
   settlement_file_received  ─┘

EventBridge bank-core (cross-bus)
   balance_updated            ─▶ Mod082PositionUpdaterHandler

EventBridge schedules
   cron(55 11 * * ? *)  NZ EOD 23:55 NZST  ─▶ Mod082EodSnapshotHandler { jurisdiction: "NZ" }
   cron(55 13 * * ? *)  AU EOD 23:55 AEST  ─▶ Mod082EodSnapshotHandler { jurisdiction: "AU" }

EventBridge bank-payments (publish)
   nostro_threshold_breached  ◀─ Mod082PositionUpdaterHandler (on threshold cross)

Tables

Table Migration Purpose Mutability
payments.nostro_positions V001 Per-corridor live position. SD04 data model defines the table; V001 adds maximum_threshold (proposed for wiki update). Mutable
payments.nostro_adjustment_audit V002 Per-adjustment immutable log (FR-164). Append-only via V003 trigger
payments.nostro_position_eod_snapshots V004 Daily snapshot for FR-163 reporting. Append-only via V005 trigger
payments.idempotency_keys (MOD-021 V005) Shared SD04 idempotency store. INSERT/SELECT/DELETE per existing grants

Append-only enforcement on the two audit tables follows ADR-048 Category 1 — BEFORE UPDATE / DELETE / TRUNCATE triggers raise insufficient_privilege.

Dev / UAT seed (V900)

Three corridors seeded for integration tests in dev/uat (no-op in prod):

correspondent_bank nostro_account_ref currency minimum maximum
CUSCAL NSTRO-NZD-001 NZD 2,000,000.00 10,000,000.00
CUSCAL NSTRO-AUD-001 AUD 1,500,000.00 9,000,000.00
CITIBANK NSTRO-USD-001 USD 500,000.00 5,000,000.00

Lambdas

Function Trigger Purpose
Mod082CheckBalanceHandler HTTP POST /internal/v1/nostro/check PAY-002 GATE pre-check called by MOD-020
Mod082PositionsQueryHandler HTTP GET /internal/v1/nostro/positions Treasury read API
Mod082AdjustmentHandler HTTP POST /internal/v1/nostro/adjustments Manual adjustment + immutable audit (FR-164)
Mod082PositionUpdaterHandler EventBridge consumer Apply event deltas + threshold-breach publish
Mod082EodSnapshotHandler Cron (NZ + AU EOD) Daily snapshot writes (FR-163)

Memory: 512 MB. Timeouts: hot path 10s, consumer 30s, EOD 120s. Reserved concurrency tiered (prod / uat / dev).

EventBridge

Consumes

Event Source bus Notes
bank.payments.fx_conversion_completed bank-payments Source-side inflow + target-side outflow.
bank.payments.fx_rate_locked bank-payments Hedging signal; soft pending reservation.
bank.payments.settlement_file_received bank-payments Position update post-scheme settlement (MOD-081 not deployed; handler is a no-op until then).
bank.core.balance_updated bank-core (cross-bus) Filtered to internal nostro account product codes. Cross-bus rule requires the MOD-104 grant filed in docs/handoffs/MOD-104-bank-core-cross-bus-grant.handoff.md.

Publishes

Event Notes
bank.payments.nostro_threshold_breached NEW — pending wiki catalogue add. Schema in schemas/.

SSM contract

Reads

Path From
/bank/{stage}/neon/pooler-host, /bank/{stage}/neon/direct-host MOD-103
/bank/{stage}/eventbridge/bank-payments/arn MOD-104
/bank/{stage}/eventbridge/bank-core/arn MOD-104
/bank/{stage}/iam/lambda/bank-payments/arn MOD-104
/bank/{stage}/observability/adot-nodejs-layer-arn MOD-076
/bank/{stage}/sns/alerts/arn MOD-104

Writes

Path Value
/bank/{stage}/mod-082/api/base-url API Gateway base URL
/bank/{stage}/mod-082/check-balance/url Full /internal/v1/nostro/check URL
/bank/{stage}/mod-082/positions/url Full /internal/v1/nostro/positions URL
/bank/{stage}/mod-082/adjustments/url Full /internal/v1/nostro/adjustments URL
/bank/{stage}/mod-082/check-balance-lambda/arn Lambda ARN
/bank/{stage}/mod-082/positions-query-lambda/arn Lambda ARN
/bank/{stage}/mod-082/adjustment-lambda/arn Lambda ARN
/bank/{stage}/mod-082/position-updater-lambda/arn Lambda ARN
/bank/{stage}/mod-082/eod-snapshot-lambda/arn Lambda ARN
/bank/{stage}/mod-082/nostro-positions-table payments.nostro_positions
/bank/{stage}/mod-082/nostro-adjustment-audit-table payments.nostro_adjustment_audit
/bank/{stage}/mod-082/nostro-position-eod-snapshots-table payments.nostro_position_eod_snapshots

Configuration

Env Default Purpose
MAX_RATE_AGE_SECONDS 86400 Stale-rate guard for EOD P&L computation
LOG_LEVEL info (prod) / debug (non-prod) Log gate
PAYMENTS_BUS_NAME bank-payments Publish target

Policy mapping

Policy Mode How satisfied Test
CLQ-002 CALC Position deltas resolve arithmetically: source-side inflow + target-side outflow on every fx_conversion_completed; balance_updated deltas applied to internal nostro accounts. tests/policy/clq-002-calc.test.ts (unit math) + tests/integration/fr-161-real-time-position.test.ts (live event-driven)
PAY-002 GATE The check-balance API returns the truthful available_balance and sufficient flag MOD-020 reads for its gate. Refuses to lie about balance; rejects unknown currencies with position_id: null. No bypass tokens. tests/policy/pay-002-gate.test.ts (compare logic + token scan) + tests/integration/check-balance.test.ts (live)
PAY-008 AUTO Position-updater consumes all four event types automatically; no manual ETL step in the position path. Manual adjustments go through nostro_adjustment_audit so the auto-vs-manual distinction is reconcilable. External correspondent statement reconciliation = v2 follow-up. tests/policy/pay-008-auto.test.ts (handler + event-rules wiring scan)

Performance approach

  • NFR-013 ≤ 5 ms p99 on the check-balance hot path: a single aggregateAvailableForCurrency SELECT against payments.nostro_positions indexed on (currency, position_date DESC). withConnection (no BEGIN/COMMIT) for read-only.
  • FR-161 ≤ 1 min position-update latency: EventBridge delivery is sub-second; only Lambda cold-start + DB UPDATE counts. Comfortable inside the budget. Alarm trips at p99 ≥ 10s.

Error handling

  • Sync HTTP paths — standard error envelope per bank-wiki/source/pages/design/system/error-handling-standard.md (HTTP 422 / 503 / 500).
  • EventBridge consumer — re-raise on transient failures so EB retries; bank-payments / bank-core DLQ catches after retry exhaustion.
  • Scheduled paths (EOD) — re-raise on transient failures; alarm trips on errors ≥ 1 in 5 min over 3 evaluation periods.

Event types emitted in structured logs

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

position_updated, position_update_failed, threshold_breached, threshold_breach_publish_failed, balance_check_passed, balance_check_failed, adjustment_recorded, adjustment_rejected, eod_snapshot_completed, eod_snapshot_partial, reconciliation_run_completed, reconciliation_discrepancy_detected, idempotency_replay, trace_id_missing_from_upstream, validation_failed, internal_error.

Test approach

Tier Files Status
Unit (≥80% gate) tests/unit/{amount,errors,trace,logger,emf,position-math,threshold-monitor}.test.ts 36 / 36
Contract tests/contract/{nostro-threshold-breached-schema,api-contract}.test.ts 14 / 14
Policy satisfaction tests/policy/{clq-002-calc,pay-002-gate,pay-008-auto}.test.ts 10 / 10
FR integration (one per FR + check-balance + idempotency + observability + NFR-024) tests/integration/*.test.ts runs in CI under RUN_INTEGRATION=1
Smoke (verify-deployment.mjs) tests/verify-deployment.mjs runs after smoke phase

skipIfNoDb() guards keep the integration tier green-with-skips locally; CI sets RUN_INTEGRATION=1 so the guards never fire and probe failures surface as test failures (per ADR-053).

Security and data handling

  • No customer PII flows through MOD-082. Position data is by correspondent bank + nostro account ref + currency — all internal treasury constructs.
  • The two audit tables (nostro_adjustment_audit, nostro_position_eod_snapshots) have INSERT-only role grants + trigger-level append-only enforcement.
  • The adjustment API trusts caller-asserted operator_id / operator_role — the actual role-based authorisation lives in MOD-068 / MOD-064 once those modules ship. Until then, the audit row records the asserted identity for downstream investigation.

Open items / handoff follow-ups

  1. bank.payments.nostro_threshold_breached — wiki catalogue add. New event; schema in schemas/. Consumer hint: MOD-022 (audit trail) and MOD-064 (treasury work queue, future). Add to bank-wiki/source/pages/design/system/event-catalogue.md.

  2. SD04 data model — two new tables. Add to bank-wiki/source/pages/design/system/data-models/SD04-payments.md:

  3. payments.nostro_adjustment_audit (immutable per ADR-048 Cat 1)
  4. payments.nostro_position_eod_snapshots (immutable per ADR-048 Cat 1)

  5. SD04 data model — nostro_positions.maximum_threshold column. V001 adds maximum_threshold numeric(18,2) NULL to the table. Document this column in the wiki data model (the existing entry only lists minimum_threshold).

  6. Cross-bus IAM grant. BankPaymentsRole needs events:PutRule

  7. events:PutTargets on the bank-core bus. Filed in docs/handoffs/MOD-104-bank-core-cross-bus-grant.handoff.md. SST deploy is red until that grant lands.

  8. Lock-expiry release (FR-141 follow-up). MOD-025's expirer writes EXPIRED audit rows but emits no event. The hedge pending reservation MOD-082 applies on fx_rate_locked is therefore not automatically released when the lock expires unused. v2: emit a fx_lock_expired event from MOD-025; MOD-082 releases the reservation on receipt.

  9. External correspondent statement reconciliation (PAY-008 v2). No module in the bank ingests MT940 / camt.052 today. v1 reconciles nostro positions against the internal MOD-001/MOD-004 ledger only.

  10. DST handling on EOD cron. Same pending follow-up as MOD-003 / MOD-004 — both crons run on standard-time offsets and accept the 1-hour slip across DST. Future maintenance pass to add DST-aware cron rules.