Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/20126-currency-mode-describe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': patch
---

docs(spec): the `currencyConfig.currencyMode` description says what the runtime does with each mode, and no longer calls `dynamic` "user selectable" (#20126)

Clause-②: no

The old text read "dynamic (user selectable) or fixed (single currency)". No key, stored value or runtime path lets anyone pick a currency per record. The value is a bare number in both modes (ADR-0104 D1), and the currency code lives in field config. What the runtime does, and what the description now says:

- **`fixed`**: the field has one currency, `defaultCurrency`.
- **`dynamic`** (the default): the field has no currency of its own. Amounts display in the tenant default currency, which is the `localization.currency` setting. When that setting is not set, amounts display as a plain number. `defaultCurrency` is not read.

The field faces follow this rule, and so do analytics dataset measure columns and the publish-time precision check.

Only the description text changes. The accepted keys and values, the defaults (`dynamic`, `CNY`) and parse output are the same as before. The generated reference page `content/docs/references/data/field.mdx` is regenerated from the new text.
10 changes: 9 additions & 1 deletion content/docs/api/data-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -464,8 +464,16 @@ The **cube's metric/dimension definition** (`MetricSchema.label` / `MetricSchema
([#6442](https://github.com/objectstack-ai/objectstack/issues/6442)). So a client that
renders table headers or formats amounts takes `label` / `format` / `currency` /
`percentScale` off the query result's `fields[]`: those are resolved server-side (the
ADR-0053 currency chain, the percent-scale chain), and `format` is not reachable through
currency chain below, the percent-scale chain), and `format` is not reachable through
the metadata endpoint at all.

The currency chain runs on the dataset query (`POST /analytics/dataset/query`). A
monetary measure column takes the measure's own `currency`; then, only when the source
field is `currencyMode: 'fixed'`, that field's `currencyConfig.defaultCurrency`; then the
tenant default currency (the `localization.currency` setting), if one is set. A
`dynamic` field (the default mode) never lends its `defaultCurrency` — only `fixed` does —
so its column shows the tenant default. The cube query on this page carries no column
`currency`.
</Callout>

### `GET /analytics/meta`
Expand Down
6 changes: 3 additions & 3 deletions content/docs/data-modeling/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,10 @@ 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.precision` | `number` | `2` | Decimal places (0–10) |
| `currencyConfig.currencyMode` | `enum` | `dynamic` | `dynamic` (user-selectable) or `fixed` (single currency) |
| `currencyConfig.defaultCurrency` | `string` | `CNY` | 3-character currency code (ISO 4217 or crypto) |
| `currencyConfig.currencyMode` | `enum` | `dynamic` | `fixed` pins the field to `defaultCurrency`, and an authored `precision` must agree with that currency's ISO 4217 fraction digits; `dynamic` displays the tenant default currency (the `localization.currency` setting). Not a per-record choice |
| `currencyConfig.defaultCurrency` | `string` | `CNY` | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`; not the displayed currency under `dynamic` |

**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 per-record currency **code** is a separate concern, carried by `currencyConfig` above. Precision defaults to 2 decimal places.
**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. Precision defaults to 2 decimal places.

### `percent`

Expand Down
4 changes: 2 additions & 2 deletions content/docs/getting-started/common-patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default defineStack({
// Person references target the platform user object `sys_user`
owner: { label: 'Owner', type: 'lookup', reference: 'sys_user' },
due_date: { label: 'Due Date', type: 'date' },
budget: { label: 'Budget', type: 'currency', currencyConfig: { precision: 2, defaultCurrency: 'USD' } },
budget: { label: 'Budget', type: 'currency', currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' } },
}
}
]
Expand Down Expand Up @@ -79,7 +79,7 @@ export default defineStack({
// Person references target the platform user object `sys_user`
owner: { label: 'Owner', type: 'lookup', reference: 'sys_user' },
due_date: { label: 'Due Date', type: 'date' },
budget: { label: 'Budget', type: 'currency', currencyConfig: { precision: 2, defaultCurrency: 'USD' } },
budget: { label: 'Budget', type: 'currency', currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' } },
},
},
},
Expand Down
9 changes: 6 additions & 3 deletions content/docs/protocol/objectql/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ annual_revenue:
currencyConfig:
precision: 2
currencyMode: fixed
defaultCurrency: USD
defaultCurrency: USD # the field's currency under fixed
```

**Storage:** a **bare number** — the same value shape as `number`.
Expand All @@ -305,8 +305,11 @@ annual_revenue:

<Callout type="warn">
A currency value is **not** a `{ value, currency }` object. The currency *code*
lives once on the field definition (`currencyConfig.defaultCurrency`), not on
every stored value. The old per-value object shape (`CurrencyValueSchema`) was
is field configuration, never stored per 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
never consumed by the validator, the driver, or import coercion and is
deprecated in the spec.
</Callout>
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = CurrencyConfigSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **precision** | `integer` | optional (default: `2`) | Decimal precision (default: 2) |
| **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode: dynamic (user selectable) or fixed (single currency) |
| **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. |
| **defaultCurrency** | `string` | optional (default: `"CNY"`) | Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR) |


Expand Down Expand Up @@ -236,7 +236,7 @@ const result = CurrencyConfigSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **precision** | `integer` | optional (default: `2`) | Decimal precision (default: 2) |
| **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode: dynamic (user selectable) or fixed (single currency) |
| **currencyMode** | `Enum<'dynamic' \| 'fixed'>` | optional (default: `"dynamic"`) | Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way. |
| **defaultCurrency** | `string` | optional (default: `"CNY"`) | Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR) |

### Nested Shape: `Field.maskingRule`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
* | `label` | `measure.label` / `dimension.label` + `ctx.locale` (#6761) |
* | `format` | `measure.format` |
* | `builtinAggregate` | `measure.aggregate` + `measure.label == null` (#14492) |
* | `currency` | ADR-0053 chain: `measure.currency` → `sourceFieldMeta().defaultCurrency` → `ctx.currency` |
* | `currency` | `measure.currency` → the source field's FIXED currency (`sourceFieldMeta().defaultCurrency`, relayed only under `currencyMode: 'fixed'`) → `ctx.currency` |
* | `percentScale` | `measure.derived.op === 'ratio'`, else `percentScaleOf(sourceFieldMeta())` (objectui#3136) |
* | `type` | `measureResultType(measure.aggregate, sourceFieldMeta().type)` (#16101) |
*
Expand Down Expand Up @@ -279,15 +279,15 @@ describe('#16097 — a preview response describes its measure columns like the l
expect(preview.total_amount.builtinAggregate).toBeUndefined();
});

it('currency (ADR-0053) — the sourceFieldMeta limb resolves on the preview path', async () => {
it('currency — the fixed-field limb (sourceFieldMeta().defaultCurrency) resolves on the preview path', async () => {
const { live, preview } = await bothPaths();
expect(live.total_amount.currency).toBe('EUR');
expect(preview.total_amount.currency).toBe('EUR');
});

it('currency (ADR-0053) — the ExecutionContext limb reaches the preview path too', async () => {
it('currency — the ExecutionContext (tenant default) limb reaches the preview path too', async () => {
const { live, preview } = await bothPaths();
// No measure currency, no field default ⇒ the tenant default on `ctx`.
// No measure currency, no fixed field currency ⇒ the tenant default on `ctx`.
expect(live.total_fee.currency).toBe('USD');
expect(preview.total_fee.currency).toBe('USD');
// A non-monetary measure never acquires one, on either path.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ describe('AnalyticsService.queryDataset', () => {
expect(revenueField?.format).toBe('0,0');
});

// ── ADR-0053 currency chain (measure → field currencyConfig → tenant ctx) ──
// ── currency chain (measure → the field's FIXED currency → tenant ctx) ──
// `sourceFieldMeta().defaultCurrency` is the field's fixed currency: the
// plugin relays `currencyConfig.defaultCurrency` only under
// `currencyMode: 'fixed'`, so a `dynamic` field reaches `ctx.currency`.
function pricedSvc(rows: Array<Record<string, unknown>>, sourceFieldMeta?: (o: string, f: string) => { type?: string; defaultCurrency?: string; max?: number } | undefined) {
return new AnalyticsService({
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({
description: 'Decimal precision (default: 2)',
default: 2,
}),
currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode: dynamic (user selectable) or fixed (single currency)'),
currencyMode: z.enum(['dynamic', 'fixed']).default('dynamic').describe('Currency mode. `fixed`: the field has one currency, `defaultCurrency`. `dynamic` (the default): the field has no currency of its own — amounts display in the tenant default currency (the `localization.currency` setting; a plain number when none is set) and `defaultCurrency` is not read. Neither mode is a per-record choice: the value is a bare number either way.'),
defaultCurrency: z.string().length(3).default('CNY').describe('Default or fixed currency code (ISO 4217, e.g., USD, CNY, EUR)'),
}).superRefine((config, ctx) => {
// #7918 (maintainer ruling 2026-08-12, Option A): an AUTHORED `precision`
Expand Down
Loading