feat(payments): check every gateway against the currency it settles

The only currency rule anywhere was CBE_BILL's, spelled out twice — once
inside initiateCbeBill and again in the freight API before it calls the
payment service. Two copies of one provider's rule, and no check at all
for the other seven. initiate-payment.dto accepted any 3-to-8 character
string as a currency.

Both copies now read PROVIDER_CURRENCIES, and the check sits once in
initiate() before any provider opens a session, so it covers all of them.
The DTO gets the whitelist it never had.

Amount precision follows the same rule. eBirr and D-Money formatted at a
fixed 2dp regardless of currency; D-Money would have sent "71088.00" for
a DJF order, and DJF has no centimes to send. Both now format at the
currency's own precision. CAC Bank already did this and documents it.

Waafi is deliberately left alone: its toAmount() truncates rather than
rounds, the comment says that is intentional, and changing it would alter
an existing money path for a reason unrelated to DJF. Same for the
amountMinor major/minor unit disagreement in cbe-birr and card — real,
pre-existing, and unreachable from DJF since both are ETB rails.
This commit is contained in:
Nathnael
2026-08-29 08:13:31 +00:00
parent 8299181261
commit a119b09322
5 changed files with 46 additions and 19 deletions

View File

@@ -32,6 +32,7 @@ import {
PaymentPlatformDto,
} from "./payments.dto";
import { isBypassEnv } from "../../common/dev-bypass.util";
import { providerSupportsCurrency } from "@edr/types";
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
* the caller (billing) — this service never derives them from a domain record. */
@@ -249,11 +250,13 @@ export class PaymentService {
{ path: "payment.initiate" },
);
try {
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
// CBE settles ETB only (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8).
if (isCbeBill && input.currency?.toUpperCase() !== "ETB") {
// Which gateway settles which currency, from the shared table — the same one the
// payment service enforces, so the two cannot drift. It previously spelled out the
// CBE/ETB rule here and checked nothing else, which is how a DJF invoice could be
// handed to an Ethiopian-only rail.
if (!providerSupportsCurrency(input.method as ProviderMethod, input.currency)) {
throw new BadRequestException(
"CBE bill payment is only available for ETB invoices",
`${input.method} cannot settle ${input.currency ?? "an unspecified currency"} invoices`,
);
}

View File

@@ -17,6 +17,7 @@ import {
PaymentService,
ProviderMethod,
} from "@edr/types";
import { PAYMENT_CURRENCIES } from "@edr/types";
/** Wire shape is the shared `InitiatePaymentRequest` contract from @edr/types. */
export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
@@ -53,9 +54,9 @@ export class InitiatePaymentRequestDto implements InitiatePaymentRequest {
@IsPositive()
amountMinor!: number;
@ApiProperty({ example: "ETB" })
@ApiProperty({ example: "ETB", enum: PAYMENT_CURRENCIES })
@IsString()
@Length(3, 8)
@IsIn([...PAYMENT_CURRENCIES])
currency!: string;
@ApiProperty({ enum: ProviderMethod })

View File

@@ -1,3 +1,4 @@
import { providerSupportsCurrency } from "@edr/types";
import {
BadRequestException,
Inject,
@@ -92,6 +93,21 @@ export class IntentsService {
/* ------------------------------------------------------------------ initiate */
/**
* Rejects a currency the chosen gateway cannot settle, from the shared table so the
* freight API and this service cannot disagree about it.
*/
private assertProviderCurrency(
provider: ProviderMethod,
currency: string | null | undefined,
): void {
if (!providerSupportsCurrency(provider, currency)) {
throw new BadRequestException(
`${provider} cannot settle ${currency ?? "an unspecified currency"}`,
);
}
}
async initiate(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
@@ -103,6 +119,11 @@ export class IntentsService {
if (byKey) return this.toSnapshot(byKey);
}
// One gate for every provider, before any of them opens a session. This used to be a
// lone `currency !== "ETB"` inside the CBE_BILL branch, duplicated again in the freight
// API — so no other provider was checked at all, and the DTO whitelists nothing.
this.assertProviderCurrency(request.provider, request.currency);
// CBE_BILL is inbound-only: there is no provider session to open and deliberately no entry
// in PAYMENT_PROVIDER_MAP (plan D5 — the sweep and refreshIfStale must no-op on it).
if (request.provider === ProviderMethod.CBE_BILL) {
@@ -214,12 +235,8 @@ export class IntentsService {
private async initiateCbeBill(
request: InitiatePaymentRequest,
): Promise<PaymentIntentSnapshot> {
// D8: CBE settles ETB only. The domain app must price/charge the order in ETB.
if (request.currency !== "ETB") {
throw new BadRequestException(
`CBE_BILL supports ETB only (got ${request.currency})`,
);
}
// D8: CBE settles ETB only — enforced for every provider by assertProviderCurrency()
// in initiate(), from the shared PROVIDER_CURRENCIES table.
// The bill reference is issued ONCE per order: the domain app persists it (freight stores it
// as the booking's PNR) and the payer may already have written it down, so re-initiating the

View File

@@ -24,6 +24,7 @@ import {
DMoneyPreOrderResponse,
DMoneyQueryOrderResponse,
} from "./dmoney.types";
import { CURRENCY_DECIMALS, PaymentCurrency, roundMoney } from "@edr/types";
const DMONEY_HTTP_TIMEOUT_MS = 10_000;
@@ -210,7 +211,10 @@ export class DMoneyProvider implements PaymentProvider {
private buildPreOrderRequest(
input: ProviderInitiationInput,
): DMoneyPreOrderRequest {
const totalAmount = (input.amountMinor).toFixed(2);
// At the currency's own precision: a fixed 2dp sends "71088.00" for a DJF order, and
// DJF has no centimes to send.
const decimals = CURRENCY_DECIMALS[input.currency as PaymentCurrency] ?? 2;
const totalAmount = roundMoney(input.amountMinor, input.currency).toFixed(decimals);
const redirectUrl = input.redirectUrl ?? this.returnUrl;
const req = {
timestamp: createTimestamp(),

View File

@@ -20,6 +20,7 @@ import {
EbirrPurchaseRequest,
EbirrPurchaseResponse,
} from "./ebirr.types";
import { roundMoney } from "@edr/types";
/** EbirrPay's "request processed" envelope code. Says nothing about the payment outcome. */
const EBIRR_SUCCESS_CODE = "2001";
@@ -144,7 +145,7 @@ export class EBirrProvider implements PaymentProvider, OnModuleInit {
this.logger.log(
`eBirr initiate ref=${input.merchantOrderId} account=${maskMsisdn(accountNo)} ` +
`currency=${input.currency} amount=${this.toAmount(input.amountMinor)} ` +
`currency=${input.currency} amount=${this.toAmount(input.amountMinor, input.currency)} ` +
`(amountMinorIn=${input.amountMinor})`,
);
@@ -408,7 +409,7 @@ export class EBirrProvider implements PaymentProvider, OnModuleInit {
transactionInfo: {
referenceId: input.merchantOrderId,
invoiceId: input.orderRef,
amount: this.toAmount(input.amountMinor),
amount: this.toAmount(input.amountMinor, input.currency),
// Charge exactly the currency the caller already converted to. The provider never
// relabels the currency.
currency: input.currency,
@@ -439,11 +440,12 @@ export class EBirrProvider implements PaymentProvider, OnModuleInit {
/**
* `amountMinor` is a misnomer inherited from the shared contract — it carries the *major*
* amount (see PaymentIntent.amountMinor: "real/major price; may be fractional"). Pass it
* through at 2dp. Never divide by 100 (the pre-rewrite code did, charging 1/100th), and don't
* borrow Waafi's `Math.trunc` — that is only correct for 0-decimal DJF and would drop ETB cents.
* through at the currency's own precision. Never divide by 100 (the pre-rewrite code did,
* charging 1/100th), and never truncate at a fixed 2dp — 0-decimal DJF and 2-decimal ETB
* need different treatment, which is exactly what CURRENCY_DECIMALS encodes.
*/
private toAmount(amountMinor: number): number {
return Number(amountMinor.toFixed(2));
private toAmount(amountMinor: number, currency?: string): number {
return roundMoney(amountMinor, currency ?? "ETB");
}
/** eBirr expects `YYYY-MM-DD HH:mm:ss` — not the epoch seconds Waafi's `/asm` accepts. */