fix(payment-providers): stop halving every non-DJF CAC Bank amount

Both callers already hand the provider a major-currency amount before this
call — freight sends invoice.balanceAmount (e.g. 700 for $700), passenger
sends CurrencyService's converted charge amount — with the target currency's
own decimal precision already applied (0dp DJF, 2dp USD/ETB). CAC's
toMajorAmount then divided anything that wasn't DJF by 100 on top of that,
so a USD 700 charge reached the bank as 7.00. This never surfaced because
CAC has only ever been wired for DJF (passenger) until now.

Also scoped the bank's documented 10-100,000 bound to DJF — it's the only
currency the spec states bounds for, so a USD amount outside that DJF-shaped
range is no longer rejected locally; an amount genuinely out of range still
comes back as a bank rejection.

Passenger is unaffected: its CAC currency has always been DJF, and the DJF
branch's behavior (amount unchanged, bounds enforced) is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-07-31 10:33:46 +00:00
parent 991881108d
commit a975aa7547
2 changed files with 123 additions and 11 deletions

View File

@@ -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 10100,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);
});
});