Skip to content

MOD-004 — Multi-currency ledger (NZD/AUD)

System: SD01 Core Banking · Repo: bank-core Phase: 4 · Build status: Built (pre-deploy) Depends on: MOD-001, MOD-002, MOD-104, MOD-103 ADR: ADR-015 (Cross-border NZ/AU wallet) Date: 2026-04-30

Purpose

Extends MOD-001's posting engine with multi-currency support per ADR-015. Customer accounts already carry a currency column; MOD-004 adds the missing pieces around them:

  • A currency register that gates which ISO 4217 codes may appear on a posting (FR-433, FR-058)
  • An FX-conversion ledger that ties source/target posting pairs together with the applied rate, spread, and rate timestamp (FR-434)
  • Daily revaluation of open positions when a new mid-market rate lands, posting gain/loss to the FX P&L account (FR-435)
  • A daily multi-currency trial balance report (FR-060)
  • A cross-currency endpoint returning a single NZD-equivalent total for all of a party's sub-accounts (FR-436)

The four-leg atomic journal (Dr customer NZD → Cr NZD nostro / Dr AUD nostro → Cr customer AUD) is the workhorse. MOD-004 is the ledger surface; rate lock and customer disclosure UX belong upstream in MOD-051 / payment modules.

Declared ledger-direct-write contract

Per orchestrator A1.c, MOD-004 writes directly to accounts.postings inside its own transaction rather than calling MOD-001's posting handler four times over HTTP. This is the first SD01 module other than MOD-001 to do so, and the contract is captured here for wiki adoption as pattern: ledger-direct-write.

An SD01 module MAY write directly to accounts.postings outside MOD-001's handler if and only if all of the following hold:

  1. Atomicity required — the write is part of a multi-leg journal that needs single-transaction guarantees an HTTP saga cannot provide.
  2. Same writer role — INSERT runs as bank_core_app_user. V001's INSERT-only grant and V003's append-only trigger remain the gate.
  3. Schema parity — every NOT NULL column populated identically to MOD-001's poster (id = gen_uuidv7(), entry_type ∈ {DEBIT, CREDIT}, amount > 0, currency active in accounts.currency_register, jurisdiction ∈ {NZ, AU}, source_module = 'MOD-NNN', narrative set, metadata jsonb with at minimum {module_id, ...domain_id}).
  4. Balance update — writer updates accounts.accounts.{balance, available_balance, version} for every affected account inside the same transaction. Pessimistic SELECT … FOR UPDATE on each row in deterministic ID order.
  5. Eventing — emit bank.core.posting_completed for every leg OR a domain-specific event (e.g. fx_conversion_completed) that MOD-002's ingest rule pattern explicitly subscribes to. Skipping events breaks the audit log.
  6. Idempotency — caller-supplied key on a UNIQUE constraint observable to the writer.
  7. Verification — integration tests include a row-hash equivalence check against core.transaction_log.

Outside these conditions, modules call MOD-001's posting handler.

MOD-004 satisfies (1) — atomicity (4 legs in one tx); (2) — uses bank_core_app_user; (3) — every NOT NULL column populated, including the new fx_conversion_id FK; (4) — pessimistic locks via loadAccountForUpdate; (5) — emits bank.core.fx_conversion_completed, to which MOD-002's ingest rule subscribes (widened by this PR); (6) — accounts.fx_conversions(idempotency_key) UNIQUE; (7) — covered by FR-434 integration test (cross-checks via row-hash).

Tables and columns owned by MOD-004

Table Migration Purpose
accounts.currency_register V001 ISO 4217 active-codes register (FR-433)
accounts.fx_rates V002 Append-only price history (FR-435/436)
accounts.fx_conversions V002 Audit row per 4-leg conversion (FR-434, PAY-004 LOG)
accounts.daily_trial_balance V004 Daily per-currency reconciliation (FR-060, REP-002 CALC)
accounts.fx_revaluation_snapshots V008 One mutable row per jurisdiction holding the most recent revaluation state

Cross-module additive ALTERs (mirroring MOD-003 V002 pattern):

Migration Target Change
V003 accounts.postings Add nullable fx_conversion_id uuid REFERENCES accounts.fx_conversions(id) + index
V007 accounts.accounts Add is_internal bool NOT NULL DEFAULT false + backfill the four V006-seeded internal accounts

