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
24 changes: 24 additions & 0 deletions .changeset/27157-currency-config-fixed-code-required.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/spec': minor
'@objectstack/formula': minor
'@objectstack/platform-objects': minor
---

fix(spec)!: a `fixed` currency config needs its code — `currencyConfig.defaultCurrency` loses its `'CNY'` default, and a `dynamic` config parses with no code

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: registered currency-config-fixed-default-currency-required, currency-config-dynamic-default-currency-removed -->

**BREAKING** for one shape: a `currencyConfig` with `currencyMode: 'fixed'` and no `defaultCurrency`, on a currency field or on a formula field whose `returnType` is `currency`. Ruling X on #27119: an amount whose currency is not known takes the tenant default, and nothing guesses.

- **Before.** `defaultCurrency` was `.default('CNY')`. `{ currencyMode: 'fixed' }` parsed to `{ currencyMode: 'fixed', defaultCurrency: 'CNY' }`, so the field silently became a yuan field and showed `¥` in a dollar or euro app. Every `dynamic` config's parse output carried that `'CNY'` too, though nothing reads a `dynamic` config's code.
- **Now.** A `fixed` config without a code is refused at every door that parses a field (`defineStack`, `os validate`, the object save door), with one `custom` issue at `currencyConfig.defaultCurrency`: "`currencyConfig.currencyMode: 'fixed'` needs `defaultCurrency`, the field's one currency: give its ISO 4217 code (e.g. `defaultCurrency: 'USD'`), or use `currencyMode: 'dynamic'` to display amounts in the tenant default currency (the `localization.currency` setting)." A `dynamic` config (and an empty one) parses to `{ currencyMode: 'dynamic' }`: no code is materialized. An authored code under `dynamic` is still accepted and still unread.
- **FROM → TO.** `currencyConfig: { currencyMode: 'fixed' }` → `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }` (the code the field is in; until now it displayed CNY), or `currencyMode: 'dynamic'` / no `currencyConfig` for the tenant default currency.
- **The one-line fix.** Write the code next to `currencyMode: 'fixed'`.
- **Stored rows and built artifacts (ADR-0087).** A built artifact holds the parsed stack, so its `fixed` configs carry the code the old parse filled in and keep it. A `sys_metadata` object row is stored as authored, so a row saved as `{ currencyMode: 'fixed' }` (Studio's Fixed mode with the code left blank, or a `/meta` write) has no code, and nothing converts one in: guessing `'CNY'` is what the ruling forbids. Such a row still loads and is registered unchanged; boot counts it `invalid` and prints `[metadata_spec_invalid] stored object/NAME fails the current spec schema even after conversion (fields.FIELD.currencyConfig.defaultCurrency: …)`, and the next save of that object is refused (422 `INVALID_METADATA` at `fields.FIELD.currencyConfig.defaultCurrency`) until the code is written. Its display does not change: the registry already held the row as stored, and the console reads a `fixed` code only when there is one, so it showed the tenant default before and still does. `@objectstack/formula` now reads a formula over that field as an unproven currency instead of fixed CNY. The D2 conversion `currency-config-dynamic-default-currency-removed` strips the unread code from `dynamic` configs (and configs with no mode) on stored-row rehydration and inside a built artifact's declared-floor window, on objects and object extensions; no display changes. It is retired from the load path, so an authored source is never rewritten. The upgrade guide entry is `currency-config-fixed-default-currency-required`.
- **Readers.** None changes what it displays. The console's `resolveFieldCurrency` and the analytics field-currency relay already read the code only under `fixed`. `@objectstack/formula`'s return-type inference reads a field's currency through `CurrencyConfigSchema`; a `fixed` config without a code no longer parses there, so it reads as an unproven currency instead of CNY.
- **`@objectstack/platform-objects`.** The field form's `defaultCurrency` help text says the code is required in fixed mode instead of "Defaults to CNY", in en, zh-CN, ja-JP and es-ES.

Survey at the change: 11 `fixed` currency configs across `examples/**`, `packages/platform-objects/**`, `packages/qa/**`, seeds and fixtures, none without a code.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ code lives in `currencyConfig`, never in the value.
| `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` | `'CNY'` | The column's currency code under `fixed`; not the displayed currency under `dynamic` |
| `currencyConfig.defaultCurrency` | `string` | — | The column's currency code under `fixed`, where it is required; not the displayed currency under `dynamic` |

```typescript
{ name: 'price', label: 'Price', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }
Expand Down
2 changes: 1 addition & 1 deletion content/docs/data-modeling/validation-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ 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` | `CNY` | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`; not the displayed currency under `dynamic` |
| `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` |

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

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 @@ -29,7 +29,7 @@ const result = CurrencyConfigSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. |
| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency. |


---
Expand Down Expand Up @@ -249,7 +249,7 @@ const result = CurrencyConfigSchema.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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"`) | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency. Not read under `dynamic` (the default), where amounts display in the tenant default currency. |
| **defaultCurrency** | `string` | optional | The currency code (ISO 4217, e.g. USD, CNY, EUR) of a `fixed`-mode field: its one currency, required under `fixed`. Not read under `dynamic` (the default), where amounts display in the tenant default currency. |

