diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 168fa3df4..cc69a81d3 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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`, ); } diff --git a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts index fa7587b2e..7cc699dbe 100644 --- a/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts +++ b/apps/edr-payment-api/src/modules/intents/dto/initiate-payment.dto.ts @@ -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 }) diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 53d4e70bd..2beed4dfa 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -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 { @@ -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 { - // 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 diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 3af351e32..1a6bd46ef 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -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(), diff --git a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts index d8d5e7cf1..9979ef1ab 100644 --- a/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts +++ b/packages/payment-providers/src/providers/ebirr/ebirr.provider.ts @@ -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. */