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:
File diff suppressed because one or more lines are too long
11
modules/boardwalk/dist/assets/index-BPd1k6k8.js
vendored
Normal file
11
modules/boardwalk/dist/assets/index-BPd1k6k8.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-BxyrlyKr.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-BxyrlyKr.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
11
modules/boardwalk/dist/assets/index-DzfknjJw.js
vendored
11
modules/boardwalk/dist/assets/index-DzfknjJw.js
vendored
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
||||
<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-DzfknjJw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CmttTOWN.css">
|
||||
<script type="module" crossorigin src="./assets/index-BPd1k6k8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BxyrlyKr.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -46,6 +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.
|
||||
- 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.
|
||||
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
|
||||
@@ -250,7 +251,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `cabana.AdminRecordAction` | Swag annotation of the record action route. |
|
||||
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
|
||||
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. |
|
||||
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |
|
||||
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows; its `Tr` translator localizes the Put's money 422 messages. |
|
||||
| `cabana.FieldRelationProvider` / `cabana.FieldRelationContract` | Controller-supplied bindings for `type: relation` form fields. `WritableForeignKey` on a belongsTo contract makes a protected foreign key writable through that field. |
|
||||
| `cabana.RelationLock` | The related ids of one relation field the requesting administrator may not add or remove, and the message of the 403. |
|
||||
| `cabana.RelationLockProvider` | Controller capability that returns the `cabana.RelationLock` of a relation field per request; enforced on create and update. |
|
||||
|
||||
@@ -619,6 +619,11 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err := checkRichEditorValues(cc, in.Body, op); err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
// D-07: a money value is an exact decimal string within the field's
|
||||
// scale and max, or null; anything else is a 422 on the field.
|
||||
if err := checkMoneyValues(ctx, s.tr, cc, in.Body, op); err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
// D-07: repeater rows are decoded in place (only nested keys, coerced
|
||||
// scalars, per-row required, row bounds) before the projection, which
|
||||
// lets a repeater's list through.
|
||||
@@ -1270,7 +1275,7 @@ func modelColumns(model any) map[string]struct{} {
|
||||
func scalarFormField(typ string) bool {
|
||||
switch typ {
|
||||
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker",
|
||||
"markdown", "mltext", "mltextarea", "mlmarkdown", "richeditor":
|
||||
"markdown", "mltext", "mltextarea", "mlmarkdown", "richeditor", moneyType:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
327
modules/cabana/field_money.go
Normal file
327
modules/cabana/field_money.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"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 fraction digits (money.too_precise, :scale) and, when the field
|
||||
// declares max, not exceed it in absolute magnitude (money.too_large, :max).
|
||||
// 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(frac) > 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
|
||||
}
|
||||
@@ -27,7 +27,7 @@ var (
|
||||
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
||||
"password": {}, "permissioneditor": {},
|
||||
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
|
||||
"richeditor": {}, "repeater": {},
|
||||
"richeditor": {}, "repeater": {}, "money": {},
|
||||
}
|
||||
formSpans = map[string]struct{}{
|
||||
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
||||
@@ -48,6 +48,7 @@ var (
|
||||
"preset": {}, "dependsOn": {},
|
||||
"form": {}, "maxItems": {}, "minItems": {}, "titleFrom": {},
|
||||
"groups": {}, "style": {},
|
||||
"currency": {}, "currencyFrom": {}, "scale": {}, "max": {},
|
||||
}
|
||||
// widgetKeys are valid only on `type: widget` (D-06).
|
||||
widgetKeys = []string{"widget", "action", "fill"}
|
||||
@@ -632,6 +633,9 @@ func compileFieldNode(name string, node ast.Node, types *fieldTypeRegistry) (For
|
||||
if err := compileDatepickerKeys(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if err := compileMoneyKeys(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if err := compilePresetKey(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
|
||||
@@ -527,7 +527,7 @@ func (s *service) settingsGet(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
result, err := (SettingsService{DB: db}).Get(r.Context(), setting)
|
||||
result, err := (SettingsService{DB: db, Tr: s.translator()}).Get(r.Context(), setting)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
@@ -548,7 +548,7 @@ func (s *service) settingsPut(w http.ResponseWriter, r *http.Request) {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
result, err := (SettingsService{DB: db}).Put(r.Context(), setting, body)
|
||||
result, err := (SettingsService{DB: db, Tr: s.translator()}).Put(r.Context(), setting, body)
|
||||
if err != nil {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
|
||||
403
modules/cabana/payments_smoke_test.go
Normal file
403
modules/cabana/payments_smoke_test.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// paymentsDir is the neutral acme.payments fixture plugin tree of the Phase
|
||||
// 18 cabana features: the exact-decimal money field and column. No
|
||||
// application names appear in framework tests.
|
||||
const paymentsDir = "testdata/payments"
|
||||
|
||||
// rawAmount is the fixture's amount column type: a sql.Scanner and
|
||||
// driver.Valuer that keeps the decimal string exactly as the database or the
|
||||
// save handed it, so a test can assert that cabana never rewrote it.
|
||||
type rawAmount struct {
|
||||
text string
|
||||
valid bool
|
||||
}
|
||||
|
||||
// Scan keeps the text of a numeric column; a float would mean the driver
|
||||
// rounded the value, so it is refused.
|
||||
func (a *rawAmount) Scan(src any) error {
|
||||
switch v := src.(type) {
|
||||
case nil:
|
||||
*a = rawAmount{}
|
||||
case string:
|
||||
*a = rawAmount{text: v, valid: true}
|
||||
case []byte:
|
||||
*a = rawAmount{text: string(v), valid: true}
|
||||
default:
|
||||
return fmt.Errorf("rawAmount: cannot scan %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value writes the text back unchanged, or NULL.
|
||||
func (a rawAmount) Value() (driver.Value, error) {
|
||||
if !a.valid {
|
||||
return nil, nil
|
||||
}
|
||||
return a.text, nil
|
||||
}
|
||||
|
||||
// MarshalJSON serves the stored text as a JSON string, or null.
|
||||
func (a rawAmount) MarshalJSON() ([]byte, error) {
|
||||
if !a.valid {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(a.text)
|
||||
}
|
||||
|
||||
// paymentsInvoice is the fixture model of the invoices controller: a
|
||||
// currency code column and two money columns, one following the currency
|
||||
// column and one with a fixed currency.
|
||||
type paymentsInvoice struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Number string `gorm:"column:number"`
|
||||
Currency string `gorm:"column:currency"`
|
||||
Total rawAmount `gorm:"column:total;type:numeric(12,4)"`
|
||||
Fee rawAmount `gorm:"column:fee;type:numeric(10,4)"`
|
||||
}
|
||||
|
||||
func (paymentsInvoice) TableName() string { return "cabana_payments_invoices" }
|
||||
func (paymentsInvoice) Fillable() []string {
|
||||
return []string{"number", "currency", "total", "fee"}
|
||||
}
|
||||
func (paymentsInvoice) Rules() map[string]string { return map[string]string{"number": "required"} }
|
||||
|
||||
// paymentsModels are the fixture tables, dropped and recreated per env.
|
||||
func paymentsModels() []any {
|
||||
return []any{&paymentsInvoice{}}
|
||||
}
|
||||
|
||||
// paymentsPlugin registers the invoices controller.
|
||||
type paymentsPlugin struct{}
|
||||
|
||||
func (paymentsPlugin) ID() string { return "acme.payments" }
|
||||
func (paymentsPlugin) Requires() []string { return nil }
|
||||
func (paymentsPlugin) Register(*backpack.App) error { return nil }
|
||||
func (paymentsPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (paymentsPlugin) AdminFS() fs.FS { return os.DirFS(paymentsDir) }
|
||||
func (paymentsPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{paymentsInvoices{}}
|
||||
}
|
||||
func (paymentsPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{Code: "acme.payments.access", Roles: []string{"developer"}}}
|
||||
}
|
||||
|
||||
// paymentsInvoices is the invoices controller.
|
||||
type paymentsInvoices struct{}
|
||||
|
||||
func (paymentsInvoices) ID() string { return "acme.payments.invoices" }
|
||||
func (paymentsInvoices) ModelName() string { return "Invoice" }
|
||||
func (paymentsInvoices) ConfigDir() string { return "controllers/invoices" }
|
||||
func (paymentsInvoices) RequiredPermissions() []string { return []string{"acme.payments.access"} }
|
||||
func (paymentsInvoices) NewRecord() any { return &paymentsInvoice{} }
|
||||
|
||||
// paymentsAssemble boots plugins on the test database with the fixture's
|
||||
// config: the assembled admin handler, or the boot error.
|
||||
func paymentsAssemble(t *testing.T, gdb *gorm.DB, plugins []party.Plugin) (http.Handler, error) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-payments\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
|
||||
if err := cfg.Set(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := phrasebook.Activate(app, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return surf.Assemble(app, plugins)
|
||||
}
|
||||
|
||||
// newPaymentsEnv boots the acme.payments fixture alone on Postgres and signs
|
||||
// an administrator in.
|
||||
func newPaymentsEnv(t *testing.T) *conformEnv {
|
||||
t.Helper()
|
||||
gdb := adminGorm(t)
|
||||
if err := gdb.Migrator().DropTable(paymentsModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(paymentsModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login := fmt.Sprintf("payments-%d", time.Now().UnixNano())
|
||||
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
||||
h, err := paymentsAssemble(t, gdb, []party.Plugin{paymentsPlugin{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &conformEnv{h: h, db: gdb, login: login}
|
||||
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// moneyFormFS is a one-controller plugin tree whose invoice fields.yaml is
|
||||
// the given text, for compile-only checks of the money keys.
|
||||
func moneyFormFS(fields string) fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"controllers/invoices/config_form.yaml": {Data: []byte("name: invoices\nform: ~/plugins/acme/payments/models/invoice/fields.yaml\nmodelClass: Invoice\n")},
|
||||
"models/invoice/fields.yaml": {Data: []byte(fields)},
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoneyFieldCompile checks the money field's boot gates without a
|
||||
// database: exactly one currency source, a scale of 2 to 4 (default 4), an
|
||||
// ISO code, a max within the scale, and the keys refused elsewhere.
|
||||
func TestMoneyFieldCompile(t *testing.T) {
|
||||
schema, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS(`fields:
|
||||
currency:
|
||||
type: text
|
||||
total:
|
||||
type: money
|
||||
currencyFrom: currency
|
||||
max: "999999.9999"
|
||||
fee:
|
||||
type: money
|
||||
currency: EUR
|
||||
scale: 2
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byName := map[string]cabana.FormField{}
|
||||
for _, field := range schema.Fields {
|
||||
byName[field.Name] = field
|
||||
}
|
||||
if total := byName["total"]; total.Type != "money" || total.CurrencyFrom != "currency" || total.Currency != "" || total.Scale != 4 || total.Max != "999999.9999" {
|
||||
t.Fatalf("total = %+v", total)
|
||||
}
|
||||
if fee := byName["fee"]; fee.Currency != "EUR" || fee.CurrencyFrom != "" || fee.Scale != 2 || fee.Max != "" {
|
||||
t.Fatalf("fee = %+v", fee)
|
||||
}
|
||||
raw, err := json.Marshal(byName["total"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"currencyFrom":"currency"`, `"scale":4`, `"max":"999999.9999"`} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Fatalf("schema JSON lacks %s: %s", want, raw)
|
||||
}
|
||||
}
|
||||
|
||||
refused := map[string]string{
|
||||
"both sources": `fields:
|
||||
currency:
|
||||
type: text
|
||||
total:
|
||||
type: money
|
||||
currency: PLN
|
||||
currencyFrom: currency
|
||||
`,
|
||||
"no source": `fields:
|
||||
total:
|
||||
type: money
|
||||
`,
|
||||
"scale 5": `fields:
|
||||
total:
|
||||
type: money
|
||||
currency: PLN
|
||||
scale: 5
|
||||
`,
|
||||
"scale 1": `fields:
|
||||
total:
|
||||
type: money
|
||||
currency: PLN
|
||||
scale: 1
|
||||
`,
|
||||
"lower-case code": `fields:
|
||||
total:
|
||||
type: money
|
||||
currency: pln
|
||||
`,
|
||||
"max beyond scale": `fields:
|
||||
total:
|
||||
type: money
|
||||
currency: PLN
|
||||
scale: 2
|
||||
max: "10.001"
|
||||
`,
|
||||
"max not a decimal": `fields:
|
||||
total:
|
||||
type: money
|
||||
currency: PLN
|
||||
max: "1,5"
|
||||
`,
|
||||
"options on money": `fields:
|
||||
total:
|
||||
type: money
|
||||
currency: PLN
|
||||
options: {a: A}
|
||||
`,
|
||||
"currency on text": `fields:
|
||||
total:
|
||||
type: text
|
||||
currency: PLN
|
||||
`,
|
||||
}
|
||||
for name, fields := range refused {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := cabana.CompileForm("acme.payments", paymentsInvoices{}, moneyFormFS(fields))
|
||||
if err == nil {
|
||||
t.Fatalf("compiled")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "field total") {
|
||||
t.Fatalf("error does not name the field: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// invoiceRecord is a create or update answer of the invoices controller.
|
||||
type invoiceRecord struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// TestMoneyFieldSmoke checks the money save path end to end on Postgres: an
|
||||
// accepted decimal is stored byte for byte and served back unchanged, and a
|
||||
// too-precise, malformed or too-large value is a 422 on the field with the
|
||||
// money.* message (Polish, the request locale of the env).
|
||||
func TestMoneyFieldSmoke(t *testing.T) {
|
||||
e := newPaymentsEnv(t)
|
||||
rec := e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "INV-1", "currency": "PLN", "total": "85.4193", "fee": "1.50"}, true)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
id := dataID(t, rec.Body.Bytes())
|
||||
var stored paymentsInvoice
|
||||
if err := e.db.First(&stored, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stored.Total.valid || stored.Total.text != "85.4193" {
|
||||
t.Fatalf("stored total = %+v, want the exact string 85.4193", stored.Total)
|
||||
}
|
||||
if !stored.Fee.valid || stored.Fee.text != "1.5000" {
|
||||
t.Fatalf("stored fee = %+v, want 1.5000 (numeric(10,4) pads the text; the input was not changed by cabana)", stored.Fee)
|
||||
}
|
||||
show := e.send(t, http.MethodGet, fmt.Sprintf("/acme/payments/invoices/%d", id), nil, true)
|
||||
if show.Code != http.StatusOK {
|
||||
t.Fatalf("show status=%d body=%s", show.Code, show.Body.String())
|
||||
}
|
||||
var record invoiceRecord
|
||||
if err := json.Unmarshal(show.Body.Bytes(), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.Data["total"] != "85.4193" {
|
||||
t.Fatalf("served total = %#v, want the string 85.4193", record.Data["total"])
|
||||
}
|
||||
|
||||
// An untouched value sent back is re-saved byte-identical.
|
||||
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/payments/invoices/%d", id), map[string]any{"number": "INV-1", "currency": "PLN", "total": "85.4193"}, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := e.db.First(&stored, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Total.text != "85.4193" {
|
||||
t.Fatalf("re-saved total = %+v", stored.Total)
|
||||
}
|
||||
|
||||
expect422 := func(t *testing.T, body map[string]any, field, message string) {
|
||||
t.Helper()
|
||||
rec := e.send(t, http.MethodPost, "/acme/payments/invoices", body, true)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("want 422 on %s, status=%d body=%s", field, rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Error struct {
|
||||
Details map[string][]string `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("422 body: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
msgs := out.Error.Details[field]
|
||||
if len(msgs) != 1 || !strings.Contains(msgs[0], message) {
|
||||
t.Fatalf("422 details on %s = %v, want %q; body=%s", field, msgs, message, rec.Body.String())
|
||||
}
|
||||
}
|
||||
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": "12.34567"}, "total", "najwyżej 4 miejsc")
|
||||
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": "1,5"}, "total", "To nie jest kwota")
|
||||
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": "1000000.0000"}, "total", "najwyżej 999999.9999")
|
||||
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "total": 85.4193}, "total", "To nie jest kwota")
|
||||
expect422(t, map[string]any{"number": "INV-2", "currency": "PLN", "fee": "1.234"}, "fee", "najwyżej 2 miejsc")
|
||||
|
||||
// Exactly max is accepted; null clears.
|
||||
rec = e.send(t, http.MethodPost, "/acme/payments/invoices", map[string]any{"number": "INV-3", "currency": "PLN", "total": "999999.9999", "fee": nil}, true)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("max status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var capped paymentsInvoice
|
||||
if err := e.db.First(&capped, dataID(t, rec.Body.Bytes())).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if capped.Total.text != "999999.9999" || capped.Fee.valid {
|
||||
t.Fatalf("stored = %+v", capped)
|
||||
}
|
||||
|
||||
// A boot whose money column cannot hold the string fails naming the field.
|
||||
t.Run("column type", func(t *testing.T) {
|
||||
_, err := paymentsAssemble(t, e.db, []party.Plugin{moneyBadColumnPlugin{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "field total") {
|
||||
t.Fatalf("boot with an int money column: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// moneyBadInvoice has an integer total: not a money column.
|
||||
type moneyBadInvoice struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Number string `gorm:"column:number"`
|
||||
Currency string `gorm:"column:currency"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
Fee string `gorm:"column:fee"`
|
||||
}
|
||||
|
||||
func (moneyBadInvoice) TableName() string { return "cabana_payments_bad" }
|
||||
func (moneyBadInvoice) Fillable() []string { return []string{"number", "currency", "total", "fee"} }
|
||||
func (moneyBadInvoice) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
type moneyBadColumnPlugin struct{ paymentsPlugin }
|
||||
|
||||
func (moneyBadColumnPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{moneyBadInvoices{}}
|
||||
}
|
||||
|
||||
type moneyBadInvoices struct{ paymentsInvoices }
|
||||
|
||||
func (moneyBadInvoices) NewRecord() any { return &moneyBadInvoice{} }
|
||||
@@ -137,6 +137,9 @@ func compileRegistryWithTypes(items []controllerRef, types *fieldTypeRegistry) (
|
||||
if err := compileDateFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileMoneyFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compilePermissionFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ func relationQuery(ctx context.Context, tx *gorm.DB, cc *CompiledController, cr
|
||||
// model rules merged with the form's required flags, and the datepicker
|
||||
// bounds. A failure is a 422 ValidationError.
|
||||
func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *CompiledController, model any, body map[string]any, op string) error {
|
||||
if err := checkMoneyValues(ctx, s.tr, form, body, op); err != nil {
|
||||
return err
|
||||
}
|
||||
projected := projectOperation(form, body, op)
|
||||
if err := lagoon.Fill(model, fillAllowed(form, model, op), projected, false); err != nil {
|
||||
var typed *lagoon.FillTypeError
|
||||
|
||||
@@ -203,6 +203,9 @@ func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS,
|
||||
if err := compileDateFields(pluginID, cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileMoneyFields(pluginID, cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cc, nil
|
||||
}
|
||||
if err := checkFormDates(pluginID, cc); err != nil {
|
||||
|
||||
@@ -244,6 +244,7 @@ func TestRepeaterCompile(t *testing.T) {
|
||||
"partial": " path: summary\n",
|
||||
"permissioneditor": " mode: checkbox\n",
|
||||
"repeater": " form:\n fields:\n a:\n type: text\n",
|
||||
"money": " currency: PLN\n",
|
||||
}
|
||||
for typ := range formFieldTypes {
|
||||
if repeaterNestedTypes[typ] {
|
||||
|
||||
@@ -309,6 +309,18 @@ type FormField struct {
|
||||
TwelveHour bool `json:"twelveHour,omitempty"`
|
||||
// IgnoreTimezone keeps a datetime's wall-clock value with no conversion.
|
||||
IgnoreTimezone bool `json:"ignoreTimezone,omitempty"`
|
||||
// Currency is the ISO 4217 code a `type: money` field shows as its
|
||||
// suffix; exactly one of Currency and CurrencyFrom is set.
|
||||
Currency string `json:"currency,omitempty"`
|
||||
// CurrencyFrom names the sibling column of the model whose value is the
|
||||
// money field's currency code; the admin follows that field's value.
|
||||
CurrencyFrom string `json:"currencyFrom,omitempty"`
|
||||
// Scale is the most fraction digits a money value may carry, 2 to 4
|
||||
// (default 4). A value with more digits is refused, never rounded.
|
||||
Scale int `json:"scale,omitempty"`
|
||||
// Max is a money field's column ceiling as a decimal string; a value
|
||||
// above it in absolute magnitude is a 422 money.too_large.
|
||||
Max string `json:"max,omitempty"`
|
||||
// FileTypes are the allowed lower-case extensions of a fileupload field.
|
||||
FileTypes []string `json:"fileTypes,omitempty"`
|
||||
// MimeTypes are the allowed MIME patterns (or extensions) of a
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -33,7 +34,12 @@ type SettingsResult struct {
|
||||
}
|
||||
|
||||
// SettingsService reads and transactionally updates compiled singleton rows.
|
||||
type SettingsService struct{ DB *gorm.DB }
|
||||
// Tr localizes the 422 messages of the settings Put (a money value outside
|
||||
// its field's scale or max); nil keeps the phrase keys.
|
||||
type SettingsService struct {
|
||||
DB *gorm.DB
|
||||
Tr *phrasebook.Translator
|
||||
}
|
||||
|
||||
// compileSettingLink registers a settings entry that opens an admin
|
||||
// controller. It declares no singleton form and no model; the controller ID
|
||||
@@ -115,6 +121,11 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
|
||||
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
|
||||
}
|
||||
}
|
||||
if field.Type == moneyType {
|
||||
if err := checkMoneyType(model, field); err != nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
|
||||
}
|
||||
}
|
||||
writable = append(writable, WritableField{Name: field.Name, FillKey: field.Name})
|
||||
}
|
||||
return &CompiledSetting{PluginID: pluginID, Item: item, Form: form, Writable: writable}, nil
|
||||
@@ -151,6 +162,9 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
|
||||
if err := requireSettingInputs(setting, body); err != nil {
|
||||
return SettingsResult{}, err
|
||||
}
|
||||
if details := moneyDetails(ctx, s.Tr, setting.Form.Fields, body, nil); len(details) > 0 {
|
||||
return SettingsResult{}, &ValidationError{Details: details}
|
||||
}
|
||||
var result SettingsResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
model, err := newSettingModel(setting)
|
||||
|
||||
4
modules/cabana/testdata/payments/controllers/invoices/config_form.yaml
vendored
Normal file
4
modules/cabana/testdata/payments/controllers/invoices/config_form.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
name: New invoice
|
||||
form: ~/plugins/acme/payments/models/invoice/fields.yaml
|
||||
modelClass: Invoice
|
||||
defaultRedirect: acme/payments/invoices
|
||||
7
modules/cabana/testdata/payments/controllers/invoices/config_list.yaml
vendored
Normal file
7
modules/cabana/testdata/payments/controllers/invoices/config_list.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
list: ~/plugins/acme/payments/models/invoice/columns.yaml
|
||||
modelClass: Invoice
|
||||
title: Invoices
|
||||
recordUrl: acme/payments/invoices/update/:id
|
||||
recordsPerPage: 20
|
||||
toolbar:
|
||||
buttons: [create]
|
||||
6
modules/cabana/testdata/payments/models/invoice/columns.yaml
vendored
Normal file
6
modules/cabana/testdata/payments/models/invoice/columns.yaml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
columns:
|
||||
number:
|
||||
label: Number
|
||||
searchable: true
|
||||
currency:
|
||||
label: Currency
|
||||
19
modules/cabana/testdata/payments/models/invoice/fields.yaml
vendored
Normal file
19
modules/cabana/testdata/payments/models/invoice/fields.yaml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
fields:
|
||||
number:
|
||||
label: Number
|
||||
type: text
|
||||
required: true
|
||||
currency:
|
||||
label: Currency
|
||||
type: text
|
||||
total:
|
||||
label: Total
|
||||
type: money
|
||||
currencyFrom: currency
|
||||
scale: 4
|
||||
max: "999999.9999"
|
||||
fee:
|
||||
label: Fee
|
||||
type: money
|
||||
currency: EUR
|
||||
scale: 2
|
||||
@@ -139,6 +139,11 @@ datepicker:
|
||||
clear: Clear value
|
||||
prev_month: Previous month
|
||||
next_month: Next month
|
||||
money:
|
||||
invalid: "This is not an amount. Enter digits with one decimal separator, for example 1234.56."
|
||||
too_precise: "Use at most :scale decimal places. Amounts are never rounded."
|
||||
too_large: "This amount is larger than the field can store (at most :max)."
|
||||
currency: "Currency: :code"
|
||||
fileupload:
|
||||
attachment: Attachment details
|
||||
help: Add a title and description for this attachment.
|
||||
|
||||
@@ -149,6 +149,11 @@ datepicker:
|
||||
clear: Wyczyść wartość
|
||||
prev_month: Poprzedni miesiąc
|
||||
next_month: Następny miesiąc
|
||||
money:
|
||||
invalid: "To nie jest kwota. Wpisz cyfry z jednym separatorem dziesiętnym, np. 1234,56."
|
||||
too_precise: "Użyj najwyżej :scale miejsc po przecinku. Kwoty nie są zaokrąglane."
|
||||
too_large: "Ta kwota jest większa, niż pole może zapisać (najwyżej :max)."
|
||||
currency: "Waluta: :code"
|
||||
fileupload:
|
||||
attachment: Szczegóły załącznika
|
||||
help: Dodaj tytuł oraz opis załącznika.
|
||||
|
||||
Reference in New Issue
Block a user