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:
Jakub Zych
2026-10-13 11:15:05 +02:00
parent 99ccae232c
commit 20385e53ee
33 changed files with 1388 additions and 26 deletions

98
admin/src/app/money.ts Normal file
View 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)
}