A scale-2 money field over a four-decimal column (the documented fee example) was served "12.5000" and answered money.too_precise when the untouched value was saved back. Trailing zeros carry no precision, so the server check and the SPA parser count only significant fraction digits; the value still reaches the column unchanged, never rounded or truncated. - cabana validateMoneyValue and admin parseMoneyInput trim trailing zeros before the scale comparison - forms docs and cabana README name the rule; embedded dist rebuilt - MoneyField spec asserts 12.5000 at scale 2 is accepted, 12.5010 refused
101 lines
4.2 KiB
TypeScript
101 lines
4.2 KiB
TypeScript
// 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)
|
||
}
|