mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,13 +309,15 @@ 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") {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
return amountMinor / 100;
|
||||
}
|
||||
|
||||
private sanitizeKeys(
|
||||
body: CacPaymentInitiateRequest | CacPaymentConfirmRequest,
|
||||
|
||||
Reference in New Issue
Block a user