Files
summercms/admin/src/app/money.ts
Jakub Zych eb60c329d8 fix(18-07): money accepts trailing zeros beyond the field scale
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
2026-10-13 16:53:58 +02:00

101 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}