Skip to content

Commit 1e45a4e

Browse files
docs(data-modeling): the currency page — dynamic, fixed and record modes, the sibling code field, what a record-currency amount refuses (#27475)
epic:#8345#1 · os-bill · session_01Utp6CYWaabH6fd31U9CyS7 <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg37pZAmbAEy9x3UWxYvaxYX)_ Fixes #27189 Merge-after: #27268, #27339, #27338, #27340 ⛔ not released alone — R1: pairs with #27184, #27186, #27187, #27188 (one Version Packages release) Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 新增数据建模文档「Currency」页:三种模式(dynamic / fixed / record)、record 模式的兄弟 ISO 4217 币种字段(`currency_code` 只是约定,不是契约)、哪些写法被拒及处方、平台从不代填币种、元数据永不写本位币;把仍只写两种模式的手写页补成三种并链到新页;skills 的 dashboards 指南补一句 record 金额度量列带 `currencyField`、`count` 不带、未分组聚合被拒,field-types 指南注明 `currency_code` 只是约定。 - **为什么改:** 文档和 skills 是 AI 作者照抄的源头;R1 一发布,旧页「只有两种模式、币种只在字段配置里」就成了错话。 - **风险与回滚:** 纯文档 + 一处 skills 注释(Tier H,`skills/**`);回滚即还原本 PR。 - **先看:** `content/docs/data-modeling/currency.mdx`,再看 `skills/objectstack-ui/rules/dashboards.md` 那 7 行注释。 - **两分钟验证:** `node scripts/check-skills-token-ratchet.mjs` 通过(dashboards.md 6220 / 6221,field-types.md 3158 / 3158);`pnpm check:docs-audit-scope` 通过。 - **需要你做的:** `skills/**` 是 Tier H,落地需 os-zhuang 或 hotlong 批准;四个 R1 PR(#27268、#27339、#27338、#27340)均已合入 `main`。 ## What changes **A new page, `content/docs/data-modeling/currency.mdx`** (listed in `meta.json` after the field-type decision tree; `scripts/docs-audit/handwritten-docs.json` regenerated by `check-audit-scope.mjs --write`, +1 page). Written from ADR-0144 and the four R1 heads: - The three modes and when to pick each. `currencyMode: 'record'` is the switch (D8 rule 1). - The record mode's code field: a `text` field with `valueDomain: 'iso_4217_currency'`, named by `currencyField`; `currency_code` is a convention, not a contract (D8 rule 2). A formula over record-mode amounts carries `{ currencyMode: 'record', currencyField }` (#27339's `inferFormulaReturn` rider). - What the declaration refuses, with the fix: `record` with no pointer; a pointer under `dynamic` / `fixed`; `defaultCurrency` under `record`; an empty pointer; a pointer at no other field or at the amount itself; at a non-`text` field; at a `text` field without the ISO 4217 domain (#27268 `refuseRecordModeKeyMismatch`, `refuseDishonestRecordCurrencyField`). - The write rule: an amount beside a blank code is refused `ERR_CURRENCY_CODE_REQUIRED` / 400; only the pairs an update touches are judged; the platform never fills in a code; the Console pre-fills the organization's currency on a create form; an author-declared `defaultValue` on the code field satisfies the rule because `applyFieldDefaults` runs before the check (#27339 `record-currency-code.ts`, `engine.ts`). - Aggregates: ungrouped `sum` / `avg` / `min` / `max` refused `INVALID_FIELD` / 400 at the ObjectQL aggregate and at the analytics cube door (all its paths); `count` and `count_distinct` served; a `where` is not grouping; a grouped column carries `currencyField`, a `count` column never does (#27339 `record-currency-aggregate-door.ts`, #27338 `cube-record-currency-door.ts`, `enrichResultColumns`). - Roll-ups and datasets: a `summary` over a record-mode amount refused `INVALID_METADATA` / 422 at registration; a measure `currency` over one, or on a derived measure resolving to one, refused `DATASET_INVALID` / 400; a derived measure mixing a record-mode amount with another currency refused `DATASET_INVALID` / 400 (#27338 `measure-currency-coherence.ts`). - The Console's per-row display, footer and group headers (#27340). - The home currency: `localization.currency`; metadata never names it (D8 rule 5); consolidation through rates and a stored home amount is "a later release" (R2), with no R2 key named. **Hand-written pages that described two modes** (the docs-drift lists in comments 6480459047 on #27268 and 6487627326 on #27340): | Page | Change | |:--|:--| | `data-modeling/field-types.mdx` | `currencyMode` row names `record`; new `currencyField` row; `defaultCurrency` refused under `record`; Console line for record mode; formula `returnType` row; the `summary` section states the roll-up refusal | | `data-modeling/fields.mdx` | a record-mode example beside the fixed one; "both modes" → every mode; link | | `data-modeling/validation-rules.mdx` | three-mode `currencyMode` row, `currencyField` row with the blank-code refusal; the default-constraints paragraph no longer says the code is always field configuration | | `data-modeling/formulas.mdx` | a record-mode formula's `currencyConfig`; the mail template `currency:@path` form for a record-mode amount (already on `main`) | | `protocol/objectql/types.mdx` | the YAML comment, the `currency` section, the callout and the features list name `record`; the "no companion column" line says the code field is the author's own column | | `protocol/objectql/schema.mdx` | `valueDomain` row names the record code field; a `currencyConfig` row | Checked and left unchanged: `api/data-api.mdx` (#27338 already states `currencyField` and the refusal; not duplicated), `getting-started/common-patterns.mdx` (its two `fixed` examples and plain amounts stay true), `concepts/metadata-driven.mdx` (listed via `ObjectSchemaBase`; it names no currency mode). `content/docs/releases/**` is release-owned and untouched. **Skills (Tier H).** `skills/objectstack-ui/rules/dashboards.md`: the measure-currency comment now says a `currencyMode: 'record'` amount refuses a measure `currency` and an ungrouped `sum` / `avg` / `min` / `max`, that grouped by its code field the column carries `currencyField` (the per-row code column), and that a `count` never does (#27338's "Skills-docs: follow-up owed — #27189"). Paid inside the token ratchet by tightening the same comment: 6216 → 6220 against the 6221 ceiling. A `@objectstack/skills` patch changeset carries it, since a `skills/**` change releases that package. **`skills/objectstack-data/rules/field-types.md`** (should-fix of contract review 6489126506, routed here): the code field is named `currency_code` "by convention", added once #27268's paragraph reached `main`. Paid inside the token ratchet (3158 / 3158). The same changeset names it. ## Why ADR-0144 D9 pairs the docs page (M0-5) with R1. The pages above are what AI authors copy from, and once R1 ships they would teach that a currency is always field configuration and that `currencyConfig` has two modes. ## Verification Head `272afe11`, a merge of `main` at `1ed33609`. All four R1 PRs are on `main`: #27268 (`41452103`), #27338 (`3604371f`), #27339 (`57c51aab`) and #27340 (`1ed33609`). The bring-ins were merges, with no rebase, and none conflicted. **Citations, re-checked against the landed code:** - `ERR_CURRENCY_CODE_REQUIRED` / 400, with `field` and `amountField`, and the blank-code definition: `packages/objectql/src/validation/record-currency-code.ts`. - `applyFieldDefaults` runs before `assertRecordCurrencyCodes` on insert: `engine.ts`. - `INVALID_FIELD` / 400 for an ungrouped `sum` / `avg` / `min` / `max`, with `count` and `count_distinct` served: `record-currency-aggregate-door.ts`, and `cube-record-currency-door.ts` for the grouping, `totals`, `compareTo` and the draft preview. The cube door's header confirms that a `where` is not grouping. - `INVALID_METADATA` / 422 for a roll-up, judged at whichever registration completes the pair: `RecordCurrencySummaryRefusedError`. - `DATASET_INVALID` / 400 for both dataset guards, with a derived measure over a `count` still legal: `measure-currency-coherence.ts`. - `currencyField` on the grouped column: `content/docs/api/data-api.mdx`. The Console paragraph: - `measureCellCurrency` reads the row in the dashboard table, the report table and its export, and the Studio dataset preview. - The report's grand total formats with no row, so it shows no symbol. - The footer and group headers follow `recordCurrencyAggregates.ts` and `honestGroupAggregations`. - A create form pre-fills the code field only when it declares no `defaultValue` (`schemaDefaults.ts`). Every claim on the page holds as written. **Local runs at `272afe11`:** - `packages/spec` `currency-mode-family-closure.pin.test.ts`: 6 / 6 passed. - `check-skills-token-ratchet`, `check-section-landing-index` and `check-audit-scope` pass. - `dispatch-gates --commands --repo objectstack-ai/objectstack` derives 76 families. They ran after `pnpm build` and `pnpm --filter @objectstack/client-react build`, with each exit captured before any pipe, and every one exited 0. `dispatch-gates --ran` reads: "76 derived famil(ies) accounted for — 76 run, 0 NOT-MEASURED". CI at `272afe11`: 46 check runs, none failed. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran (true); `pnpm lint` is CI's, not run locally. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CYWaabH6fd31U9CyS7 --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYWaabH6fd31U9CyS7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 492703e commit 1e45a4e

13 files changed

Lines changed: 301 additions & 34 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the dashboards guide says how a record-currency amount behaves in a dataset measure; the code field's name is a convention
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
⛔ 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).
11+
12+
- **`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.
13+
- **`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.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
---
2+
title: Currency fields — dynamic, fixed and record modes
3+
navTitle: Currency
4+
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
5+
---
6+
7+
A `currency` field stores a **bare number** in every mode, never a `{ value, currency }`
8+
object (ADR-0104 D1). What the field's `currencyConfig` decides is where that number's
9+
currency comes from. There are three modes, and the record mode is the one to use when
10+
different records of the same object are in different currencies: an invoice in USD next
11+
to an invoice in EUR (ADR-0144).
12+
13+
## The three modes
14+
15+
| `currencyMode` | The amount's currency | Keys it reads |
16+
|:---|:---|:---|
17+
| `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 |
18+
| `fixed` | One currency for the whole column. | `defaultCurrency`, required |
19+
| `record` | Each record's own currency: the ISO 4217 code stored in a sibling field of the same record. | `currencyField`, required |
20+
21+
```typescript
22+
// dynamic: displays in the organization's currency
23+
budget: Field.currency({ label: 'Budget' }),
24+
25+
// fixed: every row is in USD
26+
list_price: Field.currency({
27+
label: 'List Price',
28+
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
29+
}),
30+
31+
// record: every row names its own currency in `currency_code`
32+
amount: Field.currency({
33+
label: 'Amount',
34+
currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' },
35+
}),
36+
```
37+
38+
Pick by asking what the currency belongs to. If it belongs to the organization, use
39+
`dynamic`. If it belongs to the column, such as a price list kept in one currency, use
40+
`fixed`. If it belongs to the document, such as an invoice billed in the customer's
41+
currency, use `record`.
42+
43+
The modes do not mix in one config. `fixed` needs its code and `record` needs its pointer.
44+
Nothing turns multi-currency on for an organization or a deployment: declaring
45+
`currencyMode: 'record'` on a field is the switch, and the field is the only place it
46+
applies.
47+
48+
## The record mode
49+
50+
### The code field
51+
52+
A record-mode amount names, through `currencyConfig.currencyField`, a field **on the same
53+
object** that holds each record's currency. That field must be a `text` field declaring
54+
`valueDomain: 'iso_4217_currency'`, so every code written to it is checked against the
55+
ISO 4217 list.
56+
57+
```typescript
58+
import { ObjectSchema, Field } from '@objectstack/spec/data';
59+
60+
export const Invoice = ObjectSchema.create({
61+
name: 'invoice',
62+
label: 'Invoice',
63+
fields: {
64+
name: Field.text({ label: 'Number', required: true }),
65+
currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }),
66+
amount: Field.currency({
67+
label: 'Amount',
68+
currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' },
69+
}),
70+
tax: Field.currency({
71+
label: 'Tax',
72+
currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' },
73+
}),
74+
},
75+
});
76+
```
77+
78+
`currency_code` is a **convention, not a contract**. The platform never injects a code
79+
column and never looks one up by name: the code field is whatever field you declare and
80+
point at. Several amounts may share one code field, as `amount` and `tax` do above, and an
81+
object may carry two code fields when a record really holds amounts in two currencies.
82+
83+
A `formula` field with `returnType: 'currency'` that computes from record-mode amounts
84+
carries the same config, `{ currencyMode: 'record', currencyField: … }`, naming the code
85+
field its source amounts name. `inferFormulaReturn` in `@objectstack/formula` answers it
86+
for a formula it can prove is money.
87+
88+
### What the declaration refuses
89+
90+
`ObjectSchema.create()`, `os validate` and publish refuse each of these, with the fix in the
91+
message:
92+
93+
| Declaration | Refused at | Fix |
94+
|:---|:---|:---|
95+
| `currencyMode: 'record'` with no `currencyField` | `currencyConfig.currencyField` | Name the code field, or use `fixed` (one currency) or `dynamic` (the tenant default). |
96+
| `currencyField` under `dynamic` or `fixed` | `currencyConfig.currencyField` | Set `currencyMode: 'record'`, or delete `currencyField`. |
97+
| `defaultCurrency` under `record` | `currencyConfig.defaultCurrency` | Delete `defaultCurrency`, or use `fixed` if the field has one currency. |
98+
| `currencyField: ''` | `currencyConfig.currencyField` | Name a field. |
99+
| `currencyField` names no other field of the object, or names the amount itself | `fields.<amount>.currencyConfig.currencyField` | Point at a declared code field. The message lists the object's candidates. |
100+
| `currencyField` names a field that is not `text` | same | Point at a `text` field. |
101+
| `currencyField` names a `text` field without `valueDomain: 'iso_4217_currency'` | same | Add `valueDomain: 'iso_4217_currency'` to that field. |
102+
103+
### Writing a record
104+
105+
When a record-mode amount holds a value, its code field must hold a code in the same
106+
write. An insert, or an update that leaves the amount beside a blank code (absent, `null`
107+
or white space only), is refused with **`ERR_CURRENCY_CODE_REQUIRED` / 400** and nothing
108+
is written. The error names the code field in `field` and the amount in `amountField`.
109+
Fix it by writing the record's code, for example `'USD'`, in the same write, or by leaving
110+
the amount empty.
111+
112+
- A blank amount needs no code.
113+
- On an update, only the amount and code pairs the payload touches are judged, so editing
114+
an unrelated field of an older row is not refused.
115+
- A code that is not an ISO 4217 code is refused by the code field's own `valueDomain`,
116+
not by this rule.
117+
118+
**The platform never fills in a code.** No server default, no tenant fallback and no
119+
"most recent currency" is ever stored on your behalf. In the Console, a create form opens
120+
with the code field pre-filled with the organization's currency, and the user submits it
121+
as an explicit value. A `defaultValue` you declare on the code field yourself is an
122+
ordinary field default: the engine applies it before the check, so it satisfies the
123+
rule, because you wrote it.
124+
125+
### Aggregating a record-mode amount
126+
127+
Amounts in different currencies do not add. A `sum`, `avg`, `min` or `max` over a
128+
record-mode amount is refused with **`INVALID_FIELD` / 400** unless the same query groups
129+
by the amount's code field, and the query is not run. Grouped by the code, every group
130+
holds one currency and each figure is right.
131+
132+
```typescript
133+
// Refused: USD 100 and EUR 100 would answer 200 under one label.
134+
{ aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }] }
135+
136+
// Served: one total per currency.
137+
{ groupBy: ['currency_code'], aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }] }
138+
```
139+
140+
The rule holds at two doors:
141+
142+
- **The ObjectQL aggregate**, so it covers every caller that reaches the engine: the REST
143+
query API, flows, hooks and the analytics strategy.
144+
- **The analytics cube door**, ahead of strategy selection: dataset queries (the main
145+
grid, every `totals` grouping including the grand total, and the `compareTo` pass),
146+
cube queries over a declared or inferred cube, and the dataset draft preview.
147+
148+
A `count` reads no amount and is always served, grouped or not, and so is
149+
`count_distinct`. A `where` that happens to select one currency does not count as
150+
grouping: the rule is the grouping alone.
151+
152+
In an analytics answer, a measure column over a record-mode amount, grouped by the code,
153+
carries **`currencyField`** instead of `currency`. It names the dimension column of the
154+
same answer that groups by the code field, and each row's currency is that row's value of
155+
that column. A `count` column never carries `currencyField`. See the
156+
[analytics result columns](/docs/api/data-api) for the full `fields[]` surface.
157+
158+
### Roll-ups and datasets
159+
160+
- **A `summary` roll-up** whose `sum`, `avg`, `min` or `max` reads a record-mode amount of
161+
the child object is refused when the object registers, with **`INVALID_METADATA` /
162+
422**. A roll-up stores one number with no currency, so child rows in different
163+
currencies would be added together. Use `function: 'count'`. The pair is judged at
164+
whichever registration completes it, the parent's or the child's.
165+
- **A dataset measure that declares `currency` over a record-mode amount** is refused at
166+
compile with **`DATASET_INVALID` / 400**, and so is a `currency` on a derived measure that
167+
sums record-mode amounts. One code cannot label amounts in several currencies: delete
168+
the measure's `currency` and select the dimension over the code field, so each row
169+
carries its own.
170+
- **A derived measure that combines a record-mode amount with an amount in another
171+
currency** (another code field, a `fixed` code, an explicit measure code or the tenant
172+
currency) is refused at compile with `DATASET_INVALID` / 400, whatever the operation.
173+
A derived measure over a `count`, such as an amount per invoice, stays legal.
174+
175+
### In the Console
176+
177+
Each record-mode amount shows the symbol and minor-unit decimals of its own record's code.
178+
A record whose code is blank shows a plain number. Grouped analytics answers do the same
179+
per row: the dashboard table, the report table and its export, and the Studio dataset
180+
preview each take a row's currency from its code column. A place that spans many records
181+
and is not grouped by the code has no one currency to show: the grid footer draws no
182+
`sum` / `avg` / `min` / `max` for that column, a group header draws them only when the
183+
grouping includes the code field, a report's grand total on such a column shows no
184+
symbol, and the counts always stand.
185+
186+
## The home currency
187+
188+
The organization's home currency is the existing `localization.currency` setting.
189+
**Metadata never names it**: no field, object or dataset declares the home currency, and a
190+
`dynamic` amount displays in it because that is what `dynamic` means.
191+
192+
Consolidating record-mode amounts into one home-currency total, through dated exchange
193+
rates and a stored home amount, comes in a later release. Until then, total a record-mode
194+
amount per currency by grouping by its code field.
195+
196+
## See also
197+
198+
- [Field Type Gallery: `currency`](/docs/data-modeling/field-types#currency) — the field's
199+
properties and decimal places.
200+
- [Validation rules: `currency`](/docs/data-modeling/validation-rules#currency) — the value
201+
shape a write is judged against.
202+
- [Analytics](/docs/data-modeling/analytics) — datasets, measures and dimensions.

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,19 @@ The input is a native number box, so the browser filters what is typed. See
206206
[What a number input silently rewrites](#what-a-number-input-silently-rewrites).
207207

208208
### `currency`
209-
Monetary value. The stored value is a bare number in both modes; the currency
210-
code lives in `currencyConfig`, never in the value.
209+
Monetary value. The stored value is a bare number in every mode; the currency
210+
code lives in `currencyConfig`, or under `record` in a sibling code field, never in
211+
the value. See [Currency fields](/docs/data-modeling/currency) for the three modes and
212+
what a record-currency amount refuses.
211213

212214
| Property | Type | Default | Description |
213215
|:---|:---|:---|:---|
214216
| `precision` | `number` | — | Total digits |
215217
| `min` | `number` | — | Minimum value |
216218
| `max` | `number` | — | Maximum value |
217-
| `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 |
218-
| `currencyConfig.defaultCurrency` | `string` | — | The column's currency code under `fixed`, where it is required; not the displayed currency under `dynamic` |
219+
| `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 |
220+
| `currencyConfig.defaultCurrency` | `string` | — | The column's currency code under `fixed`, where it is required; not the displayed currency under `dynamic`; refused under `record` |
221+
| `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` |
219222

220223
```typescript
221224
{ 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.
233236
**In the Console:** a numeric input with the currency's symbol. The read-only display, the
234237
grid cell, the input's `step` and the rounding applied when the input loses focus all use the
235238
currency's minor unit, so a JPY amount shows no decimals and a KWD amount shows three. With no
236-
currency resolved at all, an amount shows two decimals. A field-level `currency` key is
239+
currency resolved at all, an amount shows two decimals. A `record`-mode amount resolves only
240+
its own record's code, never the tenant default. A field-level `currency` key is
237241
refused: the currency is `currencyConfig`. The input is a native number box; see
238242
[What a number input silently rewrites](#what-a-number-input-silently-rewrites).
239243

@@ -671,7 +675,7 @@ Calculated field using a CEL expression (see [Expressions](/docs/data-modeling/f
671675
| Property | Type | Default | Description |
672676
|:---|:---|:---|:---|
673677
| `expression` | `string \| Expression` | **required** | CEL calculation expression |
674-
| `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) |
678+
| `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`) |
675679

676680
```typescript
677681
{ name: 'total', label: 'Total', type: 'formula', expression: 'record.quantity * record.unit_price', returnType: 'number' }
@@ -704,6 +708,11 @@ Roll-up summary from child records.
704708
| `summaryOperations.relationshipField` | `string` | auto | Child FK back to the parent when it cannot be inferred |
705709
| `summaryOperations.filter` | `FilterCondition` | — | Only child rows matching this `where` predicate are aggregated |
706710

711+
A `sum`, `avg`, `min` or `max` over a child amount declared `currencyMode: 'record'` is
712+
refused when the object registers (`INVALID_METADATA` / 422): child rows in different
713+
currencies would be added into one number. A `count` is served
714+
([Currency fields](/docs/data-modeling/currency#roll-ups-and-datasets)).
715+
707716
```typescript
708717
{
709718
name: 'task_count',

‎content/docs/data-modeling/fields.mdx‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,29 @@ price: Field.currency({
7575
label: 'Price',
7676
currencyConfig: {
7777
currencyMode: 'fixed', // 'fixed' pins the column to defaultCurrency;
78-
// 'dynamic' (default) shows the tenant default
78+
// 'dynamic' (default) shows the tenant default;
79+
// 'record' reads each record's own code
7980
defaultCurrency: 'USD', // the column's currency under 'fixed'
8081
},
8182
}),
83+
84+
// Each invoice in its own currency: the code lives in a sibling text field
85+
currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }),
86+
amount: Field.currency({
87+
label: 'Amount',
88+
currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' },
89+
}),
8290
```
8391

84-
The stored value is a bare number in both modes — never `{ value, currency }`.
92+
The stored value is a bare number in every mode — never `{ value, currency }`.
8593
Under `dynamic` (the default) the amount displays in the tenant's
8694
`localization.currency` setting and `defaultCurrency` is not the displayed
87-
currency; `fixed` pins the column to `defaultCurrency`.
95+
currency; `fixed` pins the column to `defaultCurrency`; `record` takes each
96+
record's ISO 4217 code from the field `currencyField` names, a `text` field
97+
declaring `valueDomain: 'iso_4217_currency'` (`currency_code` is a convention,
98+
not a required name). A record-mode amount refuses a write with a blank code and
99+
an aggregate that does not group by the code: see
100+
[Currency fields](/docs/data-modeling/currency).
88101

89102
### Date & Time Types
90103

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ Formatters: `currency[:CODE]`, `number[:decimals]`, `percent[:decimals]`,
8686
`localization.currency` setting, the one currency fields and dashboards read);
8787
when the organization has none, the render refuses the hole by name instead of
8888
guessing a currency. Write `currency:EUR` where the amount's currency is fixed.
89+
For a `record`-mode amount, whose currency is each record's own code, a mail
90+
template writes `currency:@currency_code`: `@` and a path into the send's data
91+
whose value is the code. Only mail templates admit the `@` form; the in-app
92+
dialect refuses it by name.
8993

9094
At **input** time you may write a bare string for shorthand — the spec
9195
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
133137
tool reports the type it infers from the expression (`inferredType`) so an AI
134138
author can stamp the matching value. A `'currency'` result is an amount of money
135139
and carries its currency the way a currency field does: in the formula's own
136-
`currencyConfig` (none means dynamic, the tenant default currency). Copy it from
140+
`currencyConfig` (none means dynamic, the tenant default currency; over
141+
record-mode amounts it is `{ currencyMode: 'record', currencyField }`, naming the
142+
same code field). Copy it from
137143
the currency field the formula computes from — `inferFormulaReturn` in
138144
`@objectstack/formula` answers both halves for a formula it can prove is money. Consumers read the declared `returnType`
139145
instead of re-parsing the expression (record-title eligibility, for one: a

0 commit comments

Comments
 (0)