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:
Jakub Zych
2026-10-13 11:15:05 +02:00
parent 99ccae232c
commit 20385e53ee
33 changed files with 1388 additions and 26 deletions

View File

@@ -1310,6 +1310,14 @@
"context": {
"$ref": "#/components/schemas/cabana.fieldContext"
},
"currency": {
"description": "Currency is the ISO 4217 code a `type: money` field shows as its\nsuffix; exactly one of Currency and CurrencyFrom is set.",
"type": "string"
},
"currencyFrom": {
"description": "CurrencyFrom names the sibling column of the model whose value is the\nmoney field's currency code; the admin follows that field's value.",
"type": "string"
},
"default": {
"$ref": "#/components/schemas/cabana.jsonScalar"
},
@@ -1375,6 +1383,10 @@
"label": {
"type": "string"
},
"max": {
"description": "Max is a money field's column ceiling as a decimal string; a value\nabove it in absolute magnitude is a 422 money.too_large.",
"type": "string"
},
"maxDate": {
"type": "string"
},
@@ -1459,6 +1471,10 @@
"required": {
"type": "boolean"
},
"scale": {
"description": "Scale is the most fraction digits a money value may carry, 2 to 4\n(default 4). A value with more digits is refused, never rounded.",
"type": "integer"
},
"size": {
"type": "string"
},

View File

