diff --git a/apps/edr-payment-api/src/modules/providers/cac-bank-amount.spec.ts b/apps/edr-payment-api/src/modules/providers/cac-bank-amount.spec.ts new file mode 100644 index 000000000..e1898de53 --- /dev/null +++ b/apps/edr-payment-api/src/modules/providers/cac-bank-amount.spec.ts @@ -0,0 +1,104 @@ +import { of } from "rxjs"; +import { CacBankProvider } from "@edr/payment-providers"; + +/** + * Both freight (invoice.balanceAmount) and passenger (CurrencyService) hand the + * provider a MAJOR-currency amount — e.g. 700 for $700, not 70000 cents — with the + * target currency's own decimal precision already applied. CAC previously divided + * anything that wasn't DJF by 100, undercharging every non-DJF payment 100x (a USD + * 700 invoice would have been billed as $7). These pin the fix: the amount reaches + * the bank unscaled, and the bank's own DJF bounds don't leak onto other currencies. + */ +describe("CacBankProvider — amount handling", () => { + const config = { + get: (key: string) => + ({ + "cac.baseUrl": "https://cac.example", + "cac.username": "u", + "cac.password": "p", + "cac.appKey": "app", + "cac.apiKey": "api", + "cac.companyServicesId": 21, + "cac.currency": "DJF", + "cac.tokenTtlMs": 3_600_000, + "cac.otpExpiryMs": 600_000, + "cac.httpTimeoutMs": 60_000, + })[key], + }; + + function build() { + const post = jest.fn(); + // 1st call: CacBankAuth signin (axios parses this one normally — no raw-text + // override). 2nd call: PaymentInitiateRequest (raw text; CAC provider parses it + // itself via parseCacResponse to preserve oversized ids). + post + .mockReturnValueOnce(of({ data: { accessToken: "tok" } })) + .mockReturnValueOnce( + of({ data: JSON.stringify({ description: "ok", paymentRequestId: 1 }) }), + ); + const provider = new CacBankProvider(config as never, { post } as never); + return { provider, post }; + } + + it("sends a USD amount unscaled — no more /100 division", async () => { + const { provider, post } = build(); + + await provider.initiate({ + merchantOrderId: "m-1", + orderRef: "INV-1", + amountMinor: 700, + currency: "USD", + payerAccount: "77092076", + }); + + const body = JSON.parse(post.mock.calls[1][1]); + expect(body.amount).toBe(700); + expect(body.currency).toBe("USD"); + }); + + it("still sends a DJF amount unscaled, matching pre-fix behaviour exactly", async () => { + const { provider, post } = build(); + + await provider.initiate({ + merchantOrderId: "m-2", + orderRef: "INV-2", + amountMinor: 222, + currency: "DJF", + payerAccount: "77092076", + }); + + const body = JSON.parse(post.mock.calls[1][1]); + expect(body.amount).toBe(222); + }); + + it("rejects a DJF amount outside the bank's documented 10–100,000 bounds", async () => { + const { provider } = build(); + + await expect( + provider.initiate({ + merchantOrderId: "m-3", + orderRef: "INV-3", + amountMinor: 5, + currency: "DJF", + payerAccount: "77092076", + }), + ).rejects.toThrow(/outside the accepted range/); + }); + + it("does not apply the DJF bounds to a USD amount outside that DJF range", async () => { + const { provider, post } = build(); + + // 500,000 is well past the DJF ceiling (100,000) but is a perfectly normal USD + // amount — the DJF-only bound must not reject it locally. + await provider.initiate({ + merchantOrderId: "m-4", + orderRef: "INV-4", + amountMinor: 500_000, + currency: "USD", + payerAccount: "77092076", + }); + + const body = JSON.parse(post.mock.calls[1][1]); + expect(body.amount).toBe(500_000); + }); +}); diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts index df69c4b1f..764d4a5d8 100644 --- a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts @@ -27,7 +27,9 @@ import type { CacPaymentInitiateResponse, } from "./cac-bank.types"; -/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). */ +/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). Not + * documented for other settlement currencies (e.g. USD) — skip the local pre-check there and + * let the bank's own validation reject an out-of-range amount. */ const CAC_MIN_AMOUNT = 10; const CAC_MAX_AMOUNT = 100_000; @@ -73,10 +75,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit { } const customerMobile = normalizeCacMobile(input.payerAccount); - const amount = this.toMajorAmount(input.amountMinor, input.currency); - if (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) { + const amount = this.toMajorAmount(input.amountMinor); + const currency = (input.currency || this.defaultCurrency).toUpperCase(); + if ( + currency === "DJF" && + (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) + ) { throw new Error( - `CAC Bank amount ${amount} ${input.currency} is outside the accepted range ` + + `CAC Bank amount ${amount} DJF is outside the accepted range ` + `(${CAC_MIN_AMOUNT}–${CAC_MAX_AMOUNT} DJF)`, ); } @@ -85,7 +91,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit { app_key: this.appKey, api_key: this.apiKey, customer_mobile: customerMobile, - currency: input.currency || this.defaultCurrency, + currency, desc: `${input.orderRef}`.slice(0, 500), vender_ref: input.merchantOrderId, amount, @@ -303,12 +309,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit { return this.auth; } - /** DJF has no fractional units — amountMinor is the major amount. */ - private toMajorAmount(amountMinor: number, currency: string): number { - if (currency.toUpperCase() === "DJF") { - return amountMinor; - } - return amountMinor / 100; + /** + * Both callers (freight's invoice.balanceAmount, passenger's CurrencyService) already + * hand off a major-currency amount — e.g. 700 for $700, not 70000 cents — with the target + * currency's own decimal precision already applied (0dp for DJF, 2dp for USD/ETB). CAC Bank + * bills in that same major unit, so it is forwarded unchanged. + */ + private toMajorAmount(amountMinor: number): number { + return amountMinor; } private sanitizeKeys(