feat(18-01): add the cabana money field (task 1)
- type: money on fields.yaml with currency or currencyFrom, scale (2..4, default 4) and max; boot fails naming the field on two sources, none, a bad scale or max, a column that is not a string or a Scanner/Valuer, or a currencyFrom that is not a string column - a save answers 422 money.invalid, money.too_precise or money.too_large on the field and fills the accepted decimal string unchanged; the same check runs on controller, settings and relation child saves - backend::lang money.* phrases in en and pl - admin SPA: money.ts (parseMoneyInput, trimMoneyDisplay, formatMoney over Intl with the string itself), MoneyField.vue with the currency suffix, blur validation and the read-only box, preview through the control, the field scope on PreviewView; regenerated OpenAPI and TS types, rebuilt modules/boardwalk/dist - acme.payments fixture with TestMoneyFieldCompile and the Docker TestMoneyFieldSmoke; forms.md Money section and cabana README
This commit is contained in:
98
admin/src/app/money.ts
Normal file
98
admin/src/app/money.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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` fraction digits is `too_precise`: the value is never rounded or
|
||||
* truncated. 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.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)
|
||||
}
|
||||
Reference in New Issue
Block a user