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 }