"INV multi-currency support — VND/USD/SGD/EUR/GBP with daily SBV FX snapshot + per-invoice currency lock + cross-currency reporting"
§1 — Description (BCP-14 normative)
The INV service MUST ship multi-currency FX support at services/inv/src/fx/ with daily SBV+ECB snapshots, per-date deterministic conversion, CFO-gated manual override, and 4 memory audit kinds.
- MUST define closed
fx_sourceenum:('sbv_daily','ecb_daily','manual_override')per DEC-1512. Cardinality 3.
- MUST define
fx_ratestable at migration0006:(snapshot_date DATE NOT NULL, base_currency billing_currency_enum NOT NULL, quote_currency billing_currency_enum NOT NULL, rate NUMERIC(18,8) NOT NULL CHECK (rate > 0), source fx_source NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), recorded_by_subject_id UUID, override_reason TEXT, PRIMARY KEY (snapshot_date, base_currency, quote_currency)).
- MUST invoice currency immutable per DEC-1510 — already enforced by TASK-INV-001 + engagement.billing_currency immutability per TASK-TEN-003.
- MUST snapshot SBV daily at 09:00 UTC per DEC-1511 via
snapshot_job.rs:
- Fetch SBV reference rates (VND base; USD/EUR/JPY/CNY/etc cross-rates).
- Persist all (VND, X) pairs.
- Fetch ECB rates as cross-validation + non-SBV pairs.
- Failure → emit
inv.fx_snapshot_failedsev-2; retry hourly.
- MUST support deterministic as-of conversion per DEC-1513 via
converter.rs::convert(amount_minor, from, to, as_of_date):
- Lookup
fx_rates(as_of_date, from, to). - If direct rate missing, compute via VND base:
from→VND→to. - Returns same value on every call with same inputs.
- If no rate available for date → 412 +
no_fx_for_date.
- MUST support CFO manual override via
POST /v1/admin/inv/fx/overridebody{ snapshot_date, base, quote, rate, reason }. Caller hascfo. Inserts row with source='manual_override' + emitsinv.fx_manual_overridesev-1.
- MUST consume by INV-011 (revenue recognition) + reporting endpoints for cross-currency rollup.
- MUST emit 4 memory audit kinds per DEC-1515.
- MUST thread trace_id end-to-end.
- MUST NOT mutate invoice currency post-creation (DEC-1510).
- MUST NOT allow non-CFO override (DEC-1512).
§2 — Why this design
Why SBV official rate (DEC-1511)? VN tax law references SBV daily rate for VAT/CIT calculations; using SBV ensures regulatory alignment.
Why per-date snapshot (DEC-1513)? Reports referencing past dates must reproduce. Snapshot-based vs live FX = deterministic.
Why CFO manual override (DEC-1512)? Edge cases (weekend dates, missing SBV publication days, treaty rates) need a manual path with audit trail.
§3 — API contract
CREATE TYPE fx_source AS ENUM ('sbv_daily','ecb_daily','manual_override');
CREATE TABLE fx_rates (
snapshot_date DATE NOT NULL,
base_currency billing_currency_enum NOT NULL,
quote_currency billing_currency_enum NOT NULL,
rate NUMERIC(18,8) NOT NULL CHECK (rate > 0),
source fx_source NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
recorded_by_subject_id UUID,
override_reason TEXT,
trace_id CHAR(32),
PRIMARY KEY (snapshot_date, base_currency, quote_currency)
);
CREATE INDEX idx_fx_date ON fx_rates(snapshot_date DESC);
REVOKE UPDATE, DELETE ON fx_rates FROM cyberos_app;
Endpoints:
GET /v1/inv/fx/rates?as_of=...&base=VND
POST /v1/admin/inv/fx/override (cfo)
GET /v1/inv/fx/convert?amount=...&from=USD&to=VND&as_of=...
§4 — Acceptance criteria
- fx_source cardinality 3. 2. Daily SBV snapshot persists VND pairs. 3. ECB fallback for non-VND pairs. 4. As-of conversion deterministic — same inputs same output. 5. Direct pair preferred over via-VND. 6. Via-VND cross-rate computation — USD→EUR via VND. 7. Missing date → 412. 8. Manual override CFO-only. 9. Override reason required. 10. 4 memory audit kinds emitted. 11. Snapshot retry on failure. 12. Invoice currency immutable. 13. Trace_id end-to-end. 14. PII scrub override reason. 15. Concurrent snapshot race-safe (PRIMARY KEY). 16. Cross-currency report uses as-of correctly. 17. Rate CHECK > 0. 18. Weekend SBV gap handled — falls forward to prior Friday rate. 19. Per-tenant base currency for reports. 20. All 5 currencies supported (VND/USD/SGD/EUR/GBP).
§5 — Verification
#[tokio::test]
async fn daily_snapshot_persists() {
let ctx = TestContext::with_mocked_sbv().await;
ctx.run_snapshot_job(today()).await;
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM fx_rates WHERE snapshot_date=$1")
.bind(today()).fetch_one(&ctx.pool).await.unwrap();
assert!(count >= 4); // VND-USD, VND-EUR, VND-SGD, VND-GBP minimum
}
#[tokio::test]
async fn as_of_deterministic() {
let ctx = TestContext::with_fx_snapshot(date!(2026-05-15)).await;
let r1 = ctx.convert(100_00, "USD", "VND", date!(2026-05-15)).await;
let r2 = ctx.convert(100_00, "USD", "VND", date!(2026-05-15)).await;
assert_eq!(r1, r2);
}
#[tokio::test]
async fn manual_override_cfo_only() {
let ctx = TestContext::new().await;
let r = ctx.as_engagement_admin().fx_override(date!(2026-05-15), "VND", "USD", 24500).await;
assert_eq!(r.status(), 403);
let r2 = ctx.as_cfo().fx_override(date!(2026-05-15), "VND", "USD", 24500).await;
assert_eq!(r2.status(), 200);
}
// 5.4..5.10
§7 — Dependencies
Upstream: TASK-INV-001. Cross-module: TASK-AUTH-101 (cfo role), TASK-AI-003, TASK-MEMORY-111. Consumed by: TASK-INV-011, TASK-TEN-003, TASK-TEN-102 reporting.
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| SBV API down | timeout | Failed snapshot; retry hourly | ECB fallback temporary |
| ECB API down | timeout | Sev-2 alert; manual override path | Inherent |
| Weekend gap | date-not-found | Falls forward to prior weekday rate | Inherent |
| Future date conversion | check | 412 | Inherent |
| Cross-rate inconsistency (SBV vs ECB) | drift > 1% | Sev-3 alert | Manual review |
| Rate precision overflow | NUMERIC(18,8) | INSERT fail | Use BigDecimal lib |
| Snapshot race | PRIMARY KEY | One wins | Inherent |
| Manual override conflicts with snapshot | source enum | Override row coexists; converter prefers manual | Inherent |
| Concurrent override | tx isolation | Last-writer-wins | Inherent |
| Currency not in enum | billing_currency_enum check | 400 | Inherent |
| SBV publishes different rate retroactively | doesn't happen per SBV policy | N/A | Inherent |
| Tenant base currency change | grandfather past reports | Inherent | Reports re-render with current base |
§11 — Implementation notes
- §11.1 SBV scraping via
sbv.gov.vndaily PDF; backup ECB JSON API. - §11.2 Snapshot at 09:00 UTC = 2pm Vietnam time; SBV publishes by then.
- §11.3 Cross-rate via VND base when direct missing; documented as canonical path.
- §11.4 NUMERIC(18,8) gives 10 decimals + 8 fractional — sufficient for any FX precision.
- §11.5 Weekend gap rule: use Friday's rate for Sat/Sun queries.
End of TASK-INV-002 spec.