Append-only triggers (V005) on fx_conversions, fx_rates, daily_trial_balance — load-bearing for append-only because bank_core_app_user has BYPASS RLS (MOD-103 follow-up still open from MOD-003's handoff).

Internal accounts (V006 seed)

account_number product_code currency jurisdiction Purpose
INT-NZ-NZD-NOSTRO INTERNAL_FX_NOSTRO_NZD NZD NZ NZD leg of FX nostro pair
INT-AU-AUD-NOSTRO INTERNAL_FX_NOSTRO_AUD AUD AU AUD leg of FX nostro pair
INT-NZ-FX-PL INTERNAL_FX_PL_NZD NZD NZ FR-435 P&L collector for NZ jurisdiction
INT-AU-FX-PL INTERNAL_FX_PL_AU AUD AU FR-435 P&L collector for AU jurisdiction

All four flagged is_internal = true (V007 backfill). The cross-currency balance endpoint (FR-436) filters them out so customer-facing party totals are clean.

Lambdas

Function Trigger Purpose
Mod004FxConversionHandler HTTP POST /internal/v1/fx/convert 4-leg ADR-015 conversion
Mod004CrossCurrencyBalanceHandler HTTP GET /internal/v1/balance/cross-currency/{party_id} FR-436 NZD-equivalent total
Mod004FxRateIngest EventBridge bank.core.fx_rate_received Insert rate row into accounts.fx_rates
Mod004EodRevaluation Cron cron(55 11 * * ? *) (NZ) + cron(55 13 * * ? *) (AU); also direct invoke for flash FR-435 revaluation
Mod004TrialBalance Cron cron(58 11 * * ? *) (NZ) + cron(58 13 * * ? *) (AU) FR-060 daily trial balance
Mod004DevRateSeeder Cron rate(30 minutes) (non-prod only) Fires fx_rate_received to keep dev/uat tests fed

EventBridge

Consumes: bank.core.fx_rate_received

Publishes: - bank.core.fx_conversion_completed — every committed 4-leg conversion. MOD-002's ingest rule widened in this PR to capture it. - bank.core.fx_revaluation_completed — every revaluation run that produced a non-zero P&L.

SSM outputs

Path Type Purpose
/bank/{stage}/mod-004/api/base-url String HTTP API root
/bank/{stage}/mod-004/fx-convert/url String Convenience: full /fx/convert URL
/bank/{stage}/mod-004/balance/cross-currency/url String Convenience: full balance URL prefix
/bank/{stage}/mod-004/fx-conversions-table String accounts.fx_conversions
/bank/{stage}/mod-004/fx-rates-table String accounts.fx_rates
/bank/{stage}/mod-004/currency-register-table String accounts.currency_register
/bank/{stage}/mod-004/fx-conversion-lambda/arn String Lambda ARN
/bank/{stage}/mod-004/cross-currency-balance-lambda/arn String Lambda ARN
/bank/{stage}/mod-004/fx-rate-ingest-lambda/arn String Lambda ARN
/bank/{stage}/mod-004/eod-revaluation-lambda/arn String Lambda ARN
/bank/{stage}/mod-004/trial-balance-lambda/arn String Lambda ARN
/bank/{stage}/mod-004/dev-rate-seeder-lambda/arn String Lambda ARN

Configuration

Env Default (prod) Default (non-prod) Purpose
MAX_RATE_AGE_HOURS 24 720 (30 days) FR-436 staleness threshold
SPREAD_MAX_PCT 0.05 0.05 Sanity guard for spread input
TARGET_AMOUNT_TOLERANCE_MINOR 1 1 Cents of rounding tolerance when validating caller-asserted target_amount
DEV_RATE_NZDAUD (n/a) 0.9233 Dev seeder rate (env override on the seeder Lambda)

Policy mapping

Policy Mode How satisfied
PAY-004 LOG accounts.fx_conversions row stores rate, spread, timestamp, and links to all 4 posting legs. V005 trigger guarantees immutability. Test: tests/policy/pay-004-log-fx-audit-trail.test.ts
CLQ-001 CALC Every posting carries an explicit currency tag (FR-058); the schema preserves currency-split balances queryable by jurisdiction. Test: tests/policy/clq-001-calc-currency-split-balance.test.ts
AML-008 AUTO cross_border flag set when source_currency ≠ target_currency OR source_jurisdiction ≠ target_jurisdiction. Carried on bank.core.fx_conversion_completed. Test: tests/policy/aml-008-auto-cross-border-flag.test.ts
REP-002 CALC Daily trial balance reconciles per (date, jurisdiction, currency); unreconciled rows alarm and block submission. Test: tests/policy/rep-002-calc-currency-split-trial-balance.test.ts

Decisions captured in resolution dialogue

  • A1.c — direct write to accounts.postings (declared contract above)
  • A2.a + A2.c — dev rate seeder + EventBridge consumer: production rates flow Snowflake → marketplace provider → EventBridge → MOD-004 ingest. Dev fires the same event from a Lambda cron at 30-min cadence.
  • A3.a — internal accounts via accounts.accounts: the 4 nostro / P&L accounts live alongside customer accounts, distinguished only by the V007 is_internal flag.
  • A5.a — widen MOD-002 ingest rule: 1-line event-rules.ts edit shipped with this PR adds fx_conversion_completed to the consumed pattern.
  • A6 — cross-border = currency or jurisdiction crosses
  • A7 — fail with RATE_UNAVAILABLE 503 when no rate < MAX_RATE_AGE_HOURS; dev default 720h compensates for sparse seed data.
  • A9 — flash revaluation supported: idempotency keyed on (jurisdiction, rate_timestamp); same rate ⇒ no-op, new rate ⇒ new P&L delta.

Known follow-ups

  • Revaluation P&L is single-legaccounts.postings rows from revaluation are excluded from FR-060 trial-balance reconciliation via metadata.revaluation_pnl='true'. Proper double-entry treatment (offsetting "FX translation reserve" account) deferred to MOD-140 Chart of Accounts. Captured here so the next pass doesn't lose it.
  • Daylight saving on EOD cron — same as MOD-003's open follow-up. Two extra cron rules with DST-window expressions in a future maintenance pass.
  • Currency expansion (CAP-003) — USD/EUR/GBP/SGD/JPY beyond NZD/AUD requires (a) more rows in currency_register, (b) per-pair rate seeding, (c) per-currency nostro pairs. Schema is ready; the product launch is the gate.
  • MOD-103 BYPASS RLS — still open from MOD-003 handoff. V005 triggers compensate for now.