// Generic finance utilities for formatting and arithmetic export type Currency = string; // ISO 4217, e.g. 'EUR', 'USD' export interface FormatOptions { locale?: string; // defaults to 'en-US' currency?: Currency; // defaults to 'EUR' minimumFractionDigits?: number; maximumFractionDigits?: number; } const DEFAULT_LOCALE = 'en-US'; const DEFAULT_CURRENCY: Currency = 'EUR'; export const formatNumber = (value: number, locale = DEFAULT_LOCALE) => { return new Intl.NumberFormat(locale).format(value); }; export const formatCurrency = (value: number | bigint, opts?: Partial) => { const locale = opts?.locale ?? DEFAULT_LOCALE; const currency = opts?.currency ?? DEFAULT_CURRENCY; const minimumFractionDigits = opts?.minimumFractionDigits ?? 0; const maximumFractionDigits = opts?.maximumFractionDigits ?? 0; // If passed a bigint, treat it as cents and convert to major units if maximumFractionDigits>0 let numericValue: number; if (typeof value === 'bigint') { numericValue = Number(value) / Math.pow(10, maximumFractionDigits); } else { numericValue = value; } return new Intl.NumberFormat(locale, { style: 'currency', currency, minimumFractionDigits, maximumFractionDigits, }).format(numericValue); }; // A small helper to avoid floating point rounding issues by using integer cents export class Money { // internal representation: cents for a 2-decimal currency by default private cents: bigint; private readonly scale: number; // number of fraction digits (e.g. 2 for cents) constructor(amount: number | bigint, scale = 2) { this.scale = scale; if (typeof amount === 'bigint') { this.cents = amount; } else { // convert float to scaled integer const multiplier = Math.pow(10, this.scale); this.cents = BigInt(Math.round(amount * multiplier)); } } static fromCents(cents: bigint, scale = 2) { const m = new Money(0, scale); m.cents = cents; return m; } add(other: Money) { this._assertSameScale(other); return Money.fromCents(this.cents + other.cents, this.scale); } sub(other: Money) { this._assertSameScale(other); return Money.fromCents(this.cents - other.cents, this.scale); } mul(factor: number) { const result = Number(this.cents) * factor; return Money.fromCents(BigInt(Math.round(result)), this.scale); } div(divisor: number) { const result = Number(this.cents) / divisor; return Money.fromCents(BigInt(Math.round(result)), this.scale); } toNumber() { return Number(this.cents) / Math.pow(10, this.scale); } toCents(): bigint { return this.cents; } format(opts?: Partial) { const maximumFractionDigits = opts?.maximumFractionDigits ?? this.scale; return formatCurrency(this.cents, { locale: opts?.locale, currency: opts?.currency, minimumFractionDigits: opts?.minimumFractionDigits ?? this.scale, maximumFractionDigits, }); } private _assertSameScale(other: Money) { if (other.scale !== this.scale) { throw new Error('Money scale mismatch'); } } } export default { formatCurrency, formatNumber, Money, };