From 427665f5b22a9bad60c51ffa20054c94115a489e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 29 Aug 2026 08:12:21 +0000 Subject: [PATCH] feat(types): own the currency list in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/types/src/common/payments.ts | 73 +++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 430170548..9b6cd8af2 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -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 = { + 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.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; +}