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
This commit is contained in:
2026-10-13 16:53:58 +02:00
parent 5141059026
commit eb60c329d8
8 changed files with 20 additions and 12 deletions

View File

@@ -20,8 +20,10 @@ 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`.
* `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, '')
@@ -42,7 +44,7 @@ export function parseMoneyInput(text: string, scale: number): MoneyParseResult {
if (!digitsOnly.test(whole ?? '') || (separators.length === 1 && !digitsOnly.test(fraction))) {
return { value: null, error: 'invalid' }
}
if (fraction.length > scale) {
if (fraction.replace(/0+$/, '').length > scale) {
return { value: null, error: 'too_precise' }
}
const canonical = fraction === '' ? whole : `${whole}.${fraction}`

View File

@@ -65,6 +65,9 @@ describe('money helpers', () => {
expect(parseMoneyInput('', 4)).toEqual({ value: null, error: null })
expect(parseMoneyInput(' ', 4)).toEqual({ value: null, error: null })
expect(parseMoneyInput('12.34567', 4)).toEqual({ value: null, error: 'too_precise' })
// Trailing zeros beyond the scale carry no precision: kept, accepted.
expect(parseMoneyInput('12.5000', 2)).toEqual({ value: '12.5000', error: null })
expect(parseMoneyInput('12.5010', 2)).toEqual({ value: null, error: 'too_precise' })
expect(parseMoneyInput('1.234,56', 4)).toEqual({ value: null, error: 'invalid' })
expect(parseMoneyInput('12a', 4)).toEqual({ value: null, error: 'invalid' })
expect(parseMoneyInput('1e3', 4)).toEqual({ value: null, error: 'invalid' })

View File

@@ -499,7 +499,7 @@ A save checks every submitted money value before the transaction and answers a 4
| Value | Answer |
|-------|--------|
| Not a string, or a string that is not an optional minus, digits and at most one dot followed by digits (`"1,5"`, `"1e3"`, `"1 000"`, a JSON number) | `money.invalid` |
| More fraction digits than `scale` (`"12.34567"` at scale 4) | `money.too_precise`, with `:scale` |
| More significant fraction digits than `scale` (`"12.34567"` at scale 4); trailing zeros do not count, so a scale-2 field over a `numeric(10,4)` column accepts its loaded `"12.5000"` | `money.too_precise`, with `:scale` |
| Above `max` in absolute magnitude, compared as scaled digit strings | `money.too_large`, with `:max` |
`null` is accepted and stored as NULL (or the column type's empty value); `required: true` refuses it through the usual rule. A value the administrator did not edit is sent back as it was loaded, so opening and saving a form with a stored `85.4193` writes `85.4193` again, never `85.42`. The same checks run on settings forms and relation forms.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-Dioh6ysQ.js"></script>
<script type="module" crossorigin src="./assets/index-DYW0Mjwc.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-c6AgbZf1.css">
</head>
<body>

View File

@@ -46,7 +46,7 @@ Use cases:
- Preset fields: `preset` on a `type: text` or `type: mltext` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text or mltext field of the same form on the create screen until the administrator edits it, per locale for an mltext pair. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- Money fields: a `type: money` field in `fields.yaml` edits an exact decimal amount that travels as a decimal string with a dot and at most `scale` (2 to 4, default 4) fraction digits, or null; the server never parses it into a float, never rounds it and never truncates it, and fills the accepted string into a string column or a type with its own `sql.Scanner` and `driver.Valuer` unchanged. A field declares exactly one of `currency` (an ISO 4217 code) or `currencyFrom` (a sibling string column holding the code) and may declare `max` (the column ceiling); two sources, none, a scale outside 2..4, a max beyond the scale, a column of another type or a currencyFrom that is not a string column fails boot naming the field. A save answers 422 on the field with `backend::lang.money.invalid`, `money.too_precise` (`:scale`) or `money.too_large` (`:max`, compared as scaled digit strings), on controller forms, settings forms and relation forms alike. The schema serves `currency`, `currencyFrom`, `scale` and `max` on the field. A list column `type: money` takes the same `currency` or `currencyFrom` key, is never searchable and not sortable by default, and every list row carries the `currencyFrom` column's value under its own key even when it is not a declared column; the admin right-aligns the locale-formatted amount with its ISO code.
- Money fields: a `type: money` field in `fields.yaml` edits an exact decimal amount that travels as a decimal string with a dot and at most `scale` (2 to 4, default 4) significant fraction digits (trailing zeros beyond the scale are accepted unchanged), or null; the server never parses it into a float, never rounds it and never truncates it, and fills the accepted string into a string column or a type with its own `sql.Scanner` and `driver.Valuer` unchanged. A field declares exactly one of `currency` (an ISO 4217 code) or `currencyFrom` (a sibling string column holding the code) and may declare `max` (the column ceiling); two sources, none, a scale outside 2..4, a max beyond the scale, a column of another type or a currencyFrom that is not a string column fails boot naming the field. A save answers 422 on the field with `backend::lang.money.invalid`, `money.too_precise` (`:scale`) or `money.too_large` (`:max`, compared as scaled digit strings), on controller forms, settings forms and relation forms alike. The schema serves `currency`, `currencyFrom`, `scale` and `max` on the field. A list column `type: money` takes the same `currency` or `currencyFrom` key, is never searchable and not sortable by default, and every list row carries the `currencyFrom` column's value under its own key even when it is not a declared column; the admin right-aligns the locale-formatted amount with its ISO code.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.

View File

@@ -153,8 +153,11 @@ func allDigits(s string) bool {
// validateMoneyValue checks one submitted money value against the field's
// scale and max: null is accepted (stored as NULL or the column type's zero
// value); a string must match the decimal grammar (money.invalid), carry at
// most scale fraction digits (money.too_precise, :scale) and, when the field
// declares max, not exceed it in absolute magnitude (money.too_large, :max).
// most scale significant fraction digits (money.too_precise, :scale) and,
// when the field declares max, not exceed it in absolute magnitude
// (money.too_large, :max). Trailing zeros beyond the scale carry no
// precision and are accepted as they are, so a scale-2 field over a
// four-decimal column saves its loaded "12.5000" again unchanged.
// Magnitudes are compared as scaled digit strings, never as floats, and the
// value is never rounded or truncated. Anything that is not a string or null
// is money.invalid. It returns the phrase key and its placeholders of the
@@ -171,7 +174,7 @@ func validateMoneyValue(value any, scale int, max string) (string, map[string]st
if !ok {
return msgMoneyInvalid, nil
}
if len(frac) > scale {
if len(strings.TrimRight(frac, "0")) > scale {
return msgMoneyTooPrecise, map[string]string{"scale": fmt.Sprint(scale)}
}
if max != "" {