/** * Money handling for PayMe. * * INTEGRATION RULE #1: PayMe amounts are ALWAYS integers in the currency's * minor unit — agorot for ILS, cents for USD/EUR. Never send a decimal amount * to the API. `sale_price: 5075` means 50.75. * * This is the most common source of a working-but-wrong integration. On * generate-sale PayMe does not reject a decimal `sale_price`: it rounds it to a * whole number before the range check. `50.75` becomes 51 and fails that check, * but `600.75` becomes 601 and charges 6.01. An amount sent in whole currency * units is also accepted when it is in range, and charges a hundredth of what * you meant: `600` charges 6.00, not 600.00. Keep money as integer minor units * everywhere in your own code and convert only at the display edge — that is * what this file exists to enforce. * * The default range for `sale_price` is 500 to 4000000 minor units, in every * currency; PayMe can set different limits per account. Outside it PayMe returns * error 352. Token sales are exempt, because PayMe sets their price itself. * * Reference: https://docs.payme.io/payments/hosted-payment-page/post-generate-sale */ /** PayMe's default minimum amount, in minor units (5.00). */ export const PAYME_MIN_AMOUNT_MINOR = 500; /** Common currencies. PayMe accepts any 3-letter ISO 4217 code your account is enabled for. */ export const SUPPORTED_CURRENCIES = ['ILS', 'USD', 'EUR'] as const; export type Currency = (typeof SUPPORTED_CURRENCIES)[number]; /** * Convert a human-entered decimal amount ("50.75") to PayMe minor units (5075). * * A minor-unit amount has at most two decimal places, so more are rejected * rather than rounded: float rounding of a third decimal is unreliable (1.005 * * 100 is 100.49999999999999). Within two decimals it rounds away float noise, * so 19.99 -> 1999, never 1998. */ export function toMinorUnits(amount: number | string): number { const text = typeof amount === 'string' ? amount.trim() : String(amount); // Plain non-negative decimals only. Number() alone would also accept // "1e3", "0x10" and "-5", and ports to other languages would differ. if (!/^\d+(\.\d+)?$/.test(text)) { throw new Error(`Cannot convert "${amount}" to minor units`); } if (/\.\d{3,}$/.test(text)) { throw new Error(`"${amount}" has more than two decimal places`); } const minor = Math.round(Number(text) * 100); if (!Number.isSafeInteger(minor)) { throw new Error(`"${amount}" is too large to represent exactly`); } return minor; } /** Convert PayMe minor units (5075) back to a decimal number (50.75) for display. */ export function fromMinorUnits(minor: number): number { return minor / 100; } /** Format minor units for humans: 5075, 'ILS' -> "₪50.75". */ export function formatMinorUnits(minor: number, currency: string): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency, }).format(fromMinorUnits(minor)); }