diff --git a/.changeset/27189-record-currency-skills.md b/.changeset/27189-record-currency-skills.md new file mode 100644 index 00000000000..0c9820cd15b --- /dev/null +++ b/.changeset/27189-record-currency-skills.md @@ -0,0 +1,13 @@ +--- +'@objectstack/skills': patch +--- + +docs(skills): the dashboards guide says how a record-currency amount behaves in a dataset measure; the code field's name is a convention + +Clause-②: no +Metadata-protocol: none + +⛔ Not released alone: this ships in the same release as the `currencyMode: 'record'` mode, its refusals and its per-row display (ADR-0144 D9, release R1). + +- **`objectstack-ui/rules/dashboards.md`**: a measure over a `currencyMode: 'record'` amount refuses a measure `currency` and an ungrouped `sum` / `avg` / `min` / `max`. Grouped by the amount's code field, its result column carries `currencyField`, which names the per-row code column, and a `count` column never does. +- **`objectstack-data/rules/field-types.md`**: a record-mode amount's code field is named `currency_code` by convention, not by contract; any `text` field with `valueDomain: 'iso_4217_currency'` that `currencyField` names will do. diff --git a/content/docs/data-modeling/currency.mdx b/content/docs/data-modeling/currency.mdx new file mode 100644 index 00000000000..ebd68eb13df --- /dev/null +++ b/content/docs/data-modeling/currency.mdx @@ -0,0 +1,202 @@ +--- +title: Currency fields — dynamic, fixed and record modes +navTitle: Currency +description: How a currency amount gets its currency — the tenant default, one fixed code, or each record's own ISO 4217 code in a sibling field — and the writes, aggregates and declarations the platform refuses for a record-currency amount +--- + +A `currency` field stores a **bare number** in every mode, never a `{ value, currency }` +object (ADR-0104 D1). What the field's `currencyConfig` decides is where that number's +currency comes from. There are three modes, and the record mode is the one to use when +different records of the same object are in different currencies: an invoice in USD next +to an invoice in EUR (ADR-0144). + +## The three modes + +| `currencyMode` | The amount's currency | Keys it reads | +|:---|:---|:---| +| `dynamic` (the default) | The tenant default currency, the `localization.currency` setting. With no setting, the amount is a plain number with no symbol. | none — `defaultCurrency` is not read | +| `fixed` | One currency for the whole column. | `defaultCurrency`, required | +| `record` | Each record's own currency: the ISO 4217 code stored in a sibling field of the same record. | `currencyField`, required | + +```typescript +// dynamic: displays in the organization's currency +budget: Field.currency({ label: 'Budget' }), + +// fixed: every row is in USD +list_price: Field.currency({ + label: 'List Price', + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, +}), + +// record: every row names its own currency in `currency_code` +amount: Field.currency({ + label: 'Amount', + currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }, +}), +``` + +Pick by asking what the currency belongs to. If it belongs to the organization, use +`dynamic`. If it belongs to the column, such as a price list kept in one currency, use +`fixed`. If it belongs to the document, such as an invoice billed in the customer's +currency, use `record`. + +The modes do not mix in one config. `fixed` needs its code and `record` needs its pointer. +Nothing turns multi-currency on for an organization or a deployment: declaring +`currencyMode: 'record'` on a field is the switch, and the field is the only place it +applies. + +## The record mode + +### The code field + +A record-mode amount names, through `currencyConfig.currencyField`, a field **on the same +object** that holds each record's currency. That field must be a `text` field declaring +`valueDomain: 'iso_4217_currency'`, so every code written to it is checked against the +ISO 4217 list. + +```typescript +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +export const Invoice = ObjectSchema.create({ + name: 'invoice', + label: 'Invoice', + fields: { + name: Field.text({ label: 'Number', required: true }), + currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }), + amount: Field.currency({ + label: 'Amount', + currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }, + }), + tax: Field.currency({ + label: 'Tax', + currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }, + }), + }, +}); +``` + +`currency_code` is a **convention, not a contract**. The platform never injects a code +column and never looks one up by name: the code field is whatever field you declare and +point at. Several amounts may share one code field, as `amount` and `tax` do above, and an +object may carry two code fields when a record really holds amounts in two currencies. + +A `formula` field with `returnType: 'currency'` that computes from record-mode amounts +carries the same config, `{ currencyMode: 'record', currencyField: … }`, naming the code +field its source amounts name. `inferFormulaReturn` in `@objectstack/formula` answers it +for a formula it can prove is money. + +### What the declaration refuses + +`ObjectSchema.create()`, `os validate` and publish refuse each of these, with the fix in the +message: + +| Declaration | Refused at | Fix | +|:---|:---|:---| +| `currencyMode: 'record'` with no `currencyField` | `currencyConfig.currencyField` | Name the code field, or use `fixed` (one currency) or `dynamic` (the tenant default). | +| `currencyField` under `dynamic` or `fixed` | `currencyConfig.currencyField` | Set `currencyMode: 'record'`, or delete `currencyField`. | +| `defaultCurrency` under `record` | `currencyConfig.defaultCurrency` | Delete `defaultCurrency`, or use `fixed` if the field has one currency. | +| `currencyField: ''` | `currencyConfig.currencyField` | Name a field. | +| `currencyField` names no other field of the object, or names the amount itself | `fields..currencyConfig.currencyField` | Point at a declared code field. The message lists the object's candidates. | +| `currencyField` names a field that is not `text` | same | Point at a `text` field. | +| `currencyField` names a `text` field without `valueDomain: 'iso_4217_currency'` | same | Add `valueDomain: 'iso_4217_currency'` to that field. | + +### Writing a record + +When a record-mode amount holds a value, its code field must hold a code in the same +write. An insert, or an update that leaves the amount beside a blank code (absent, `null` +or white space only), is refused with **`ERR_CURRENCY_CODE_REQUIRED` / 400** and nothing +is written. The error names the code field in `field` and the amount in `amountField`. +Fix it by writing the record's code, for example `'USD'`, in the same write, or by leaving +the amount empty. + +- A blank amount needs no code. +- On an update, only the amount and code pairs the payload touches are judged, so editing + an unrelated field of an older row is not refused. +- A code that is not an ISO 4217 code is refused by the code field's own `valueDomain`, + not by this rule. + +**The platform never fills in a code.** No server default, no tenant fallback and no +"most recent currency" is ever stored on your behalf. In the Console, a create form opens +with the code field pre-filled with the organization's currency, and the user submits it +as an explicit value. A `defaultValue` you declare on the code field yourself is an +ordinary field default: the engine applies it before the check, so it satisfies the +rule, because you wrote it. + +### Aggregating a record-mode amount + +Amounts in different currencies do not add. A `sum`, `avg`, `min` or `max` over a +record-mode amount is refused with **`INVALID_FIELD` / 400** unless the same query groups +by the amount's code field, and the query is not run. Grouped by the code, every group +holds one currency and each figure is right. + +```typescript +// Refused: USD 100 and EUR 100 would answer 200 under one label. +{ aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }] } + +// Served: one total per currency. +{ groupBy: ['currency_code'], aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }] } +``` + +The rule holds at two doors: + +- **The ObjectQL aggregate**, so it covers every caller that reaches the engine: the REST + query API, flows, hooks and the analytics strategy. +- **The analytics cube door**, ahead of strategy selection: dataset queries (the main + grid, every `totals` grouping including the grand total, and the `compareTo` pass), + cube queries over a declared or inferred cube, and the dataset draft preview. + +A `count` reads no amount and is always served, grouped or not, and so is +`count_distinct`. A `where` that happens to select one currency does not count as +grouping: the rule is the grouping alone. + +In an analytics answer, a measure column over a record-mode amount, grouped by the code, +carries **`currencyField`** instead of `currency`. It names the dimension column of the +same answer that groups by the code field, and each row's currency is that row's value of +that column. A `count` column never carries `currencyField`. See the +[analytics result columns](/docs/api/data-api) for the full `fields[]` surface. + +### Roll-ups and datasets + +- **A `summary` roll-up** whose `sum`, `avg`, `min` or `max` reads a record-mode amount of + the child object is refused when the object registers, with **`INVALID_METADATA` / + 422**. A roll-up stores one number with no currency, so child rows in different + currencies would be added together. Use `function: 'count'`. The pair is judged at + whichever registration completes it, the parent's or the child's. +- **A dataset measure that declares `currency` over a record-mode amount** is refused at + compile with **`DATASET_INVALID` / 400**, and so is a `currency` on a derived measure that + sums record-mode amounts. One code cannot label amounts in several currencies: delete + the measure's `currency` and select the dimension over the code field, so each row + carries its own. +- **A derived measure that combines a record-mode amount with an amount in another + currency** (another code field, a `fixed` code, an explicit measure code or the tenant + currency) is refused at compile with `DATASET_INVALID` / 400, whatever the operation. + A derived measure over a `count`, such as an amount per invoice, stays legal. + +### In the Console + +Each record-mode amount shows the symbol and minor-unit decimals of its own record's code. +A record whose code is blank shows a plain number. Grouped analytics answers do the same +per row: the dashboard table, the report table and its export, and the Studio dataset +preview each take a row's currency from its code column. A place that spans many records +and is not grouped by the code has no one currency to show: the grid footer draws no +`sum` / `avg` / `min` / `max` for that column, a group header draws them only when the +grouping includes the code field, a report's grand total on such a column shows no +symbol, and the counts always stand. + +## The home currency + +The organization's home currency is the existing `localization.currency` setting. +**Metadata never names it**: no field, object or dataset declares the home currency, and a +`dynamic` amount displays in it because that is what `dynamic` means. + +Consolidating record-mode amounts into one home-currency total, through dated exchange +rates and a stored home amount, comes in a later release. Until then, total a record-mode +amount per currency by grouping by its code field. + +## See also + +- [Field Type Gallery: `currency`](/docs/data-modeling/field-types#currency) — the field's + properties and decimal places. +- [Validation rules: `currency`](/docs/data-modeling/validation-rules#currency) — the value + shape a write is judged against. +- [Analytics](/docs/data-modeling/analytics) — datasets, measures and dimensions. diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index f9aa290fe02..f53e9fcf8fb 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -206,16 +206,19 @@ The input is a native number box, so the browser filters what is typed. See [What a number input silently rewrites](#what-a-number-input-silently-rewrites). ### `currency` -Monetary value. The stored value is a bare number in both modes; the currency -code lives in `currencyConfig`, never in the value. +Monetary value. The stored value is a bare number in every mode; the currency +code lives in `currencyConfig`, or under `record` in a sibling code field, never in +the value. See [Currency fields](/docs/data-modeling/currency) for the three modes and +what a record-currency amount refuses. | Property | Type | Default | Description | |:---|:---|:---|:---| | `precision` | `number` | — | Total digits | | `min` | `number` | — | Minimum value | | `max` | `number` | — | Maximum value | -| `currencyConfig.currencyMode` | `'dynamic' \| 'fixed'` | `'dynamic'` | Whether the column is pinned to `defaultCurrency` (`fixed`) or displays in the tenant default currency, the `localization.currency` setting (`dynamic`); the value is a bare number either way | -| `currencyConfig.defaultCurrency` | `string` | — | The column's currency code under `fixed`, where it is required; not the displayed currency under `dynamic` | +| `currencyConfig.currencyMode` | `'dynamic' \| 'fixed' \| 'record'` | `'dynamic'` | Whether the column is pinned to `defaultCurrency` (`fixed`), displays in the tenant default currency, the `localization.currency` setting (`dynamic`), or takes each record's own code from the field `currencyField` names (`record`); the value is a bare number in every mode | +| `currencyConfig.defaultCurrency` | `string` | — | The column's currency code under `fixed`, where it is required; not the displayed currency under `dynamic`; refused under `record` | +| `currencyConfig.currencyField` | `string` | — | `record` only, and required there: the field on the same object holding each record's ISO 4217 code, a `text` field with `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`); refused under `dynamic` and `fixed` | ```typescript { name: 'price', label: 'Price', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } } @@ -233,7 +236,8 @@ amount declares `precision: 18` — and is never compared with the currency. **In the Console:** a numeric input with the currency's symbol. The read-only display, the grid cell, the input's `step` and the rounding applied when the input loses focus all use the currency's minor unit, so a JPY amount shows no decimals and a KWD amount shows three. With no -currency resolved at all, an amount shows two decimals. A field-level `currency` key is +currency resolved at all, an amount shows two decimals. A `record`-mode amount resolves only +its own record's code, never the tenant default. A field-level `currency` key is refused: the currency is `currencyConfig`. The input is a native number box; see [What a number input silently rewrites](#what-a-number-input-silently-rewrites). @@ -671,7 +675,7 @@ Calculated field using a CEL expression (see [Expressions](/docs/data-modeling/f | Property | Type | Default | Description | |:---|:---|:---|:---| | `expression` | `string \| Expression` | **required** | CEL calculation expression | -| `returnType` | `'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'` | — | Declared value type of the computed result. `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares (none: dynamic) | +| `returnType` | `'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'` | — | Declared value type of the computed result. `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares (none: dynamic; over record-mode amounts, `record` with their `currencyField`) | ```typescript { name: 'total', label: 'Total', type: 'formula', expression: 'record.quantity * record.unit_price', returnType: 'number' } @@ -704,6 +708,11 @@ Roll-up summary from child records. | `summaryOperations.relationshipField` | `string` | auto | Child FK back to the parent when it cannot be inferred | | `summaryOperations.filter` | `FilterCondition` | — | Only child rows matching this `where` predicate are aggregated | +A `sum`, `avg`, `min` or `max` over a child amount declared `currencyMode: 'record'` is +refused when the object registers (`INVALID_METADATA` / 422): child rows in different +currencies would be added into one number. A `count` is served +([Currency fields](/docs/data-modeling/currency#roll-ups-and-datasets)). + ```typescript { name: 'task_count', diff --git a/content/docs/data-modeling/fields.mdx b/content/docs/data-modeling/fields.mdx index 197a75c4ed5..9294d6e9554 100644 --- a/content/docs/data-modeling/fields.mdx +++ b/content/docs/data-modeling/fields.mdx @@ -75,16 +75,29 @@ price: Field.currency({ label: 'Price', currencyConfig: { currencyMode: 'fixed', // 'fixed' pins the column to defaultCurrency; - // 'dynamic' (default) shows the tenant default + // 'dynamic' (default) shows the tenant default; + // 'record' reads each record's own code defaultCurrency: 'USD', // the column's currency under 'fixed' }, }), + +// Each invoice in its own currency: the code lives in a sibling text field +currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }), +amount: Field.currency({ + label: 'Amount', + currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' }, +}), ``` -The stored value is a bare number in both modes — never `{ value, currency }`. +The stored value is a bare number in every mode — never `{ value, currency }`. Under `dynamic` (the default) the amount displays in the tenant's `localization.currency` setting and `defaultCurrency` is not the displayed -currency; `fixed` pins the column to `defaultCurrency`. +currency; `fixed` pins the column to `defaultCurrency`; `record` takes each +record's ISO 4217 code from the field `currencyField` names, a `text` field +declaring `valueDomain: 'iso_4217_currency'` (`currency_code` is a convention, +not a required name). A record-mode amount refuses a write with a blank code and +an aggregate that does not group by the code: see +[Currency fields](/docs/data-modeling/currency). ### Date & Time Types diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index 1223899faf3..bb5af8cb805 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -86,6 +86,10 @@ Formatters: `currency[:CODE]`, `number[:decimals]`, `percent[:decimals]`, `localization.currency` setting, the one currency fields and dashboards read); when the organization has none, the render refuses the hole by name instead of guessing a currency. Write `currency:EUR` where the amount's currency is fixed. +For a `record`-mode amount, whose currency is each record's own code, a mail +template writes `currency:@currency_code`: `@` and a path into the send's data +whose value is the code. Only mail templates admit the `@` form; the in-app +dialect refuses it by name. At **input** time you may write a bare string for shorthand — the spec transforms it into the right envelope based on the field type. The compiled @@ -133,7 +137,9 @@ yourself — nothing back-fills it — but the agent-callable `validate_expressi tool reports the type it infers from the expression (`inferredType`) so an AI author can stamp the matching value. A `'currency'` result is an amount of money and carries its currency the way a currency field does: in the formula's own -`currencyConfig` (none means dynamic, the tenant default currency). Copy it from +`currencyConfig` (none means dynamic, the tenant default currency; over +record-mode amounts it is `{ currencyMode: 'record', currencyField }`, naming the +same code field). Copy it from the currency field the formula computes from — `inferFormulaReturn` in `@objectstack/formula` answers both halves for a formula it can prove is money. Consumers read the declared `returnType` instead of re-parsing the expression (record-title eligibility, for one: a diff --git a/content/docs/data-modeling/index.mdx b/content/docs/data-modeling/index.mdx index 4fe1afd22a6..e5025e0e6e8 100644 --- a/content/docs/data-modeling/index.mdx +++ b/content/docs/data-modeling/index.mdx @@ -49,6 +49,7 @@ That one definition is enough to get a persisted table, CRUD + query endpoints, + diff --git a/content/docs/data-modeling/meta.json b/content/docs/data-modeling/meta.json index dd135dad62c..75c75e57905 100644 --- a/content/docs/data-modeling/meta.json +++ b/content/docs/data-modeling/meta.json @@ -6,6 +6,7 @@ "fields", "field-types", "field-type-decision-tree", + "currency", "relationships", "object-extensions", "validation", diff --git a/content/docs/data-modeling/validation-rules.mdx b/content/docs/data-modeling/validation-rules.mdx index 5a1db3dd941..63ffe0a35ff 100644 --- a/content/docs/data-modeling/validation-rules.mdx +++ b/content/docs/data-modeling/validation-rules.mdx @@ -152,10 +152,11 @@ These properties apply to **all** field types and are validated by the base `Fie |:---|:---|:---|:---| | `min` | `number` | — | Minimum monetary value | | `max` | `number` | — | Maximum monetary value | -| `currencyConfig.currencyMode` | `enum` | `dynamic` | `fixed` pins the field to `defaultCurrency`; `dynamic` displays the tenant default currency (the `localization.currency` setting). Not a per-record choice | -| `currencyConfig.defaultCurrency` | `string` | — | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`, where it is required (a `fixed` config without one is refused); not the displayed currency under `dynamic` | +| `currencyConfig.currencyMode` | `enum` | `dynamic` | `fixed` pins the field to `defaultCurrency`; `dynamic` displays the tenant default currency (the `localization.currency` setting); `record` takes each record's own currency from the code field `currencyField` names | +| `currencyConfig.defaultCurrency` | `string` | — | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`, where it is required (a `fixed` config without one is refused); not the displayed currency under `dynamic`; refused under `record` | +| `currencyConfig.currencyField` | `string` | — | Required under `record` and refused under the other two modes: a `text` field of the same object declaring `valueDomain: 'iso_4217_currency'` (the object refuses a pointer at anything else). A write that leaves a record-mode amount holding a value beside a blank code is refused, `ERR_CURRENCY_CODE_REQUIRED` / 400 | -**Default constraints:** Stored as a **bare number** (a finite numeric scalar — `valueSchemaFor` routes `currency` to `z.number().finite()`); there is no `{ value, currency }` envelope on the value path. The currency **code** is never stored per record: it is field configuration, carried by `currencyConfig` above. Decimal places are the currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) and are declared nowhere — `currencyConfig` has no decimal-places key (`currencyConfig.precision` is refused, with a prescription to delete it), and the field-level `precision` is the total digit count, never compared with the currency. +**Default constraints:** Stored as a **bare number** (a finite numeric scalar — `valueSchemaFor` routes `currency` to `z.number().finite()`); there is no `{ value, currency }` envelope on the value path. The amount never carries its currency code: under `dynamic` and `fixed` the code is field configuration, carried by `currencyConfig` above, and under `record` it is the value of the record's own code field, an ordinary `text` column judged by its `valueDomain` ([Currency fields](/docs/data-modeling/currency)). Decimal places are the currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD) and are declared nowhere — `currencyConfig` has no decimal-places key (`currencyConfig.precision` is refused, with a prescription to delete it), and the field-level `precision` is the total digit count, never compared with the currency. ### `percent` @@ -533,7 +534,7 @@ section above). See the | `html` | — | Sanitized, `maxLength` | | `richtext` | — | Sanitized, `maxLength` | | `number` | — | `min`, `max`, `precision`, `scale` | -| `currency` | — | `currencyConfig` (mode, code) | +| `currency` | — | `currencyConfig` (mode, code or code field) | | `percent` | — | `min`, `max`, stored as decimal | | `date` | — | ISO 8601 date | | `datetime` | — | ISO 8601 datetime, UTC | diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx index eafa8e74776..41fbb71879e 100644 --- a/content/docs/protocol/objectql/schema.mdx +++ b/content/docs/protocol/objectql/schema.mdx @@ -287,11 +287,12 @@ fields: | `description` | `string` | All | Tooltip/Help text. | | `maxLength` | `number` | `text`, `textarea` | Maximum character length. | | `minLength` | `number` | `text`, `textarea` | Minimum character length. | -| `valueDomain` | `string` | `text` | Standard the written value must belong to: `iana_time_zone`, `iso_4217_currency` or `iso_3166_alpha2` (the closed vocabulary shared with settings specifiers). | +| `valueDomain` | `string` | `text` | Standard the written value must belong to: `iana_time_zone`, `iso_4217_currency` or `iso_3166_alpha2` (the closed vocabulary shared with settings specifiers). A `record`-mode `currency` amount's code field is a `text` field declaring `iso_4217_currency` ([Currency fields](/docs/data-modeling/currency)). | | `min` | `number` | `number`, `currency` | Minimum numeric value. | | `max` | `number` | `number`, `currency` | Maximum numeric value. | | `scale` | `number` | `number`, `percent`, `rating`, `slider` | Decimal places, enforced on writes. Refused on `currency` — delete it there: a currency amount's decimal places are its currency's, so its ISO 4217 minor unit decides the display. | | `precision` | `number` | `number`, `currency` | Total digits (including scale). | +| `currencyConfig` | `object` | `currency` | Where the amount's currency comes from: `currencyMode` `dynamic` (the tenant default, the default), `fixed` (with `defaultCurrency`) or `record` (with `currencyField`, the sibling code field). | | `options` | `array` | `select`, `multiselect` | List of valid values. | | `multiple` | `boolean` | `select`, `lookup` | Allow multiple selections. | | `reference` | `string` | `lookup`, `master_detail` | Target object for relationships. | diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 252f11723bd..e2b7e8bfd44 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -27,9 +27,10 @@ revenue: label: Annual Revenue precision: 18 # Automatically knows: - # - Store a bare number, no per-record currency code: it shows in the - # tenant default currency (plain if none is set) or one fixed on the - # field, at that currency's ISO 4217 decimal places + # - Store a bare number, never a currency code beside it: it shows in + # the tenant default currency (plain if none is set), one fixed on the + # field, or each record's code in a declared sibling field, at that + # currency's ISO 4217 decimal places # - UI shows currency symbol # - Validate numeric precision # - Format for display ($1,234.56) @@ -288,9 +289,11 @@ quantity: --- #### `currency` -Money amount, stored as a bare number. The currency is field configuration, -never stored per value: `defaultCurrency` under `currencyMode: fixed`, or the -tenant default currency under `dynamic`, the default. +Money amount, stored as a bare number. The currency is never stored in the +value: `defaultCurrency` under `currencyMode: fixed`, the tenant default +currency under `dynamic`, the default, or under `record` each record's ISO 4217 +code in the sibling `text` field `currencyField` names +([Currency fields](/docs/data-modeling/currency)). ```yaml annual_revenue: @@ -299,6 +302,16 @@ annual_revenue: currencyConfig: currencyMode: fixed defaultCurrency: USD # the field's currency under fixed + +# record mode: each row's currency is its own currency_code value +currency_code: + type: text + valueDomain: iso_4217_currency +amount: + type: currency + currencyConfig: + currencyMode: record + currencyField: currency_code ``` **Storage:** a **bare number** — the same value shape as `number`. @@ -309,19 +322,23 @@ annual_revenue: A currency value is **not** a `{ value, currency }` object. The currency *code* -is field configuration, never stored per value: under `currencyMode: fixed` it is +is never stored in the value: under `currencyMode: fixed` it is `currencyConfig.defaultCurrency` (the field's one currency, as `fixed` pins it); under `dynamic`, the default, it is the tenant default currency (the `localization.currency` setting) and `defaultCurrency` is not the displayed -currency. The old per-value object shape (`CurrencyValueSchema`) was +currency; under `record` it is the value of the record's own code field, an +ordinary `text` column the author declares and points at with `currencyField`. The old per-value object shape (`CurrencyValueSchema`) was never consumed by the validator, the driver, or import coercion and has been removed from the spec. **Features:** -- `currencyMode: fixed | dynamic` and a `defaultCurrency` code on the field -- Codes are validated by **length only** (3 characters), so ISO 4217 (`USD`, - `EUR`, `CNY`) and non-ISO codes (`BTC`, `ETH`) both pass +- `currencyMode: dynamic | fixed | record`; a `defaultCurrency` code on the + field under `fixed`, a `currencyField` pointer under `record` +- A `fixed` field's `defaultCurrency` code is validated by **length only** (3 characters), so + ISO 4217 (`USD`, `EUR`, `CNY`) and non-ISO codes (`BTC`, `ETH`) both pass. A + record's code is judged by its field's `valueDomain: iso_4217_currency`, so + only ISO 4217 codes pass there - No decimal-places setting: an amount's decimal places are its currency's ISO 4217 minor unit. The field-level `precision` is the amount's **total** digit count (a DECIMAL(18,2) amount declares `precision: 18`), refused on @@ -330,7 +347,8 @@ been removed from the spec. **Database mapping:** - SQL driver: the same **exact decimal** column as `number` (`NUMERIC(65,30)` / `DECIMAL(65,30)`; `float` on SQLite) — one column, no - companion currency column and no JSON blob. Money is the member where the + companion currency column the platform adds and no JSON blob (a `record`-mode + amount's code field is a column the author declares). Money is the member where the binary32 `REAL` this replaced lost a correctness question rather than a display one: `1234567.89` read back `1234567.9` from a `REAL` column. ⚠️ The column is **not** a blanket `DECIMAL(18,2)`: the platform's own CLDR diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index 810521ac7d0..9ce1b9ca99d 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -75,6 +75,7 @@ "content/docs/concepts/ontology.mdx", "content/docs/concepts/when-to-use.mdx", "content/docs/data-modeling/analytics.mdx", + "content/docs/data-modeling/currency.mdx", "content/docs/data-modeling/drivers.mdx", "content/docs/data-modeling/external-datasources.mdx", "content/docs/data-modeling/field-type-decision-tree.mdx", diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 780dc0ed9b7..1b5701f7629 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -181,7 +181,7 @@ Stored as JSON on the parent row — no separate table / FK: `{ value, currency }`. Shown: `'fixed'`, `defaultCurrency`; `'dynamic'` (default), the tenant `localization.currency`, else a plain number (never `$`); `'record'`, each record's code in the `text` field `currencyField` -names, declaring `valueDomain: 'iso_4217_currency'`. Under +names (by convention `currency_code`), with `valueDomain: 'iso_4217_currency'`. Under `'fixed'`/`'dynamic'` a measure's own `currency` wins. ### Select with Default diff --git a/skills/objectstack-ui/rules/dashboards.md b/skills/objectstack-ui/rules/dashboards.md index 8166dbdc358..d12325d0fea 100644 --- a/skills/objectstack-ui/rules/dashboards.md +++ b/skills/objectstack-ui/rules/dashboards.md @@ -295,12 +295,13 @@ defineDataset({ dimensions: [{ name: 'signed_date', field: 'signed_date', type: 'date', dateGranularity: 'month' }], measures: [{ name: 'signed_count', aggregate: 'count' }], }); -// A monetary measure may declare `currency` (ISO 4217) for a locale-correct -// symbol: `{ name: 'revenue', aggregate: 'sum', field: 'amount', currency: 'USD' }`. -// It resolves measure `currency` → the aggregated field's -// `currencyConfig.defaultCurrency` (only under `currencyMode: 'fixed'`) → the -// tenant `localization.currency` (`CurrencyConfigSchema`'s `currencyMode` -// describe). Omit it for non-money measures (count, avg-of-hours). +// A money measure may set `currency` (ISO 4217): +// `{ name: 'revenue', aggregate: 'sum', field: 'amount', currency: 'USD' }`. +// Chain: measure `currency` → the field's `defaultCurrency` (`fixed` +// only) → tenant `localization.currency`; omit it for counts. A +// `currencyMode: 'record'` amount refuses `currency` and ungrouped +// sum/avg/min/max; grouped by its code field, the column carries +// `currencyField` (per-row code column); a `count` never does. // The widget just selects the dimension by name: const signedByMonth: DashboardWidget = { id: 'signed_by_month', type: 'line',