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.postingsoutside MOD-001's handler if and only if all of the following hold:
- Atomicity required — the write is part of a multi-leg journal that needs single-transaction guarantees an HTTP saga cannot provide.
- Same writer role — INSERT runs as
bank_core_app_user. V001'sINSERT-onlygrant and V003's append-only trigger remain the gate.- Schema parity — every NOT NULL column populated identically to MOD-001's poster (
id = gen_uuidv7(),entry_type ∈ {DEBIT, CREDIT},amount > 0,currencyactive inaccounts.currency_register,jurisdiction ∈ {NZ, AU},source_module = 'MOD-NNN',narrativeset,metadata jsonbwith at minimum{module_id, ...domain_id}).- Balance update — writer updates
accounts.accounts.{balance, available_balance, version}for every affected account inside the same transaction. PessimisticSELECT … FOR UPDATEon each row in deterministic ID order.- Eventing — emit
bank.core.posting_completedfor 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.- Idempotency — caller-supplied key on a UNIQUE constraint observable to the writer.
- 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 V007is_internalflag. - A5.a — widen MOD-002 ingest rule: 1-line
event-rules.tsedit shipped with this PR addsfx_conversion_completedto the consumed pattern. - A6 — cross-border = currency or jurisdiction crosses
- A7 — fail with
RATE_UNAVAILABLE 503when 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-leg —
accounts.postingsrows from revaluation are excluded from FR-060 trial-balance reconciliation viametadata.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.