diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts new file mode 100644 index 000000000..aabf4f416 --- /dev/null +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.amount.spec.ts @@ -0,0 +1,22 @@ +import { amountsMatchToTheCent } from "./cbe-bill.service"; + +describe("amountsMatchToTheCent (CBE payment amount gate)", () => { + it("accepts the exact amount", () => { + expect(amountsMatchToTheCent(1234.34, 1234.34)).toBe(true); + }); + + it("rejects a cents-only difference (the 1234.89 vs 1234.34 bug)", () => { + expect(amountsMatchToTheCent(1234.89, 1234.34)).toBe(false); + expect(amountsMatchToTheCent(1234.35, 1234.34)).toBe(false); + }); + + it("rejects whole-unit differences", () => { + expect(amountsMatchToTheCent(1235.34, 1234.34)).toBe(false); + }); + + it("absorbs double-precision storage noise", () => { + expect(amountsMatchToTheCent(1234.34, 1234.3399999999999)).toBe(true); + // classic float artifact: 0.1 + 0.2 !== 0.3 + expect(amountsMatchToTheCent(0.1 + 0.2, 0.3)).toBe(true); + }); +}); diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index 1c6926b8a..580e6283d 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -38,8 +38,15 @@ import { /** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */ const PG_UNIQUE_VIOLATION = "23505"; -/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */ -const AMOUNT_TOLERANCE = 0.01; +/** + * CBE must pay the bill to the exact cent — compare in integer cents so + * double-precision storage noise (1234.34 stored as 1234.33999…) can neither + * mask nor fabricate a difference. A relative tolerance is wrong here: 1% of a + * 1234.34 bill would wave through anything up to ±12.34. + */ +export function amountsMatchToTheCent(a: number, b: number): boolean { + return Math.round(a * 100) === Math.round(b * 100); +} /** * Translate an intent's own terminal state into the same reason vocabulary the domain apps @@ -268,8 +275,7 @@ export class CbeBillService { const amount = Number(dto.Amount); if ( !Number.isFinite(amount) || - Math.abs(amount - intent.amountMinor) > - intent.amountMinor * AMOUNT_TOLERANCE + !amountsMatchToTheCent(amount, intent.amountMinor) ) { throw new CbeBillError("Amount mismatch", "BUSINESS"); }