Skip to content

Commit 0cd578f

Browse files
fix(spec)!: a fixed currency config needs its code — currencyConfig.defaultCurrency loses its CNY default (#27218)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBTNfKk4ntUHExRLQXMgHcte)_ Fixes #27157 Ruling-ref: 6464202770 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — `skills/**` teaches `defaultCurrency` in two places (`skills/objectstack-data/rules/field-types.md` lines 55 and 174-187, `skills/objectstack-ui/rules/dashboards.md` lines 303-304), and both already teach it only as an explicit code under `currencyMode: 'fixed'`; no `skills/**` text teaches the `'CNY'` default (`git grep -n CNY -- skills` → 0 hits). Not Tier H. ## 维护者速读 - 改了什么:`currencyConfig.defaultCurrency` 去掉 `'CNY'` 默认值;`currencyMode: 'fixed'` 不写币种代码时在解析处拒收(货币字段和返回 currency 的公式字段同一规则),提示「写 ISO 4217 代码,或改用 dynamic(组织默认币种)」;`dynamic` 配置解析后不再带 `defaultCurrency`。 - 为什么改:裁决 X(#27119 Q2):币种未知的金额取组织默认币种,任何地方都不猜。之前不写代码的 fixed 字段静默变成人民币字段,dynamic 配置也每次被物化出一个没人读的 CNY。 - 风险与回滚:收窄(BREAKING,minor)。仓库调查 11 条 fixed 配置、0 条缺代码。已存的 `sys_metadata` 行按原样保存,所以以 `{ currencyMode: 'fixed' }` 存下的行没有代码,转换不补(补 CNY 正是裁决禁止的):它照常加载注册,启动计为 invalid 并打印 `[metadata_spec_invalid]`,下次保存 422 拒收直到写上代码;显示不变(之前就落到组织默认币种)。dynamic 行上的 CNY 由 ADR-0087 转换 `currency-config-dynamic-default-currency-removed` 在回读时剥掉,显示不变。回滚即 revert 本 PR。 - 先看:`packages/spec/src/data/field.zod.ts`(`CurrencyConfigSchema` 与 `refuseFixedCurrencyWithoutCode`)、`packages/spec/src/conversions/registry.ts`(新转换)、`packages/formula/src/formula-return.ts` 的 `currencyOf`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/currency-config-fixed-code-required.test.ts src/conversions/currency-config-dynamic-default-currency-removed.test.ts`,预期 2 个文件 12 个用例全绿:fixed 无代码在 `defaultCurrency` 处拒收,dynamic 输出无代码,已存 dynamic 行剥掉 CNY、带代码的 fixed 行保留,已存的无代码 fixed 行不被转换且仍被拒收。 - 需要你做的:无,席位落地。 ## What changes - **Spec (`@objectstack/spec`, minor, BREAKING).** `CurrencyConfigSchema.defaultCurrency` is `.optional()` instead of `.default('CNY')`, and the schema gains one `superRefine`: `currencyMode: 'fixed'` with no `defaultCurrency` answers one `custom` issue at `defaultCurrency` (`currencyConfig.defaultCurrency` on a field), first sentence "`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)." `FieldSchema.currencyConfig` is this one schema for a currency field and for a formula with `returnType: 'currency'` (#22727), so both take the same refusal. A `dynamic` (or empty) config parses to `{ currencyMode: 'dynamic' }`; an authored code under `dynamic` stays legal and unread. `.describe()`, the field form's help text, and the hand-written docs tables (`content/docs/data-modeling/field-types.mdx`, `validation-rules.mdx`) no longer say "Defaults to CNY". - **ADR-0087: registered.** D2 conversion `currency-config-dynamic-default-currency-removed` (toMajor 18, `retiredFromLoadPath`, `retiredAfter: '18.2.0'`, order 78): strips `defaultCurrency` from a config whose mode is `dynamic` or absent, on objects and object extensions; a `fixed` config is never touched, and no code is written into one. D3 semantic entry `currency-config-fixed-default-currency-required` (step 18, `conversionIds` = the D2 id) and a `STEP18_RATIONALE` fragment. `DEFAULT_CHANGES_BY_MAJOR[18]` declares `data/CurrencyConfig:defaultCurrency: "CNY" → (none)`. The `currency-config-precision-removed` fixture's dynamic row drops its baked `CNY` so the two fixtures stay disjoint. - **Stored rows and built artifacts, measured.** 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 (`saveMetaItem` persists the payload verbatim), so a row saved as `{ currencyMode: 'fixed' }` (Studio's Fixed mode with the code left blank, or a `/meta` write) has no code, and the conversion writes none in. Measured on this head with a probe through `loadMetaFromDb` and `saveMetaItem`: - Load: `{ loaded: 1, errors: 0, invalid: 1 }`; the row registers unchanged and boot prints `[metadata_spec_invalid] stored object/crm_invoice fails the current spec schema even after conversion (fields.amount.currencyConfig.defaultCurrency: …the prescription…)`. A dynamic `CNY` row in the same probe: `invalid: 0`, registered as `{ currencyMode: 'dynamic' }`. - Save: the next save of that object answers 422 `INVALID_METADATA`, one `custom` issue at `fields.amount.currencyConfig.defaultCurrency`, until a code is written. - Display: unchanged. The registry already held the row as stored, and `resolveFieldCurrency` reads a `fixed` code only when one is present, so the field showed the tenant default before and still does (its test "fixed with no code: falls through to the tenant default" pins it). - Formula: a formula over that field is read as an unproven currency instead of fixed CNY (the `currencyOf` row below). - The new conversion test pins the row: no notice, no change, and `ObjectSchema.safeParse` refuses it at `fields.amount.currencyConfig.defaultCurrency` with the prescription. - **Generated.** `dropped-refinements.baseline.json` gains `data/CurrencyConfig` plus the `currencyConfig` site in the 12 schemas that embed it (totals 252 schemas / 900 sites); `authorable-defaults/data.json` loses the `CNY` row; `content/docs/references/data/field.mdx` regenerated. Liveness `field.json` `currencyConfig` row: a dated note, verdict unchanged (live). - **Readers, each measured.** - `ui/packages/i18n/src/currency.ts#resolveFieldCurrency` (the display rule at :34-36 and :60): unchanged. It reads the code only under `fixed`, so a dynamic config without `CNY` displays the same. Its test "parsed-empty" now asserts the spec's empty-config output carries no code (`toBeUndefined()`) and still resolves to the tenant `USD`; the docblock says the CNY now lives only in rows not yet stripped, and that a stored `fixed` row saved without a code still loads with none and shows the tenant default. - `packages/formula/src/formula-return.ts#currencyOf`: the one reader that changes. It parses through `CurrencyConfigSchema`, so a `fixed` config without a code now fails to parse and reads as an unproven currency (no `returnType` inferred) instead of fixed CNY. New row `fixed_no_code` pins it; the `defaultCurrency === undefined` guard only narrows the type. - `packages/services/service-analytics/src/plugin.ts` (the field-currency relay, :1447): reads the raw config's code only under `fixed`; unchanged. - `packages/types/src/share-link-resolve-answer.ts` (:120): copies `defaultCurrency` only when it is a string, so a dynamic field's projection loses the `CNY` key; the console reads it only under `fixed`. No display changes. - Every other `defaultCurrency` read under `ui/` goes through `resolveFieldCurrency` or reads the legacy flat key. - **Survey (metadata in the repository).** `examples/**`, `packages/platform-objects/**`, `packages/qa/**`, seeds and fixtures (CHANGELOGs excluded), 1,034 files: **11** `currencyMode: 'fixed'` configs, **0** without a code. The same detector over the whole tree flags the CHANGELOG prose that names `fixed` without a code, so it is not blind. A deployed database's `sys_metadata` rows cannot be surveyed from here; any fixed row without a code there behaves as measured above. ## Tests - `packages/spec/src/data/currency-config-fixed-code-required.test.ts` (new): fixed without a code refused at `defaultCurrency` with the prescription; the same on a currency field and on a formula returning currency (`currencyConfig.defaultCurrency`); fixed with a code passes unchanged; dynamic output carries no code; `parse(parse(x))` equals `parse(x)` for every accepted shape, on the config and on `FieldSchema`. - `packages/spec/src/conversions/currency-config-dynamic-default-currency-removed.test.ts` (new): registered for 18, retired from the load path, linked from the D3 entry; a stored object row loses the code on dynamic configs (currency field and formula), keeps both fixed ones by reference, parses to itself, replays idempotently; the authoring load path does not run it; a stored `{ currencyMode: 'fixed' }` row with no code gets no notice and no change, and the current schema refuses it at `fields.amount.currencyConfig.defaultCurrency` with the prescription. - Updated: `field.test.ts`, `currency-precision-iso4217.test.ts`, `formula-currency-result.test.ts` (the `fixed`-without-code row moves to the refused list), `retired-key-removal-version.pin.test.ts` (JOIN row), `packages/formula/src/formula-return.test.ts`, `ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts`. ## Acceptance notes - The refusal no longer cites ADR-0049 (the narrowing is ruling-driven); it names `@objectstack/spec 18` because `retired-key-removal-version.pin.test.ts` requires every entry retired after the newest stable to name the release it derives. - An authored `defaultCurrency` under `dynamic` is not refused (the order asked for no such narrowing); the conversion strips it only from stored rows and artifacts. - `ui/packages/i18n/src/currency.ts` changes a comment only, so no `@object-ui/i18n` changeset line. - The formula reader stops inferring a fixed CNY currency for a source field the schema now refuses; that field is itself refused at publish. ## Verification All readings on base `main` `6866e2e7c` on a shared container; the lock's VERDICT lines are quoted. Round 1: code head `5f876858f`, head `c172c878e` (a changeset wording commit only: the survey count). **Round 2 head `a7683e16f`** (contract review 6467521422): wording at the stored-row sites, the ADR-0049 citation dropped from the refusal, one new conversion test; readings below marked *(r2)* were taken on it. <details><summary>Round 2 (a7683e1)</summary> - Stored-row probe through `loadMetaFromDb` / `saveMetaItem` (deleted after use), as quoted under **Stored rows and built artifacts, measured**. - `pnpm --filter @objectstack/spec gen:migration-registry && build`, then the conversions, migrations, field, currency and retirement-pin tests → Test Files 31 passed (31), Tests 1274 passed. VERDICT command-exit 0. - `pnpm --filter @objectstack/spec check:generated` → current. Spec vitest `--project local` → Test Files 742 passed (742), Tests 22244 passed | 1 todo (one more than round 1: the new pin). `typecheck` → exit 0. `ui` vitest `packages/i18n/` → Test Files 89 passed (89), Tests 1373 passed | 13 skipped. VERDICT command-exit 0. - Gates re-run on `a7683e16f`: the 34 `--commands` families that read the changeset, the spec package, lint or i18n (including `check-adr-0087-registration --base origin/main`, the changeset families, `check:nul-bytes`) → 34/34 exit 0, VERDICT command-exit 0. The `--commands` list is identical (113 families). The other 79 read nothing this commit touches (formula, platform-objects and the dogfood battery included: no source there changed) and keep their round 1 readings. </details> <details><summary>Measure first (origin/main 6866e2e)</summary> - `CurrencyConfigSchema.safeParse({ currencyMode: 'fixed' })` → `{"currencyMode":"fixed","defaultCurrency":"CNY"}`. - `{ currencyMode: 'dynamic' }` → `{"currencyMode":"dynamic","defaultCurrency":"CNY"}`; `{}` → the same. - A formula field with `returnType: 'currency'` and `{ currencyMode: 'fixed' }` → `{"currencyMode":"fixed","defaultCurrency":"CNY"}`. </details> <details><summary>Tests</summary> - `pnpm --filter @objectstack/spec` vitest `--project local` (after `pnpm --filter @objectstack/spec build`, VERDICT command-exit 0) → Test Files 742 passed (742), Tests 22243 passed | 1 todo. `pnpm --filter @objectstack/spec typecheck` → exit 0 (tsc, scripts-typecheck, test-typecheck OK). - `pnpm --filter @objectstack/formula test` → Test Files 56 passed (56), Tests 1484 passed; `typecheck` → exit 0. - `@objectstack/platform-objects` vitest (after its closure build) → Test Files 96 passed (96), Tests 1361 passed. VERDICT command-exit 0. - `ui` vitest `packages/i18n/` (from the ui root) → Test Files 89 passed (89), Tests 1373 passed | 13 skipped. VERDICT command-exit 0. - `pnpm --filter @objectstack/spec check:generated --fix` → 14 artifacts current after `gen:docs` (re-check green). - Dogfood battery, full (`pnpm --filter @objectstack/dogfood test`, closure built, head `5f876858f`) → Test Files 348 passed | 3 skipped (351), Tests 2804 passed | 20 skipped (2824). VERDICT command-exit 0 · held the lock 1733s. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 113 derived families; the list is identical on `c172c878e`. - 110 exited 0 on the code head's tree. Three were re-run: `pnpm --dir ui exec vitest run scripts/` (first run: one 15 s timeout in `check-vi-mock-inherit.test.ts` while the dogfood held the box; that file alone then 83/83, and the whole gate exit 0 on `c172c878e`), `spec check:skill-examples` and `check:dual-build-cjs-loads` (exit 3, built output missing; exit 0 after building client-react, studio and the other named packages). - The changeset-reading families re-ran on `c172c878e`, all exit 0: `check-adr-0087-registration` (→ `registered currency-config-fixed-default-currency-required, currency-config-dynamic-default-currency-removed (new here: both)`), `check-changeset-no-major`, `check-empty-changeset`, `check-closing-keyword-parity`, `check:changeset-gate-self-tests`, `check:pm-changeset-deadline-census`. - `--ran` → `113 derived famil(ies) accounted for — 113 run, 0 NOT-MEASURED`. - `node scripts/check-nul-bytes.mjs` → exit 0. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzgHpEdH4Nc37mK73zh --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d3a077 commit 0cd578f

27 files changed

Lines changed: 575 additions & 44 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/formula': minor
4+
'@objectstack/platform-objects': minor
5+
---
6+
7+
fix(spec)!: a `fixed` currency config needs its code — `currencyConfig.defaultCurrency` loses its `'CNY'` default, and a `dynamic` config parses with no code
8+
9+
Clause-②: no (narrowing)
10+
Metadata-protocol: narrowed
11+
12+
<!-- adr-0087: registered currency-config-fixed-default-currency-required, currency-config-dynamic-default-currency-removed -->
13+
14+
**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.
15+
16+
- **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.
17+
- **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.
18+
- **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.
19+
- **The one-line fix.** Write the code next to `currencyMode: 'fixed'`.
20+
- **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`.
21+
- **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.
22+
- **`@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.
23+
24+
Survey at the change: 11 `fixed` currency configs across `examples/**`, `packages/platform-objects/**`, `packages/qa/**`, seeds and fixtures, none without a code.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ code lives in `currencyConfig`, never in the value.
215215
| `min` | `number` | — | Minimum value |
216216
| `max` | `number` | — | Maximum value |
217217
| `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` | `'CNY'` | The column's currency code under `fixed`; not the displayed currency under `dynamic` |
218+
| `currencyConfig.defaultCurrency` | `string` | — | The column's currency code under `fixed`, where it is required; not the displayed currency under `dynamic` |
219219

220220
```typescript
221221
{ name: 'price', label: 'Price', type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }

‎content/docs/data-modeling/validation-rules.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ These properties apply to **all** field types and are validated by the base `Fie
153153
| `min` | `number` | — | Minimum monetary value |
154154
| `max` | `number` | — | Maximum monetary value |
155155
| `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 |
156-
| `currencyConfig.defaultCurrency` | `string` | `CNY` | 3-character currency code (ISO 4217 or crypto): the field's currency under `fixed`; not the displayed currency under `dynamic` |
156+
| `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` |
157157

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

‎content/docs/references/data/field.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const result = CurrencyConfigSchema.parse(data);
2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
3131
| **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. |
32-
| **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. |
32+
| **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. |
3333

3434

3535
---
@@ -249,7 +249,7 @@ const result = CurrencyConfigSchema.parse(data);
249249
| Property | Type | Required | Description |
250250
| :--- | :--- | :--- | :--- |
251251
| **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. |
252-
| **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. |
252+
| **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. |
253253

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

‎packages/formula/src/formula-return.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const FIELDS: Readonly<Record<string, FormulaSourceField>> = {
3838
weighted: { type: 'formula', returnType: 'currency', currencyConfig: USD },
3939
count_formula: { type: 'formula', returnType: 'number' },
4040
broken: { type: 'currency', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'US' } },
41+
// `fixed` with no code: the spec refuses it rather than filling in CNY (#27157).
42+
fixed_no_code: { type: 'currency', currencyConfig: { currencyMode: 'fixed' } },
4143
};
4244

4345
const NAMES = Object.keys(FIELDS);
@@ -127,6 +129,8 @@ describe('CONTROL — a numeric formula stays `number`', () => {
127129
'record.flag ? record.amount : record.fee_eur',
128130
// A source whose currency does not parse names no currency.
129131
'record.broken * 2',
132+
// Nor does a fixed source with no code: it is never read as CNY (#27157).
133+
'record.fixed_no_code * 2',
130134
// A roll-up's unit is its child field's, not readable here.
131135
'record.rollup * 2',
132136
'record.amount % 3',

‎packages/formula/src/formula-return.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,16 @@ function sameCurrency(a: MoneyCurrency, b: MoneyCurrency): boolean {
129129
return a.code === b.code;
130130
}
131131

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

141144
/** Is `d` a number that is not an amount (a quantity or a constant)? */

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
618618
},
619619
"currencyConfig.defaultCurrency": {
620620
label: "Default Currency",
621-
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."
621+
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."
622622
},
623623
step: {
624624
label: "Step",

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
618618
},
619619
"currencyConfig.defaultCurrency": {
620620
label: "Moneda predeterminada",
621-
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."
621+
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."
622622
},
623623
step: {
624624
label: "Incremento",

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
618618
},
619619
"currencyConfig.defaultCurrency": {
620620
label: "既定の通貨",
621-
helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。既定は CNY です。dynamic モードでは参照されません。"
621+
helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。fixed モードでは必須で、自動では補われません。dynamic モードでは参照されません。"
622622
},
623623
step: {
624624
label: "ステップ値",

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
618618
},
619619
"currencyConfig.defaultCurrency": {
620620
label: "默认货币",
621-
helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。默认为 CNY。dynamic 模式下不读取。"
621+
helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。fixed 模式下必填,不会自动填入。dynamic 模式下不读取。"
622622
},
623623
step: {
624624
label: "步进值",

0 commit comments

Comments
 (0)