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
383 lines
13 KiB
Go
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
|
|
}
|