fix(payments): the Ethiopian rails settle birr only

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.
This commit is contained in:
Nathnael
2026-08-29 08:59:06 +00:00
parent f46324b779
commit ae3dd825ba
2 changed files with 73 additions and 20 deletions

View File

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

View File

@@ -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<string, unknown>;
}
};
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<PaymentCurrency, number> = {
/**
* 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 10100,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, readonly PaymentCurrency[]> = {
[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. */