/** * Money is always integer minor units (cents). Never floats — 0.1 + 0.2 problems * become customer support tickets when they happen to someone's payout. */ export type Cents = number; export class MoneyError extends Error {} export function assertCents(value: number, label = 'amount'): asserts value is Cents { if (!Number.isInteger(value)) throw new MoneyError(`${label} must be an integer, got ${value}`); if (value < 0) throw new MoneyError(`${label} must not be negative, got ${value}`); if (!Number.isSafeInteger(value)) throw new MoneyError(`${label} exceeds safe integer range`); } /** Basis points: 1500 bps = 15%. */ export type Bps = number; /** * Platform commission. Rounds half-up so the platform never takes a fraction of a * cent more than stated, and the pro's share absorbs the remainder. */ export function platformFee(amount: Cents, feeBps: Bps): Cents { assertCents(amount); if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 10_000) { throw new MoneyError(`feeBps must be an integer in [0, 10000], got ${feeBps}`); } return Math.round((amount * feeBps) / 10_000); } /** What actually lands in the pro's connected account. */ export function proPayout(amount: Cents, feeBps: Bps): Cents { return amount - platformFee(amount, feeBps); } /** Split a charge into its parts. The two always sum back to `amount`. */ export function splitCharge(amount: Cents, feeBps: Bps): { fee: Cents; payout: Cents } { const fee = platformFee(amount, feeBps); return { fee, payout: amount - fee }; } export function formatCents(amount: Cents, currency = 'EUR', locale = 'en-IE'): string { return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100); } export function parseAmountToCents(input: string): Cents { const normalised = input.replace(/[^0-9.,-]/g, '').replace(',', '.'); const parsed = Number.parseFloat(normalised); if (Number.isNaN(parsed)) throw new MoneyError(`Cannot parse "${input}" as an amount`); return Math.round(parsed * 100); }