@@ -5592,6 +5592,16 @@ export interface components {
};
comment?: string;
context?: components["schemas"]["cabana.fieldContext"];
/**
* @description Currency is the ISO 4217 code a `type: money` field shows as its
* suffix; exactly one of Currency and CurrencyFrom is set.
*/
currency?: string;
/**
* @description 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;
default?: components["schemas"]["cabana.jsonScalar"];
/**
* @description Deferrable is true on a relation-manager field whose relation can be
@@ -5634,6 +5644,11 @@ export interface components {
/** @description ImageWidth and ImageHeight are the preview size of an image upload. */
imageWidth?: number;
label?: string;
/**
* @description 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;
maxDate?: string;
/** @description MaxFiles caps the number of files of an attachMany field. */
maxFiles?: number;
@@ -5690,6 +5705,11 @@ export interface components {
readOnly?: boolean;
relation?: string;
required?: boolean;
/**
* @description 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?: number;
size?: string;
span?: string;
tab?: string;

98
admin/src/app/money.ts Normal file
View File

@@ -0,0 +1,98 @@
// Exact-decimal money helpers of the `money` field and list column (Phase 18
// D-07, amended D-09, D-10). A money value is the decimal string the server
// stores ("39.8400", "85.4193"); it is never turned into a JS number here:
// parsing is text work on digits and separators, and formatting hands the
// string itself to Intl.NumberFormat, which accepts decimal strings without
// going through a float.
export type MoneyParseError = 'invalid' | 'too_precise'
export interface MoneyParseResult {
/** The canonical dot-separated string, or null for empty text. */
value: string | null
/** Why the text was refused, or null when value holds the result. */
error: MoneyParseError | null
}
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`.
*/
export function parseMoneyInput(text: string, scale: number): MoneyParseResult {
const compact = text.replace(/[\s ]/g, '')
if (compact === '') {
return { value: null, error: null }
}
let body = compact
let negative = false
if (body.startsWith('-')) {
negative = true
body = body.slice(1)
}
const separators = body.match(/[.,]/g) ?? []
if (separators.length > 1) {
return { value: null, error: 'invalid' }
}
const [whole, fraction = ''] = body.split(/[.,]/)
if (!digitsOnly.test(whole ?? '') || (separators.length === 1 && !digitsOnly.test(fraction))) {
return { value: null, error: 'invalid' }
}
if (fraction.length > scale) {
return { value: null, error: 'too_precise' }
}
const canonical = fraction === '' ? whole : `${whole}.${fraction}`
return { value: `${negative ? '-' : ''}${canonical}`, error: null }
}
/**
* The stored string for display in the input: trailing fraction zeros are
* trimmed down to two decimals ("39.8400" → "39.84", "85.4193" unchanged,
* "5.0000" → "5.00", "7" → "7"). The separator stays a dot; the field swaps
* it for the locale's own.
*/
export function trimMoneyDisplay(stored: string): string {
const [whole, fraction] = stored.split('.')
if (fraction === undefined || fraction.length <= 2) {
return stored
}
let end = fraction.length
while (end > 2 && fraction.charAt(end - 1) === '0') {
end -= 1
}
return `${whole}.${fraction.slice(0, end)}`
}
/** The decimal separator Intl uses for a locale ("," for pl, "." for en). */
export function decimalSeparator(locale: string): string {
const parts = new Intl.NumberFormat(locale).formatToParts(1.1)
return parts.find((part) => part.type === 'decimal')?.value ?? '.'
}
/**
* Formats a money string for reading: Intl.NumberFormat with the ISO code
* (`currencyDisplay: 'code'`), at least two and at most `scale` fraction
* digits, so "99999999.9999" keeps all four decimals and "39.8400" shows two.
* Without a currency the amount alone is formatted (`style: 'decimal'`). The
* string is passed to format() as is; it is never converted to a number.
*/
export function formatMoney(value: string, currency: string, locale: string, scale = 4): string {
const digits = { minimumFractionDigits: 2, maximumFractionDigits: Math.max(2, scale) }
const options: Intl.NumberFormatOptions =
currency === '' ? { style: 'decimal', ...digits } : { style: 'currency', currency, currencyDisplay: 'code', ...digits }
let formatter: Intl.NumberFormat
try {
formatter = new Intl.NumberFormat(locale, options)
} catch {
// An unknown ISO code: show the amount with the code appended.
const plain = new Intl.NumberFormat(locale, { style: 'decimal', ...digits })
return `${plain.format(value as unknown as number)} ${currency}`.trim()
}
// Intl accepts decimal strings (ECMA-402 "intl mathematical value"); the
// cast only satisfies the lib typings that predate it.
return formatter.format(value as unknown as number)
}

View File

@@ -8,8 +8,8 @@ import { isPluginField, rendererForField } from './registry'
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
// contrast and are read in order. Controls that already have a read-only
// mode (datepicker, fileupload, permissioneditor), partials and plugin field
// types (their element gets `disabled`) are rendered through their own
// mode (datepicker, fileupload, permissioneditor, money), partials and plugin
// field types (their element gets `disabled`) are rendered through their own
// component with the field marked read-only.
const props = defineProps<{
field: FormField
@@ -21,7 +21,7 @@ const props = defineProps<{
}>()
/** Types rendered by their own control in its read-only mode. */
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater'])
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater', 'money'])
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
const labelId = computed(() => `${controlId.value}-label`)

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { currentLocale, t } from '../../../app/i18n'
import { decimalSeparator, formatMoney, parseMoneyInput, trimMoneyDisplay, type MoneyParseError } from '../../../app/money'
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
import { FIELD_SCOPE } from '../formContext'
// Exact-decimal amount control for `type: money` (UI-SPEC F1, D-07 with the
// amended D-09 and D-10). The value is the server's decimal string or null;
// it is never parsed into a number. A loaded value is shown with its
// trailing zeros trimmed to two decimals and the locale's separator, and is
// sent back byte-identical until the administrator edits it. Typing emits
// the canonical dot string on every valid input and null on empty; a
// malformed or too-precise text is emitted as typed (never rounded or
// truncated), so the server's 422 stays the gate, and the error line shows
// on blur. The currency suffix is the field's literal code or the live value
// of the currencyFrom sibling; without a code the suffix is omitted.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
const scope = inject(FIELD_SCOPE, null)
const scale = computed(() => props.field.scale ?? 4)
const currency = computed(() => {
if (props.field.currencyFrom) {
const sibling = scope?.values.value[props.field.currencyFrom]
return typeof sibling === 'string' ? sibling.trim() : ''
}
return props.field.currency ?? ''
})
const readOnly = computed(() => {
const attribute = props.field.attributes?.readonly
return !!props.field.readOnly || (attribute !== undefined && attribute !== null && attribute !== false)
})
const attrs = computed(() => controlAttributes(props.field))
const locale = computed(() => currentLocale.value)
const separator = computed(() => decimalSeparator(locale.value))
const currencyId = computed(() => `${props.controlId}-currency`)
const errorId = computed(() => `${props.controlId}-money-error`)
/** The stored string as the input shows it: trimmed, with the locale's separator. */
function displayOf(value: unknown): string {
if (typeof value !== 'string' || value === '') {
return ''
}
return trimMoneyDisplay(value).replace('.', separator.value)
}
const text = ref(displayOf(props.modelValue))
const error = ref<MoneyParseError | null>(null)
// Errors show on blur, and while typing once one has shown.
const showErrors = ref(false)
let lastEmitted: string | null | undefined
// A value set from outside (a load, a reset after save) replaces the draft;
// the field's own emits do not.
watch(
() => props.modelValue,
(value) => {
if (value !== lastEmitted) {
text.value = displayOf(value)
error.value = null
showErrors.value = false
}
},
)
/** What a draft emits: the canonical string, null, or the raw text when refused. */
function emitted(draft: string): { value: string | null; error: MoneyParseError | null } {
const result = parseMoneyInput(draft, scale.value)
if (result.error === null) {
return result
}
return { value: draft.replace(/[\s ]/g, ''), error: result.error }
}
function onInput(event: Event): void {
text.value = (event.target as HTMLInputElement).value
const next = emitted(text.value)
if (showErrors.value) {
error.value = next.error
}
lastEmitted = next.value
emit('update:modelValue', next.value)
}
function onBlur(): void {
showErrors.value = true
error.value = emitted(text.value).error
}
const invalid = computed(() => !!props.invalid || error.value !== null)
const errorText = computed(() =>
error.value === 'too_precise'
? t('backend::lang.money.too_precise', { scale: scale.value })
: t('backend::lang.money.invalid'),
)
const formatted = computed(() =>
typeof props.modelValue === 'string' && props.modelValue !== ''
? formatMoney(props.modelValue, currency.value, locale.value, scale.value)
: '',
)
const describedBy = computed(() =>
[props.describedBy ?? '', currency.value !== '' ? currencyId.value : '', error.value !== null ? errorId.value : '']
.filter(Boolean)
.join(' '),
)
</script>
<template>
<div
v-if="readOnly"
:id="controlId"
data-money-readonly
:aria-describedby="describedBy || undefined"
class="flex h-input items-center justify-end rounded-control border border-border bg-subtle px-3.5 tabular-nums whitespace-nowrap"
>
<span v-if="formatted !== ''">{{ formatted }}</span>
<span v-else class="text-muted" data-empty>—</span>
</div>
<div v-else class="flex min-w-0 flex-col gap-1.5">
<div class="relative">
<input
:id="controlId"
v-bind="attrs"
:name="field.name"
type="text"
inputmode="decimal"
autocomplete="off"
spellcheck="false"
:value="text"
:required="field.required || undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="[controlClass(invalid), currency !== '' ? 'pr-16' : 'pr-3.5']"
class="h-input text-right tabular-nums"
@input="onInput"
@blur="onBlur"
/>
<span
v-if="currency !== ''"
data-money-currency
aria-hidden="true"
class="absolute inset-y-px right-px flex min-w-[52px] items-center justify-center rounded-r-control border-l border-border bg-subtle px-3 text-muted"
>{{ currency }}</span
>
<span v-if="currency !== ''" :id="currencyId" class="sr-only">{{
t('backend::lang.money.currency', { code: currency })
}}</span>
</div>
<p v-if="error !== null" :id="errorId" data-money-error class="flex items-center gap-1.5 text-[13px] text-danger">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ errorText }}</span>
</p>
</div>
</template>

View File

@@ -28,6 +28,8 @@
// a nested FormGrid, labelled as a group. It renders FormGrid, so it sits on
// an import cycle with this module; only this module imports it, so this
// module always evaluates first and the binding is complete here.
// Phase 18 adds the money control (D-07): an exact decimal string value
// with a currency suffix, formatted by locale only for reading.
import type { Component } from 'vue'
import type { FormField } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
@@ -48,6 +50,7 @@ import SwitchField from './fields/SwitchField.vue'
import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import MarkdownField from './fields/MarkdownField.vue'
import MoneyField from './fields/MoneyField.vue'
import MLTextField from './fields/MLTextField.vue'
import MLTextareaField from './fields/MLTextareaField.vue'
import MLMarkdownField from './fields/MLMarkdownField.vue'
@@ -89,6 +92,7 @@ const renderers = new Map<string, Component>([
['mlmarkdown', MLMarkdownField],
['richeditor', RichEditorField],
['repeater', RepeaterField],
['money', MoneyField],
])
/** Types whose control shows the label itself (toggle cards, relation manager). */

View File

@@ -9,7 +9,7 @@ import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
import { message, t } from '../app/i18n'
import { loadControllerAssets } from '../app/pluginAssets'
import { newSessionKey } from '../app/sessionKey'
import { FORM_SESSION, createUploadGate } from '../components/form/formContext'
import { FIELD_SCOPE, FORM_SESSION, createUploadGate } from '../components/form/formContext'
import FormTabs from '../components/form/FormTabs.vue'
import PreviewField from '../components/form/PreviewField.vue'
import RecordActions from '../components/form/RecordActions.vue'
@@ -193,6 +193,13 @@ function onGone(): void {
// never uploads or saves, so the session only carries the routes.
const { activeUploads, beginUpload } = createUploadGate()
const sessionKey = newSessionKey()
// A money field reads its currencyFrom sibling through the field scope, as
// on a form.
provide(FIELD_SCOPE, {
fields: computed(() => fields.value),
values: computed(() => values.value),
path: (name: string) => name,
})
provide(FORM_SESSION, {
key: sessionKey,
recordId,

View File

@@ -0,0 +1,192 @@
// Money control (UI-SPEC F1, D-07 with the amended D-09 and D-10), the
// component on its own and the money helpers. No application names appear
// in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
import { computed, nextTick, ref } from 'vue'
import type { FormField } from '../../src/api/types'
import { setBundle } from '../../src/app/i18n'
import { formatMoney, parseMoneyInput, trimMoneyDisplay } from '../../src/app/money'
import MoneyField from '../../src/components/form/fields/MoneyField.vue'
import { FIELD_SCOPE, type FieldScope } from '../../src/components/form/formContext'
import { langFixture } from '../fixtures/typed'
import { resetState } from '../helpers'
const strings = {
'backend::lang.money.invalid': { other: 'This is not an amount.' },
'backend::lang.money.too_precise': { other: 'Use at most :scale decimal places.' },
'backend::lang.money.currency': { other: 'Currency: :code' },
}
function moneyField(extra: Partial<FormField> = {}): FormField {
return { name: 'total', type: 'money', label: 'Total', currency: 'PLN', scale: 4, ...extra } as FormField
}
function mountField(props: Record<string, unknown> = {}, values: Record<string, unknown> = {}) {
const field = { ...moneyField(), ...((props.field as Partial<FormField>) ?? {}) } as FormField
const scopeValues = ref(values)
const scope: FieldScope = {
fields: computed(() => [field]),
values: computed(() => scopeValues.value),
path: (name: string) => name,
}
const wrapper = mount(MoneyField, {
props: { modelValue: null, controlId: 'f-total', ...props, field },
global: { provide: { [FIELD_SCOPE as symbol]: scope } },
attachTo: document.body,
})
return { wrapper, scopeValues }
}
const input = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('input')
const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0])
async function type(wrapper: VueWrapper, text: string): Promise<void> {
input(wrapper).element.value = text
await input(wrapper).trigger('input')
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('money helpers', () => {
it('parses typed text into the canonical dot string without a number', () => {
expect(parseMoneyInput('1 234,56', 4)).toEqual({ value: '1234.56', error: null })
expect(parseMoneyInput('1 234.5', 4)).toEqual({ value: '1234.5', error: null })
expect(parseMoneyInput('-12', 2)).toEqual({ value: '-12', error: null })
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' })
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' })
expect(parseMoneyInput('.5', 4)).toEqual({ value: null, error: 'invalid' })
// A 20-digit amount survives: no float is involved.
expect(parseMoneyInput('12345678901234567890.1234', 4)).toEqual({ value: '12345678901234567890.1234', error: null })
})
it('trims trailing zeros down to two decimals for display', () => {
expect(trimMoneyDisplay('39.8400')).toBe('39.84')
expect(trimMoneyDisplay('85.4193')).toBe('85.4193')
expect(trimMoneyDisplay('5.0000')).toBe('5.00')
expect(trimMoneyDisplay('5.0100')).toBe('5.01')
expect(trimMoneyDisplay('7')).toBe('7')
expect(trimMoneyDisplay('7.5')).toBe('7.5')
})
it('formats through Intl with the string itself, keeping four decimals', () => {
expect(formatMoney('99999999.9999', 'PLN', 'en', 4)).toBe('PLN 99,999,999.9999')
expect(formatMoney('99999999.9999', 'PLN', 'pl', 4).replace(/\s/g, ' ')).toBe('99 999 999,9999 PLN')
expect(formatMoney('39.8400', 'PLN', 'en', 4)).toBe('PLN 39.84')
expect(formatMoney('39.8400', 'EUR', 'pl', 2).replace(/\s/g, ' ')).toBe('39,84 EUR')
expect(formatMoney('12.5', '', 'en', 4)).toBe('12.50')
})
})
describe('money field component (UI-SPEC F1)', () => {
it('is a text input in decimal mode with the currency suffix', () => {
const { wrapper } = mountField()
const el = input(wrapper)
expect(el.attributes('type')).toBe('text')
expect(el.attributes('inputmode')).toBe('decimal')
expect(el.attributes('autocomplete')).toBe('off')
expect(el.attributes('spellcheck')).toBe('false')
expect(el.classes()).toContain('pr-16')
expect(el.classes()).toContain('text-right')
expect(el.classes()).toContain('tabular-nums')
const suffix = wrapper.find('[data-money-currency]')
expect(suffix.text()).toBe('PLN')
expect(suffix.attributes('aria-hidden')).toBe('true')
expect(wrapper.find('#f-total-currency').text()).toBe('Currency: PLN')
expect(el.attributes('aria-describedby')).toContain('f-total-currency')
})
it('shows a loaded value trimmed, with the locale separator, and does not emit', async () => {
const { wrapper } = mountField({ modelValue: '39.8400' })
expect(input(wrapper).element.value).toBe('39.84')
expect(emitted(wrapper)).toEqual([])
setBundle({ ...langFixture.data, ...strings }, 'pl')
const { wrapper: polish } = mountField({ modelValue: '85.4193' })
expect(input(polish).element.value).toBe('85,4193')
expect(emitted(polish)).toEqual([])
})
it('submits an untouched 85.4193 byte-identical: no emit on load or blur', async () => {
const { wrapper } = mountField({ modelValue: '85.4193' })
await input(wrapper).trigger('focus')
await input(wrapper).trigger('blur')
expect(emitted(wrapper)).toEqual([])
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
})
it('emits the canonical dot string for "1 234,56" and null for empty', async () => {
const { wrapper } = mountField({ modelValue: '1.00' })
await type(wrapper, '1 234,56')
expect(emitted(wrapper)).toEqual(['1234.56'])
await type(wrapper, '')
expect(emitted(wrapper)).toEqual(['1234.56', null])
})
it('refuses 12.34567 at scale 4 on blur, neither rounded nor truncated', async () => {
const { wrapper } = mountField({ modelValue: null })
await type(wrapper, '12.34567')
// Nothing is shown before blur, and no rounded value was emitted.
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
expect(emitted(wrapper)).toEqual(['12.34567'])
await input(wrapper).trigger('blur')
const line = wrapper.find('[data-money-error]')
expect(line.text()).toBe('Use at most 4 decimal places.')
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
expect(input(wrapper).classes()).toContain('border-danger')
expect(input(wrapper).attributes('aria-describedby')).toContain('f-total-money-error')
expect(emitted(wrapper)).toEqual(['12.34567'])
// Fixing the text clears the line while typing.
await type(wrapper, '12.3456')
expect(wrapper.find('[data-money-error]').exists()).toBe(false)
expect(emitted(wrapper)).toEqual(['12.34567', '12.3456'])
})
it('shows money.invalid for a second separator', async () => {
const { wrapper } = mountField()
await type(wrapper, '1.234,56')
await input(wrapper).trigger('blur')
expect(wrapper.find('[data-money-error]').text()).toBe('This is not an amount.')
})
it('follows the currencyFrom sibling and omits the suffix without a code', async () => {
const { wrapper, scopeValues } = mountField({ field: { currency: undefined, currencyFrom: 'currency' } }, { currency: '' })
expect(wrapper.find('[data-money-currency]').exists()).toBe(false)
expect(input(wrapper).classes()).toContain('pr-3.5')
expect(input(wrapper).attributes('aria-describedby')).toBeUndefined()
scopeValues.value = { currency: 'EUR' }
await nextTick()
expect(wrapper.find('[data-money-currency]').text()).toBe('EUR')
expect(input(wrapper).classes()).toContain('pr-16')
})
it('renders a read-only box with the formatted amount or an em dash', () => {
const { wrapper } = mountField({ modelValue: '39.8400', field: { readOnly: true } })
const box = wrapper.find('[data-money-readonly]')
expect(box.exists()).toBe(true)
expect(box.text()).toBe('PLN 39.84')
expect(box.classes()).toContain('tabular-nums')
expect(wrapper.find('input').exists()).toBe(false)
const { wrapper: empty } = mountField({ modelValue: null, field: { attributes: { readonly: true } } })
expect(empty.find('[data-money-readonly] [data-empty]').text()).toBe('—')
})
it('replaces the draft when the value is set from outside', async () => {
const { wrapper } = mountField({ modelValue: '1.00' })
await type(wrapper, '2')
await wrapper.setProps({ modelValue: '5.0000' })
expect(input(wrapper).element.value).toBe('5.00')
})
})

View File

@@ -103,12 +103,13 @@ for _, f := range form.Fields {
| `mlmarkdown` | A markdown field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
| `richeditor` | A WYSIWYG HTML editor for a host text column, sanitized on every save; see [Rich text](#rich-text). |
| `repeater` | A list of rows, each with its own small form, stored as JSON in one column; see [Repeater](#repeater). |
| `money` | An exact decimal amount with a currency suffix, never a float and never rounded; see [Money](#money). |
The WinterCMS widgets that are not in this list (the code editor, the color picker, the media finder, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
### Field options
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields and `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)). WinterCMS keys outside this set, such as `readOnly`, `disabled` or `trigger`, are refused.
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields, `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)) and `currency`, `currencyFrom`, `scale` and `max` on money fields (see [Money](#money)). WinterCMS keys outside this set, such as `readOnly`, `disabled` or `trigger`, are refused.
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
@@ -458,6 +459,51 @@ In the admin SPA the field is a TipTap editor with a toolbar for bold, italic, u
- **After a save** the notices appear under the field and the editor shows the sanitized value the server stored.
- If the editor code cannot be loaded, the field falls back to the HTML view, so the record can still be edited and saved.
## Money
`type: money` edits an exact decimal amount. The value travels as a decimal string with a dot and at most `scale` fraction digits (`"39.8400"`, `"85.4193"`, `"-12.5"`), or `null`. Neither the server nor the admin SPA ever parses it into a float: the string is checked as text, handed to the model column unchanged, and formatted for reading with `Intl.NumberFormat` from the string itself. A value is never rounded or truncated: one with too many decimals is refused.
The column is 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 over a `numeric(12,4)` column. Any other column type stops the start-up naming the field.
```yaml
fields:
currency:
label: acme.shop::lang.orders.currency
type: text
total:
label: acme.shop::lang.orders.total
type: money
currencyFrom: currency
max: "99999999.9999"
fee:
label: acme.shop::lang.orders.fee
type: money
currency: EUR
scale: 2
```
The field takes the generic keys plus:
| Key | Meaning |
|-----|---------|
| `currency` | A literal three-letter upper-case ISO 4217 code shown as the input's suffix. |
| `currencyFrom` | The name of a string column of the same model that holds the code; the suffix follows that field's current value on the form. Exactly one of `currency` and `currencyFrom` is required: both, or neither, stop the start-up naming the field. |
| `scale` | The most fraction digits a value may carry: 2, 3 or 4 (the default). |
| `max` | The column ceiling as a decimal string with at most `scale` places, for example `"999999.9999"` for a `numeric(10,4)` column. Optional. |
`options`, `emptyOption`, `nameFrom`, `dependsOn` and `preset` are refused on a money field, and the four money keys are refused on every other type.
A save checks every submitted money value before the transaction and answers a 422 on the field with one of three `backend::lang.money` messages:
| 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` |
| 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.
In the admin SPA the field is a text input in decimal mode, right-aligned in tabular numerals, with the ISO code as a suffix (omitted while a `currencyFrom` sibling is still empty). A loaded value is shown with its trailing zeros trimmed down to two decimals and the locale's decimal separator; typing accepts a comma or a dot and ignores spaces, and emits the canonical dot string. The error line shows on blur for a malformed or too-precise text, and the text is still sent as typed so the server's answer is the gate. Read-only and preview show the locale-formatted amount with the code (`PLN 39.84`, `39,84 PLN`) or an em dash when empty.
## What a save may write
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are the exception: they are lifted first, as [Markdown and multilingual fields](#markdown-and-multilingual-fields). A [repeater](#repeater)'s list of rows is the other exception: it is decoded row by row before the projection. The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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,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>

View File

@@ -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. |

View File

@@ -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

View 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
}

View File

@@ -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
}

View File

@@ -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

View 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{} }

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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] {

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,4 @@
name: New invoice
form: ~/plugins/acme/payments/models/invoice/fields.yaml
modelClass: Invoice
defaultRedirect: acme/payments/invoices

View 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]

View File

@@ -0,0 +1,6 @@
columns:
number:
label: Number
searchable: true
currency:
label: Currency

View 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

View File

@@ -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.

View File

@@ -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.