feat(types): own the currency list in one place

PAYMENT_CURRENCIES was copy-pasted into six files — four freight DTOs and
two portal zod schemas — with no owner, and @edr/api-common's exchange
service kept a seventh copy of its own as a bare "USD" | "ETB" union. They
had already drifted: the DTOs and the exchange service did not agree on
what the platform could price in, so a form could offer a currency the
rate service had never heard of.

Adds PAYMENT_CURRENCIES (now including DJF), the PaymentCurrency type and
an isPaymentCurrency guard for the free-string currency columns the ORMs
hand back. Every existing list re-exports this one instead of becoming a
seventh copy.

Also carries the two rules that have to travel with the list:

CURRENCY_DECIMALS, because DJF is a zero-decimal currency — an amount with
centimes is not a more precise payment, it is a malformed one, and CAC
Bank rejects it. roundMoney() applies it.

PROVIDER_CURRENCIES, which gateway settles which currency. Only CBE_BILL
carries a restriction the code can evidence (CBE settles ETB, plan D8);
eBirr, Waafi and D-Money each pass input.currency to the provider verbatim
with an explicit comment saying so. The other rows are therefore
permissive on purpose rather than guessed — settlement currency is really
a property of the merchant account, not the provider brand, and tightening
a row on a hunch would break a live flow.
This commit is contained in:
Nathnael
2026-08-29 08:12:21 +00:00
parent 3a298b5fce
commit 427665f5b2

View File

@@ -245,3 +245,76 @@ export interface PaymentFailedEvent extends PaymentEventBase {
}
export type PaymentEvent = PaymentSucceededEvent | PaymentFailedEvent;
/* ------------------------------------------------------------------------------------------------
* Currency
*
* The single owner of "which currencies this platform bills in". Before this existed the list
* was copy-pasted into six files (four freight DTOs and two portal zod schemas) with no owner,
* and `@edr/api-common`'s exchange service kept a seventh copy of its own.
* ---------------------------------------------------------------------------------------------- */
/** Currencies the platform can price, invoice and settle in. */
export const PAYMENT_CURRENCIES = ["ETB", "USD", "DJF"] as const;
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)
);
}
/**
* Decimal places each currency is quoted in. **DJF is a zero-decimal currency** — an amount
* with cents is not a smaller payment, it is a malformed one, and CAC Bank rejects it.
*/
export const CURRENCY_DECIMALS: Record<PaymentCurrency, number> = {
ETB: 2,
USD: 2,
DJF: 0,
};
/**
* 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.
*
* 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.
*/
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.WAAFI]: PAYMENT_CURRENCIES,
[ProviderMethod.DMONEY]: PAYMENT_CURRENCIES,
[ProviderMethod.CAC_BANK]: PAYMENT_CURRENCIES,
};
/** Whether `provider` can settle `currency`. Unknown currencies are never settleable. */
export function providerSupportsCurrency(
provider: ProviderMethod,
currency: string | null | undefined,
): boolean {
const code = currency?.toUpperCase();
if (!isPaymentCurrency(code)) return false;
return PROVIDER_CURRENCIES[provider].includes(code);
}
/** Rounds a money amount to its currency's precision — 0dp for DJF, 2dp for ETB/USD. */
export function roundMoney(amount: number, currency: string | null | undefined): number {
const code = currency?.toUpperCase();
const decimals = isPaymentCurrency(code) ? CURRENCY_DECIMALS[code] : 2;
const factor = 10 ** decimals;
return Math.round((amount + Number.EPSILON) * factor) / factor;
}