// Exact-decimal money helpers of the `money` field and list column (Phase 18 // D-07, amended D-09, D-10). A money value is the decimal string the server // stores ("39.8400", "85.4193"); it is never turned into a JS number here: // parsing is text work on digits and separators, and formatting hands the // string itself to Intl.NumberFormat, which accepts decimal strings without // going through a float. export type MoneyParseError = 'invalid' | 'too_precise' export interface MoneyParseResult { /** The canonical dot-separated string, or null for empty text. */ value: string | null /** Why the text was refused, or null when value holds the result. */ error: MoneyParseError | null } const digitsOnly = /^[0-9]+$/ /** * Turns typed text into the canonical money string: spaces and U+00A0 are * stripped, one comma or dot is the decimal separator, an optional leading * minus is kept, digits only otherwise. Empty text is null. More than * `scale` significant fraction digits is `too_precise`: the value is never * rounded or truncated, and trailing zeros beyond the scale are kept and * accepted (they carry no precision), as the server does. Anything else * (letters, two separators, grouping) is `invalid`. */ export function parseMoneyInput(text: string, scale: number): MoneyParseResult { const compact = text.replace(/[\sĀ ]/g, '') if (compact === '') { return { value: null, error: null } } let body = compact let negative = false if (body.startsWith('-')) { negative = true body = body.slice(1) } const separators = body.match(/[.,]/g) ?? [] if (separators.length > 1) { return { value: null, error: 'invalid' } } const [whole, fraction = ''] = body.split(/[.,]/) if (!digitsOnly.test(whole ?? '') || (separators.length === 1 && !digitsOnly.test(fraction))) { return { value: null, error: 'invalid' } } if (fraction.replace(/0+$/, '').length > scale) { return { value: null, error: 'too_precise' } } const canonical = fraction === '' ? whole : `${whole}.${fraction}` return { value: `${negative ? '-' : ''}${canonical}`, error: null } } /** * The stored string for display in the input: trailing fraction zeros are * trimmed down to two decimals ("39.8400" → "39.84", "85.4193" unchanged, * "5.0000" → "5.00", "7" → "7"). The separator stays a dot; the field swaps * it for the locale's own. */ export function trimMoneyDisplay(stored: string): string { const [whole, fraction] = stored.split('.') if (fraction === undefined || fraction.length <= 2) { return stored } let end = fraction.length while (end > 2 && fraction.charAt(end - 1) === '0') { end -= 1 } return `${whole}.${fraction.slice(0, end)}` } /** The decimal separator Intl uses for a locale ("," for pl, "." for en). */ export function decimalSeparator(locale: string): string { const parts = new Intl.NumberFormat(locale).formatToParts(1.1) return parts.find((part) => part.type === 'decimal')?.value ?? '.' } /** * Formats a money string for reading: Intl.NumberFormat with the ISO code * (`currencyDisplay: 'code'`), at least two and at most `scale` fraction * digits, so "99999999.9999" keeps all four decimals and "39.8400" shows two. * Without a currency the amount alone is formatted (`style: 'decimal'`). The * string is passed to format() as is; it is never converted to a number. */ export function formatMoney(value: string, currency: string, locale: string, scale = 4): string { const digits = { minimumFractionDigits: 2, maximumFractionDigits: Math.max(2, scale) } const options: Intl.NumberFormatOptions = currency === '' ? { style: 'decimal', ...digits } : { style: 'currency', currency, currencyDisplay: 'code', ...digits } let formatter: Intl.NumberFormat try { formatter = new Intl.NumberFormat(locale, options) } catch { // An unknown ISO code: show the amount with the code appended. const plain = new Intl.NumberFormat(locale, { style: 'decimal', ...digits }) return `${plain.format(value as unknown as number)} ${currency}`.trim() } // Intl accepts decimal strings (ECMA-402 "intl mathematical value"); the // cast only satisfies the lib typings that predate it. return formatter.format(value as unknown as number) }