/** * Currency / FX suite (matrix Suite C). Exercises CurrencyService directly. * C2 🔴 missing rate: getExchangeRate() silently returns 1.0 while getRateOrThrow() throws — * the display path degrades but the charge path errors on the SAME condition (divergence). * C3 🔴 a future-dated rate is applied immediately (no `effectiveDate <= now` filter). * C5 🔴 conversion routines disagree on units: displayMinorToChargeMajor / convertMinorToChargeMajor * return MAJOR units, convertEtbMinorToChargeMinor returns MINOR — a 100x unit landmine both * written into fields named `amountMinor` at their call sites. */ import { CurrencyService } from "../src/modules/currency/currency.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { resetAndSeedCore, USD_TO_ETB } from "./fixtures/seed-core"; describe("Pricing — CurrencyService (Suite C)", () => { let harness: ServiceHarness; let currency: CurrencyService; beforeAll(async () => { harness = await createServiceHarness(); currency = harness.moduleRef.get(CurrencyService); }); afterAll(async () => { await harness?.close(); }); beforeEach(async () => { await resetAndSeedCore(harness.prisma); }); it("C2 🔴 same DB state, 100x divergence: getExchangeRate → 1.0, getRateOrThrow → 100 (via inverse)", async () => { // Remove only the DIRECT USD→ETB row; the inverse ETB→USD (0.01) from the fixture stays. await harness.prisma.currencyExchangeRate.deleteMany({ where: { fromCurrency: "USD", toCurrency: "ETB" }, }); // Display/fare path (getExchangeRate) has NO inverse fallback → silently returns 1.0 (wrong). await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0); // Charge path (getRateOrThrow) DOES fall back to the inverse → 1 / 0.01 = 100 (correct). await expect( currency.getRateOrThrow("USD" as any, "ETB" as any), ).resolves.toBe(USD_TO_ETB); // → the display fare and the charged amount for the same trip differ by 100x. }); it("C2b 🔴 truly-missing pair: getExchangeRate → 1.0 (silent), getRateOrThrow → throws", async () => { await harness.prisma.currencyExchangeRate.deleteMany({ where: { OR: [ { fromCurrency: "USD", toCurrency: "ETB" }, { fromCurrency: "ETB", toCurrency: "USD" }, ], }, }); await expect(currency.getExchangeRate("USD" as any, "ETB" as any)).resolves.toBe(1.0); await expect( currency.getRateOrThrow("USD" as any, "ETB" as any), ).rejects.toThrow(/No exchange rate/i); }); it("C3 🔴 a future-dated rate is used right now (no effective-date gate)", async () => { const future = new Date(Date.now() + 365 * 24 * 3600 * 1000); await harness.prisma.currencyExchangeRate.create({ data: { fromCurrency: "ETB", toCurrency: "USD", rate: 999, effectiveDate: future }, }); // Correct behavior: ignore not-yet-effective rates. Actual: latest-by-date wins immediately. const rate = await currency.getExchangeRate("ETB" as any, "USD" as any); expect(rate).toBe(999); }); it("C5 🔴 conversion routines return different UNITS for the same money (100x apart)", async () => { // 100000 ETB minor = 1000.00 ETB. With ETB→USD = 1/100: const asMajor = await currency.convertMinorToChargeMajor(100000, "ETB", "USD"); // → 10.00 (major) const asMinor = await currency.convertEtbMinorToChargeMinor(100000, "USD"); // → 1000 (minor) expect(asMajor).toBeCloseTo(1000 / USD_TO_ETB, 2); // 10.00 expect(asMinor).toBe(Math.round((100000 * 1) / USD_TO_ETB)); // 1000 // Same amount, but the two results differ by 100x — and both feed fields named `amountMinor`. expect(asMinor).toBe(asMajor * 100); }); });