Task — engineering-spec@1

"INV multi-currency support — VND/USD/SGD/EUR/GBP with daily SBV FX snapshot + per-invoice currency lock + cross-currency reporting"

draftTASK-INV-002
module inv · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-INV-001 · blocks none

§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.

  1. MUST define closed fx_source enum: ('sbv_daily','ecb_daily','manual_override') per DEC-1512. Cardinality 3.
  1. MUST define fx_rates table at migration 0006: (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)).
  1. MUST invoice currency immutable per DEC-1510 — already enforced by TASK-INV-001 + engagement.billing_currency immutability per TASK-TEN-003.
  1. MUST snapshot SBV daily at 09:00 UTC per DEC-1511 via snapshot_job.rs:
  1. MUST support deterministic as-of conversion per DEC-1513 via converter.rs::convert(amount_minor, from, to, as_of_date):
  1. MUST support CFO manual override via POST /v1/admin/inv/fx/override body { snapshot_date, base, quote, rate, reason }. Caller has cfo. Inserts row with source='manual_override' + emits inv.fx_manual_override sev-1.
  1. MUST consume by INV-011 (revenue recognition) + reporting endpoints for cross-currency rollup.
  1. MUST emit 4 memory audit kinds per DEC-1515.
  1. MUST thread trace_id end-to-end.
  1. MUST NOT mutate invoice currency post-creation (DEC-1510).
  1. 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

  1. 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

FailureDetectionOutcomeRecovery
SBV API downtimeoutFailed snapshot; retry hourlyECB fallback temporary
ECB API downtimeoutSev-2 alert; manual override pathInherent
Weekend gapdate-not-foundFalls forward to prior weekday rateInherent
Future date conversioncheck412Inherent
Cross-rate inconsistency (SBV vs ECB)drift > 1%Sev-3 alertManual review
Rate precision overflowNUMERIC(18,8)INSERT failUse BigDecimal lib
Snapshot racePRIMARY KEYOne winsInherent
Manual override conflicts with snapshotsource enumOverride row coexists; converter prefers manualInherent
Concurrent overridetx isolationLast-writer-winsInherent
Currency not in enumbilling_currency_enum check400Inherent
SBV publishes different rate retroactivelydoesn't happen per SBV policyN/AInherent
Tenant base currency changegrandfather past reportsInherentReports re-render with current base

§11 — Implementation notes


End of TASK-INV-002 spec.