From ae3dd825ba307f0298fb4dd6a692b2063145c344 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 29 Aug 2026 08:59:06 +0000 Subject: [PATCH] fix(payments): the Ethiopian rails settle birr only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROVIDER_CURRENCIES was permissive for everything except CBE_BILL, on the reasoning that the adapters pass input.currency through verbatim and nothing in them refuses a currency. That was the wrong place to look. The configuration says otherwise, and so does the domain: Telebirr is Ethio Telecom, CBE Birr is a Commercial Bank of Ethiopia wallet, and neither settles Djiboutian francs. The table as written would have let a DJF freight invoice be routed to one of them. payments.service.ts already states the rule above its chargeCurrency resolution — "WAAFI/DMONEY settle in DJF, CARD in USD, Ethiopian wallets in ETB" — and fix-payment-method-currency.ts corrected exactly WAAFI to DJF and CARD to USD, deliberately leaving the Ethiopian methods on the schema's ETB default. eBirr is configured as one of those: seeded region ETHIOPIA, never corrected. So TELEBIRR, CBE_BIRR, CBE_BILL and EBIRR are ETB-only. WAAFI, DMONEY and CAC_BANK stay open rather than pinned to DJF — that is their configured settlement currency, not a refusal of anything else, and passenger converts to each method's own currency before initiating regardless. CARD stays open too; its /100 minor-unit handling is a separate problem. Adds the spec that was missing: DJF must not reach a birr rail. --- .../providers/provider-currencies.spec.ts | 49 +++++++++++++++++++ packages/types/src/common/payments.ts | 44 +++++++++-------- 2 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 apps/edr-payment-api/src/modules/providers/provider-currencies.spec.ts diff --git a/apps/edr-payment-api/src/modules/providers/provider-currencies.spec.ts b/apps/edr-payment-api/src/modules/providers/provider-currencies.spec.ts new file mode 100644 index 000000000..3857229aa --- /dev/null +++ b/apps/edr-payment-api/src/modules/providers/provider-currencies.spec.ts @@ -0,0 +1,49 @@ +import { + PROVIDER_CURRENCIES, + ProviderMethod, + providerSupportsCurrency, +} from "@edr/types"; + +/** + * Which gateway may be handed which currency. This started out permissive for every + * provider but CBE_BILL, which would have let a DJF freight invoice be routed to Telebirr + * or CBE Birr — Ethiopian birr rails that cannot settle francs. + */ +describe("provider settlement currencies", () => { + const ETB_ONLY = [ + ProviderMethod.TELEBIRR, + ProviderMethod.CBE_BIRR, + ProviderMethod.CBE_BILL, + ProviderMethod.EBIRR, + ]; + + it.each(ETB_ONLY)("refuses DJF on %s", (provider) => { + expect(providerSupportsCurrency(provider, "DJF")).toBe(false); + expect(providerSupportsCurrency(provider, "USD")).toBe(false); + expect(providerSupportsCurrency(provider, "ETB")).toBe(true); + }); + + it.each([ProviderMethod.WAAFI, ProviderMethod.DMONEY, ProviderMethod.CAC_BANK])( + "settles DJF on %s", + (provider) => { + expect(providerSupportsCurrency(provider, "DJF")).toBe(true); + }, + ); + + it("matches case-insensitively — currency columns are free strings", () => { + expect(providerSupportsCurrency(ProviderMethod.CAC_BANK, "djf")).toBe(true); + expect(providerSupportsCurrency(ProviderMethod.TELEBIRR, "djf")).toBe(false); + }); + + it("refuses a currency the platform does not bill in at all", () => { + expect(providerSupportsCurrency(ProviderMethod.CAC_BANK, "EUR")).toBe(false); + expect(providerSupportsCurrency(ProviderMethod.CAC_BANK, null)).toBe(false); + expect(providerSupportsCurrency(ProviderMethod.CAC_BANK, undefined)).toBe(false); + }); + + it("covers every provider, so a new one cannot default to unrestricted", () => { + for (const provider of Object.values(ProviderMethod)) { + expect(PROVIDER_CURRENCIES[provider]?.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 9b6cd8af2..96f04ee2a 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -32,7 +32,6 @@ export enum ProviderMethod { CBE_BILL = "CBE_BILL", } - export type PaymentPlatform = "web" | "mobile" | "inapp"; export type ClientAction = @@ -141,7 +140,6 @@ export enum PaymentReferenceType { SUPPLEMENTARY_CHARGE = "SUPPLEMENTARY_CHARGE", } - /** Body of `POST /payments/initiate` on the payment service (internal, service-authenticated). */ export interface InitiatePaymentRequest { service: PaymentService; @@ -186,7 +184,7 @@ export interface ConfirmPaymentRequest { } /** Response of `POST /payments/initiate` and shape of intent lookups. */ -export type PaymentIntentSnapshot ={ +export type PaymentIntentSnapshot = { intentId: string; service: PaymentService; referenceType: PaymentReferenceType; @@ -211,7 +209,7 @@ export type PaymentIntentSnapshot ={ * for state decisions — the state machine drives `status`. */ providerResponse?: Record; -} +}; export type PaymentEventType = "payment.succeeded" | "payment.failed"; @@ -261,10 +259,7 @@ export type PaymentCurrency = (typeof PAYMENT_CURRENCIES)[number]; /** Narrowing guard for the free-string `currency` columns the ORMs hand back. */ export function isPaymentCurrency(value: unknown): value is PaymentCurrency { - return ( - typeof value === "string" && - (PAYMENT_CURRENCIES as readonly string[]).includes(value) - ); + return typeof value === "string" && (PAYMENT_CURRENCIES as readonly string[]).includes(value); } /** @@ -280,25 +275,34 @@ export const CURRENCY_DECIMALS: Record = { /** * Which currencies each gateway will settle. * - * Only CBE_BILL carries a restriction we can actually evidence: CBE settles ETB and the - * adapter says so (docs/cbe/CBE_IMPLEMENTATION_PLAN.md D8). Every other adapter passes - * `input.currency` to the provider verbatim — eBirr, Waafi and D-Money all carry an explicit - * "charge exactly the currency the caller already converted to" comment — so no other row can - * be tightened without inventing a rule the code does not have. + * The Ethiopian rails settle birr and nothing else — Telebirr is Ethio Telecom, CBE Birr + * and CBE Bill are Commercial Bank of Ethiopia, and eBirr is configured as an Ethiopian + * method too (seeded `region: ETHIOPIA`, and `fix-payment-method-currency.ts` deliberately + * left it on the schema's ETB default while correcting Waafi). Handing any of them a DJF + * order is a routing mistake, not a conversion the provider will make for us. * - * ponytail: permissive on purpose. Settlement currency is really a property of the merchant - * account, not the provider brand; tighten a row here once ops confirms what its merchant - * account actually accepts, rather than guessing now and breaking a live flow. + * The Djibouti-side rails are the ones that settle francs: Waafi and D-Money are configured + * to DJF, and CAC Bank both defaults to it and enforces DJF's 10–100,000 bounds in its own + * adapter. They stay open to the other currencies rather than pinned to DJF — nothing in the + * code says they refuse them, and passenger already converts to each method's own settlement + * currency before it initiates, so it never sends them anything else anyway. + * + * CARD is left unrestricted: card gateways are normally multi-currency, and its `/100` + * minor-unit handling is a separate pre-existing problem from currency routing. + * + * ponytail: settlement currency is really a property of the merchant account. Tighten a row + * when ops confirms one, and leave it open rather than guessing — a guess here either + * refuses a live payment or routes one to a rail that cannot take it. */ export const PROVIDER_CURRENCIES: Record = { [ProviderMethod.CBE_BILL]: ["ETB"], - [ProviderMethod.CBE_BIRR]: PAYMENT_CURRENCIES, - [ProviderMethod.TELEBIRR]: PAYMENT_CURRENCIES, - [ProviderMethod.CARD]: PAYMENT_CURRENCIES, - [ProviderMethod.EBIRR]: PAYMENT_CURRENCIES, + [ProviderMethod.CBE_BIRR]: ["ETB"], + [ProviderMethod.TELEBIRR]: ["ETB"], + [ProviderMethod.EBIRR]: ["ETB"], [ProviderMethod.WAAFI]: PAYMENT_CURRENCIES, [ProviderMethod.DMONEY]: PAYMENT_CURRENCIES, [ProviderMethod.CAC_BANK]: PAYMENT_CURRENCIES, + [ProviderMethod.CARD]: PAYMENT_CURRENCIES, }; /** Whether `provider` can settle `currency`. Unknown currencies are never settleable. */