Files
summercms/modules/cabana/field_money.go
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

383 lines
13 KiB
Go

package cabana
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"reflect"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml/ast"
)
// moneyType is the core exact-decimal amount field type (Phase 18 D-07 with
// the amended D-09). Its value travels as a decimal string with a dot and at
// most Scale fraction digits ("39.8400", "85.4193"), or null. The server
// never parses it into a float, never rounds it and never truncates it: the
// accepted string is handed to the model column unchanged (a string column,
// or a type with its own sql.Scanner and driver.Valuer).
const moneyType = "money"
// Phrase keys of the money field's save errors and its suffix text
// (backend::lang.money.*).
const (
msgMoneyInvalid = "backend::lang.money.invalid"
msgMoneyTooPrecise = "backend::lang.money.too_precise"
msgMoneyTooLarge = "backend::lang.money.too_large"
)
// moneyDefaultScale is the fraction digits a money field keeps when
// fields.yaml names no scale: four, the GAAP precision of the amount columns
// the field edits.
const moneyDefaultScale = 4
// moneyKeys are valid only on `type: money`.
var moneyKeys = []string{"currency", "currencyFrom", "scale", "max"}
// moneyRefusedKeys are generic field keys with no meaning on a money field.
var moneyRefusedKeys = []string{"options", "emptyOption", "nameFrom", "dependsOn", "preset"}
var (
sqlScannerType = reflect.TypeOf((*sql.Scanner)(nil)).Elem()
driverValuerType = reflect.TypeOf((*driver.Valuer)(nil)).Elem()
)
// compileMoneyKeys decodes the keys of a `type: money` field: exactly one
// of currency (a three-letter upper-case ISO 4217 code) or currencyFrom (a
// sibling column of the model that holds the code), scale (2 to 4, default
// 4) and the optional max (the column ceiling, a decimal with at most scale
// places). The keys are refused on every other type.
func compileMoneyKeys(typ string, values map[string]ast.Node, field *FormField) error {
if typ != moneyType {
for _, key := range moneyKeys {
if _, ok := values[key]; ok {
return fmt.Errorf("%s is only valid on type: money", key)
}
}
return nil
}
for _, key := range moneyRefusedKeys {
if _, ok := values[key]; ok {
return fmt.Errorf("%s is not valid on type: money", key)
}
}
if node, ok := values["currency"]; ok {
code, err := nodeString(node)
if err != nil || !isoCurrencyCode(code) {
return fmt.Errorf("currency %q must be a three-letter upper-case ISO 4217 code", nodeText(node))
}
field.Currency = code
}
if node, ok := values["currencyFrom"]; ok {
name, err := nodeString(node)
if err != nil || !identifier(name) {
return fmt.Errorf("currencyFrom %q is not a column name", nodeText(node))
}
field.CurrencyFrom = name
}
if (field.Currency == "") == (field.CurrencyFrom == "") {
return fmt.Errorf("money needs exactly one of currency or currencyFrom")
}
field.Scale = moneyDefaultScale
if node, ok := values["scale"]; ok {
n, err := nodeInt(node)
if err != nil || n < 2 || n > 4 {
return fmt.Errorf("scale %q must be 2, 3 or 4 on type: money", nodeText(node))
}
field.Scale = int(n)
}
if node, ok := values["max"]; ok {
text, err := nodeString(node)
if err != nil {
text = nodeText(node)
}
text = strings.TrimSpace(text)
whole, frac, ok := decimalParts(text)
if !ok || strings.HasPrefix(text, "-") || whole == "" {
return fmt.Errorf("max %q must be a decimal such as 999999.9999", text)
}
if len(frac) > field.Scale {
return fmt.Errorf("max %q has more than %d decimal places", text, field.Scale)
}
field.Max = text
}
return nil
}
// isoCurrencyCode reports whether s is three upper-case ASCII letters.
func isoCurrencyCode(s string) bool {
if len(s) != 3 {
return false
}
for i := 0; i < 3; i++ {
if s[i] < 'A' || s[i] > 'Z' {
return false
}
}
return true
}
// decimalParts splits a decimal string into its integer and fraction digit
// runs: an optional leading minus, one or more digits, and optionally a dot
// followed by one or more digits. Anything else (an exponent, a comma, a
// space, a sign elsewhere, a lone dot) is not a decimal.
func decimalParts(s string) (whole, frac string, ok bool) {
s = strings.TrimPrefix(s, "-")
if s == "" {
return "", "", false
}
whole, frac, dotted := strings.Cut(s, ".")
if !allDigits(whole) || (dotted && !allDigits(frac)) {
return "", "", false
}
return whole, frac, true
}
// allDigits reports whether s is one or more ASCII digits.
func allDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// 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 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
// first failure, or "" when the value is accepted.
func validateMoneyValue(value any, scale int, max string) (string, map[string]string) {
if value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return msgMoneyInvalid, nil
}
whole, frac, ok := decimalParts(text)
if !ok {
return msgMoneyInvalid, nil
}
if len(strings.TrimRight(frac, "0")) > scale {
return msgMoneyTooPrecise, map[string]string{"scale": fmt.Sprint(scale)}
}
if max != "" {
maxWhole, maxFrac, _ := decimalParts(max)
if compareScaled(whole, frac, maxWhole, maxFrac, scale) > 0 {
return msgMoneyTooLarge, map[string]string{"max": max}
}
}
return "", nil
}
// compareScaled compares two absolute decimal magnitudes given as digit runs,
// both padded to scale fraction digits, without any numeric conversion. It
// returns -1, 0 or 1.
func compareScaled(whole, frac, otherWhole, otherFrac string, scale int) int {
a := strings.TrimLeft(whole, "0") + padDigits(frac, scale)
b := strings.TrimLeft(otherWhole, "0") + padDigits(otherFrac, scale)
if len(a) != len(b) {
if len(a) < len(b) {
return -1
}
return 1
}
return strings.Compare(a, b)
}
// padDigits right-pads a fraction digit run with zeros to scale digits.
func padDigits(frac string, scale int) string {
if len(frac) >= scale {
return frac[:scale]
}
return frac + strings.Repeat("0", scale-len(frac))
}
// checkMoneyType refuses a money field whose model column cannot hold the
// exact decimal string: the column must be a string (or *string), or a
// struct type whose pointer implements sql.Scanner and that implements
// driver.Valuer (such as a plugin's scale-4 amount type). A currencyFrom
// must name a string column of the same model.
func checkMoneyType(model any, field FormField) error {
sf, ok := structFieldByColumn(model, field.Name)
if !ok {
return fmt.Errorf("field %s is not a model column", field.Name)
}
if !moneyColumnType(sf.Type) {
return fmt.Errorf("field %s: money needs a string column or a sql.Scanner and driver.Valuer type, found %s", field.Name, sf.Type)
}
if field.CurrencyFrom != "" {
cf, ok := structFieldByColumn(model, field.CurrencyFrom)
if !ok || !stringColumnType(cf.Type) {
return fmt.Errorf("field %s: currencyFrom %s is not a string column of the model", field.Name, field.CurrencyFrom)
}
}
return nil
}
// moneyColumnType reports whether t can receive a money value unchanged.
func moneyColumnType(t reflect.Type) bool {
if stringColumnType(t) {
return true
}
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return false
}
ptr := reflect.PointerTo(t)
return ptr.Implements(sqlScannerType) && (t.Implements(driverValuerType) || ptr.Implements(driverValuerType))
}
// stringColumnType reports whether t is string or *string.
func stringColumnType(t reflect.Type) bool {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t.Kind() == reflect.String
}
// compileMoneyFields checks every money field of the controller's form
// against its model column's Go type at boot.
func compileMoneyFields(pluginID string, cc *CompiledController) error {
if cc == nil || cc.Form == nil {
return nil
}
var model any
for _, field := range cc.Form.Fields {
if field.Type != moneyType {
continue
}
if model == nil {
proto, err := newWritableModel(cc)
if err != nil {
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, fmt.Errorf("field %s: type money needs a fillable model", field.Name))
}
model = proto
}
if err := checkMoneyType(model, field); err != nil {
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, err)
}
}
return nil
}
// moneyDetails validates every money field of fields that is present in
// body and that allows (field name) accepts, and returns the 422 details
// per field in the request locale; nil when every value is accepted.
func moneyDetails(ctx context.Context, tr *phrasebook.Translator, fields []FormField, body map[string]any, allows func(string) bool) map[string]any {
var details map[string]any
var locCtx context.Context
for _, field := range fields {
if field.Type != moneyType || (allows != nil && !allows(field.Name)) {
continue
}
value, present := body[field.Name]
if !present {
continue
}
key, params := validateMoneyValue(value, field.Scale, field.Max)
if key == "" {
continue
}
if locCtx == nil {
locCtx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
}
if details == nil {
details = map[string]any{}
}
details[field.Name] = []string{moneyMessage(locCtx, tr, key, params)}
}
return details
}
// moneyMessage resolves a money phrase key with its placeholders.
func moneyMessage(ctx context.Context, tr *phrasebook.Translator, key string, params map[string]string) string {
if tr == nil {
return key
}
return tr.Get(ctx, key, params)
}
// checkMoneyValues refuses, before the save transaction, a submitted money
// value that does not fit its field: a 422 on the field with the money.*
// key, so the value never reaches Fill rounded, truncated or malformed.
func checkMoneyValues(ctx context.Context, tr *phrasebook.Translator, cc *CompiledController, body map[string]any, op string) error {
if cc == nil || cc.Form == nil {
return nil
}
details := moneyDetails(ctx, tr, cc.Form.Fields, body, func(name string) bool { return contextAllows(cc, name, op) })
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// checkMoneyColumn gates the money keys of a columns.yaml entry: on a
// `type: money` column exactly one of currency (an ISO 4217 code) or
// currencyFrom (a string column of the model) is required, the column is
// never searchable, takes no relation, and when the controller names its
// model (pact.AdminRecordSource) the column must be a string or a
// sql.Scanner and driver.Valuer type; on any other type the two keys are
// refused.
func checkMoneyColumn(typ, key string, spec columnDocument, ctl pact.AdminController) error {
if typ != moneyType {
if spec.Currency != "" {
return fmt.Errorf("currency is only valid on type: money")
}
if spec.CurrencyFrom != "" {
return fmt.Errorf("currencyFrom is only valid on type: money")
}
return nil
}
if spec.Relation != "" || spec.Select != "" {
return fmt.Errorf("a money column is read from the model, not through a relation")
}
if spec.Searchable {
return fmt.Errorf("a money column is never searchable")
}
if spec.Currency != "" && !isoCurrencyCode(spec.Currency) {
return fmt.Errorf("currency %q must be a three-letter upper-case ISO 4217 code", spec.Currency)
}
if spec.CurrencyFrom != "" && !identifier(spec.CurrencyFrom) {
return fmt.Errorf("currencyFrom %q is not a column name", spec.CurrencyFrom)
}
if (spec.Currency == "") == (spec.CurrencyFrom == "") {
return fmt.Errorf("money needs exactly one of currency or currencyFrom")
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {
return nil
}
model := src.NewRecord()
if model == nil {
return nil
}
if sf, ok := structFieldByColumn(model, key); ok && !moneyColumnType(sf.Type) {
return fmt.Errorf("money needs a string column or a sql.Scanner and driver.Valuer type, found %s", sf.Type)
}
if spec.CurrencyFrom != "" {
if cf, ok := structFieldByColumn(model, spec.CurrencyFrom); !ok || !stringColumnType(cf.Type) {
return fmt.Errorf("currencyFrom %s is not a string column of the model", spec.CurrencyFrom)
}
}
return nil
}