### Nested Shape: `Field.conditionalFormatting[number]`

Expand Down
4 changes: 4 additions & 0 deletions packages/formula/src/formula-return.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const FIELDS: Readonly<Record<string, FormulaSourceField>> = {
weighted: { type: 'formula', returnType: 'currency', currencyConfig: USD },
count_formula: { type: 'formula', returnType: 'number' },
broken: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'US' } },
// `fixed` with no code: the spec refuses it rather than filling in CNY (#27157).
fixed_no_code: { type: 'currency', currencyConfig: { currencyMode: 'fixed' } },
};

const NAMES = Object.keys(FIELDS);
Expand Down Expand Up @@ -127,6 +129,8 @@ describe('CONTROL — a numeric formula stays `number`', () => {
'record.flag ? record.amount : record.fee_eur',
// A source whose currency does not parse names no currency.
'record.broken * 2',
// Nor does a fixed source with no code: it is never read as CNY (#27157).
'record.fixed_no_code * 2',
// A roll-up's unit is its child field's, not readable here.
'record.rollup * 2',
'record.amount % 3',
Expand Down
11 changes: 7 additions & 4 deletions packages/formula/src/formula-return.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,16 @@ function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean {
return a.code === b.code;
}

/** A field's currency, read through the schema a currency field is parsed with — or `null` when it does not parse. */
/**
* A field's currency, read through the schema a currency field is parsed with — or `null` when it does not parse.
* A `fixed` config with no code does not parse (the schema refuses it rather than filling in a code), so the
* `defaultCurrency` guard below only narrows the type the refinement already guarantees.
*/
function currencyOf(config: unknown): MoneyCurrency | null {
const parsed = CurrencyConfigSchema.safeParse(config ?? {});
if (!parsed.success) return null;
return parsed.data.currencyMode === 'fixed'
? { mode: 'fixed', code: parsed.data.defaultCurrency }
: { mode: 'dynamic' };
if (parsed.data.currencyMode !== 'fixed') return { mode: 'dynamic' };
return parsed.data.defaultCurrency === undefined ? null : { mode: 'fixed', code: parsed.data.defaultCurrency };
}

/** Is `d` a number that is not an amount (a quantity or a constant)? */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
},
"currencyConfig.defaultCurrency": {
label: "Default Currency",
helpText: "The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Defaults to CNY. Not read in dynamic mode."
helpText: "The one currency of a fixed-mode field, as a three-character ISO 4217 code (e.g. USD, EUR). Required in fixed mode; nothing fills one in. Not read in dynamic mode."
},
step: {
label: "Step",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
},
"currencyConfig.defaultCurrency": {
label: "Moneda predeterminada",
helpText: "La única moneda de un campo en modo fixed, como código ISO 4217 de tres caracteres (p. ej., USD, EUR). Por defecto, CNY. No se lee en modo dynamic."
helpText: "La única moneda de un campo en modo fixed, como código ISO 4217 de tres caracteres (p. ej., USD, EUR). Obligatorio en modo fixed; no se rellena automáticamente. No se lee en modo dynamic."
},
step: {
label: "Incremento",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
},
"currencyConfig.defaultCurrency": {
label: "既定の通貨",
helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。既定は CNY です。dynamic モードでは参照されません。"
helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。fixed モードでは必須で、自動では補われません。dynamic モードでは参照されません。"
},
step: {
label: "ステップ値",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
},
"currencyConfig.defaultCurrency": {
label: "默认货币",
helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。默认为 CNY。dynamic 模式下不读取。"
helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。fixed 模式下必填,不会自动填入。dynamic 模式下不读取。"
},
step: {
label: "步进值",
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"data/CrossFieldValidation:severity = \"error\"",
"data/Cube:public = true",
"data/CurrencyConfig:currencyMode = \"dynamic\"",
"data/CurrencyConfig:defaultCurrency = \"CNY\"",
"data/DataEngineDeleteOptions:multi = false",
"data/DataEngineInsertOptions:returning = true",
"data/DataEngineUpdateOptions:multi = false",
Expand Down
Loading
Loading