diff --git a/.changeset/27184-currency-record-mode.md b/.changeset/27184-currency-record-mode.md new file mode 100644 index 00000000000..18188a665c9 --- /dev/null +++ b/.changeset/27184-currency-record-mode.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': minor +'@object-ui/i18n': minor +'@objectstack/platform-objects': minor +--- + +feat(spec): `currencyMode: 'record'` + `currencyField` — a record-mode amount points at a sibling ISO 4217 code field (ADR-0144 D2) + +Clause-②: yes (widening) +Metadata-protocol: added + +A currency amount (or a `formula` with `returnType: 'currency'`) can now carry a currency per record. `currencyConfig.currencyMode` gains a third value, `record`, and `currencyConfig.currencyField` names the field on the same object that holds each record's ISO 4217 code. The amount itself stays a bare number (ADR-0104 D1). + +```ts +fields: { + currency_code: Field.text({ label: 'Currency', valueDomain: 'iso_4217_currency' }), + amount: Field.currency({ label: 'Amount', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }), +} +``` + +- `currencyField` is required under `record`, refused under `dynamic` and `fixed`, and never empty; `defaultCurrency` is refused under `record`. +- The object refuses a `currencyField` that names no other declared field, a field that is not `text`, or a `text` field without `valueDomain: 'iso_4217_currency'`. Each refusal is located at `fields.FIELD.currencyConfig.currencyField` and lists the object's candidate code fields. +- `dynamic` and `fixed` configs parse exactly as before; an app that never writes `record` sees no change. + +⛔ Not released alone: this ships in the same release as the record-mode aggregate refusals and the per-row display. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 1d530f90727..b4b64c754a5 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -28,8 +28,9 @@ 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 | 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. | +| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | 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. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). | +| **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, and refused under `record`, where each record names its own. | +| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. | --- @@ -89,7 +90,7 @@ const result = CurrencyConfigSchema.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -236,8 +237,9 @@ 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 | 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. | +| **currencyMode** | `Enum<'dynamic' \| 'fixed' \| 'record'>` | 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. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1). | +| **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, and refused under `record`, where each record names its own. | +| **currencyField** | `string` | optional | `record` mode only, and required there: the name of the field on the same object that holds each record's currency — a `text` field declaring `valueDomain: 'iso_4217_currency'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty. | ### Nested Shape: `Field.conditionalFormatting[number]` diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 86c559d0301..c0e83af4ed0 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -268,7 +268,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -631,7 +631,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 485c6eaae0f..c034e66a503 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -102,7 +102,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -533,7 +533,7 @@ Add a new field to an existing object | **summaryOperations** | `{ object: string; field: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed'>; defaultCurrency?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 159c494fc3a..4416ef64977 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -618,15 +618,19 @@ export const enMetadataForms: NonNullable = { }, currencyConfig: { label: "Currency Config", - helpText: "Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in either mode." + helpText: "Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in every mode." }, "currencyConfig.currencyMode": { label: "Currency Mode", - helpText: "dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency." + helpText: "dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency. record: each record has its own currency, stored in the field currencyField names." + }, + "currencyConfig.currencyField": { + label: "Currency Field", + helpText: "Record mode only, and required there: the field on this object holding each record's ISO 4217 code — a text field with valueDomain iso_4217_currency, conventionally currency_code." }, "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). Required in fixed mode; nothing fills one in. 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; refused in record mode." }, step: { label: "Step", diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index d9174d803fc..c0056418ef8 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -618,15 +618,19 @@ export const esESMetadataForms: NonNullable = }, currencyConfig: { label: "Configuración de moneda", - helpText: "En qué moneda está este campo: un campo de moneda, o una fórmula cuyo tipo de retorno es Moneda. Sin definir: modo dynamic. En ambos modos el valor es un número sin más." + helpText: "En qué moneda está este campo: un campo de moneda, o una fórmula cuyo tipo de retorno es Moneda. Sin definir: modo dynamic. En todos los modos el valor es un número sin más." }, "currencyConfig.currencyMode": { label: "Modo de moneda", - helpText: "dynamic (predeterminado): el campo no tiene moneda propia y los importes se muestran en la moneda predeterminada del tenant (el ajuste localization.currency). fixed: el campo tiene una única moneda, defaultCurrency." + helpText: "dynamic (predeterminado): el campo no tiene moneda propia y los importes se muestran en la moneda predeterminada del tenant (el ajuste localization.currency). fixed: el campo tiene una única moneda, defaultCurrency. record: cada registro tiene su propia moneda, guardada en el campo que indica currencyField." + }, + "currencyConfig.currencyField": { + label: "Campo de moneda", + helpText: "Solo en modo record, y obligatorio en él: el campo de este objeto que guarda el código ISO 4217 de cada registro — un campo de texto con valueDomain iso_4217_currency, por convención currency_code." }, "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). Obligatorio en modo fixed; no se rellena automáticamente. 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; se rechaza en modo record." }, step: { label: "Incremento", diff --git a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts index 7d4d617c6da..280c7c7ccbe 100644 --- a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts @@ -708,9 +708,11 @@ describe('#19403 round 4 — the blind spot, FOURTH shape: a type-level coverage // locales, so the echo rule above reads them translated, not undecided. // 10 since #23735: the `referenceDeletedWhen` composite (`field`, `value`), // whose four string leaves that card authored in all three locales. - expect(UNWALKED_CHILDREN.length).toBe(10); + // 11 since #27184: `currencyConfig.currencyField` (the record mode's code + // field), its two string leaves authored in all three locales. + expect(UNWALKED_CHILDREN.length).toBe(11); for (const child of [ - 'currencyConfig.currencyMode', 'currencyConfig.defaultCurrency', 'storage.notNull', + 'currencyConfig.currencyMode', 'currencyConfig.defaultCurrency', 'currencyConfig.currencyField', 'storage.notNull', 'referenceDeletedWhen.field', 'referenceDeletedWhen.value', ]) { expect(UNWALKED_CHILDREN).toContain(child); diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index b2217261dcd..c2cf601fb12 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -618,15 +618,19 @@ export const jaJPMetadataForms: NonNullable = }, currencyConfig: { label: "通貨設定", - helpText: "このフィールドの通貨(通貨フィールド、または戻り値型が「通貨」の数式フィールド)。未設定の場合は dynamic モードです。どちらのモードでも、値は単なる数値です。" + helpText: "このフィールドの通貨(通貨フィールド、または戻り値型が「通貨」の数式フィールド)。未設定の場合は dynamic モードです。どのモードでも、値は単なる数値です。" }, "currencyConfig.currencyMode": { label: "通貨モード", - helpText: "dynamic(既定):フィールド自体は通貨を持たず、金額はテナントの既定通貨(localization.currency 設定)で表示されます。fixed:フィールドは 1 つの通貨 defaultCurrency を持ちます。" + helpText: "dynamic(既定):フィールド自体は通貨を持たず、金額はテナントの既定通貨(localization.currency 設定)で表示されます。fixed:フィールドは 1 つの通貨 defaultCurrency を持ちます。record:各レコードが独自の通貨を持ち、currencyField が指すフィールドに保存されます。" + }, + "currencyConfig.currencyField": { + label: "通貨フィールド", + helpText: "record モード専用で、同モードでは必須:各レコードの ISO 4217 通貨コードを保持する、このオブジェクト上のフィールド。valueDomain iso_4217_currency を宣言したテキストフィールドで、慣例名は currency_code。" }, "currencyConfig.defaultCurrency": { label: "既定の通貨", - helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。fixed モードでは必須で、自動では補われません。dynamic モードでは参照されません。" + helpText: "fixed モードのフィールドの通貨。3 文字の ISO 4217 コード(例:USD、EUR)で指定します。fixed モードでは必須で、自動では補われません。dynamic モードでは参照されず、record モードでは指定できません。" }, step: { label: "ステップ値", diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 2110024bdef..c11f730355b 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1240,7 +1240,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // authored in all three locales. // 694 since #26674: the field form no longer offers `externalId` (graded // `dead`) — one row label fewer, in all three locales. - expect(translated.length, `${locale} positive control`).toBe(694); + // 695 since #27184: the field form's `currencyConfig.currencyField` row + // (record currency mode) — one row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(695); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 60c83c9eefe..6ed55dee301 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -618,15 +618,19 @@ export const zhCNMetadataForms: NonNullable = }, currencyConfig: { label: "货币配置", - helpText: "此字段使用哪种货币——货币字段,或返回类型为“货币”的公式字段。未设置时为 dynamic 模式。两种模式下值都是一个纯数字。" + helpText: "此字段使用哪种货币——货币字段,或返回类型为“货币”的公式字段。未设置时为 dynamic 模式。任何模式下值都是一个纯数字。" }, "currencyConfig.currencyMode": { label: "货币模式", - helpText: "dynamic(默认):字段本身没有货币,金额以租户默认货币显示(localization.currency 设置)。fixed:字段只有一种货币,即 defaultCurrency。" + helpText: "dynamic(默认):字段本身没有货币,金额以租户默认货币显示(localization.currency 设置)。fixed:字段只有一种货币,即 defaultCurrency。record:每条记录有自己的货币,保存在 currencyField 指向的字段中。" + }, + "currencyConfig.currencyField": { + label: "币种字段", + helpText: "仅用于 record 模式,且在该模式下必填:本对象上保存每条记录 ISO 4217 币种代码的字段——一个声明了 valueDomain iso_4217_currency 的文本字段,惯例命名为 currency_code。" }, "currencyConfig.defaultCurrency": { label: "默认货币", - helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。fixed 模式下必填,不会自动填入。dynamic 模式下不读取。" + helpText: "fixed 模式字段的唯一货币,使用三个字符的 ISO 4217 代码(例如 USD、EUR)。fixed 模式下必填,不会自动填入。dynamic 模式下不读取;record 模式下不允许设置。" }, step: { label: "步进值", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 31a5e674f68..7d807145820 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -101,6 +101,7 @@ "data/Cube:sql", "data/Cube:title", "data/CubeJoin:name", + "data/CurrencyConfig:currencyField", "data/CurrencyConfig:currencyMode", "data/CurrencyConfig:defaultCurrency", "data/DataEngineAggregateOptions:aggregations", diff --git a/packages/spec/src/data/currency-config-record-mode.test.ts b/packages/spec/src/data/currency-config-record-mode.test.ts new file mode 100644 index 00000000000..72d0a2a98c7 --- /dev/null +++ b/packages/spec/src/data/currency-config-record-mode.test.ts @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D2] `currencyMode: 'record'` + `currencyField` — a record-mode + * amount points at a sibling ISO 4217 code field on the same object. + * + * Two doors, one per half: `CurrencyConfigSchema` refuses the half one config + * can see (`record` without a pointer or with a `defaultCurrency`, a pointer + * under another mode, an empty pointer), and the + * object's `superRefine` refuses what the pointer names (nothing, a non-text + * field, a text field with no ISO 4217 value domain). A `formula` declaring + * `returnType: 'currency'` carries the same config, so it meets both doors. + * `dynamic` and `fixed` parse exactly as they did before `record` existed. + */ + +import { describe, it, expect } from 'vitest'; + +import { CurrencyConfigSchema, FieldSchema } from './field.zod'; +import { ObjectSchema } from './object.zod'; + +const CODE_FIELD = { type: 'text', label: 'Currency', valueDomain: 'iso_4217_currency' } as const; +const RECORD = { currencyMode: 'record', currencyField: 'currency_code' } as const; + +function invoice(fields: Record): Record { + return { name: 'invoice', label: 'Invoice', fields }; +} + +/** The issues of a refused object parse, reduced to what an author is shown. */ +function objectIssues(input: unknown): Array<{ code: string; path: PropertyKey[]; message: string }> { + const r = ObjectSchema.safeParse(input); + if (r.success) return []; + return r.error.issues.map((i) => ({ code: i.code, path: i.path, message: i.message })); +} + +describe('`CurrencyConfigSchema`: `record` ⇔ `currencyField`', () => { + it('a `record` config with a pointer parses, keeping the mode and the pointer', () => { + const r = CurrencyConfigSchema.safeParse(RECORD); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.currencyMode).toBe('record'); + expect(r.data.currencyField).toBe('currency_code'); + }); + + it('refuses `record` without `currencyField`, at the key, with the prescription', () => { + const r = CurrencyConfigSchema.safeParse({ currencyMode: 'record' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.code).toBe('custom'); + expect(r.error.issues[0]!.path).toEqual(['currencyField']); + expect(r.error.issues[0]!.message).toMatch(/^`currencyMode: 'record'` needs `currencyField`/); + }); + + it.each(['dynamic', 'fixed'] as const)('refuses `currencyField` under `%s`, at the key', (mode) => { + const r = CurrencyConfigSchema.safeParse({ currencyMode: mode, defaultCurrency: 'USD', currencyField: 'currency_code' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.path).toEqual(['currencyField']); + expect(r.error.issues[0]!.message).toMatch(/^`currencyField` is read only under `currencyMode: 'record'`/); + }); + + it('refuses an empty `currencyField`, at the key, in every mode', () => { + for (const mode of ['record', 'dynamic', 'fixed'] as const) { + const config = mode === 'fixed' ? { currencyMode: mode, defaultCurrency: 'USD', currencyField: '' } : { currencyMode: mode, currencyField: '' }; + const r = CurrencyConfigSchema.safeParse(config); + expect(r.success, mode).toBe(false); + if (r.success) continue; + // Under `dynamic` / `fixed` the out-of-mode refusal is told too, at the same key. + expect(r.error.issues.length, mode).toBe(mode === 'record' ? 1 : 2); + expect(r.error.issues.every((i) => i.path.join('.') === 'currencyField'), mode).toBe(true); + expect(r.error.issues[0]!.message).toMatch(/^`currencyField` names a field: an empty name points at nothing/); + } + }); + + it('refuses `defaultCurrency` under `record`, at the key, with the prescription', () => { + const r = CurrencyConfigSchema.safeParse({ ...RECORD, defaultCurrency: 'EUR' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.code).toBe('custom'); + expect(r.error.issues[0]!.path).toEqual(['defaultCurrency']); + expect(r.error.issues[0]!.message).toMatch(/^`defaultCurrency` is not read under `currencyMode: 'record'`/); + // …and `dynamic` keeps tolerating its inert code, for the rows #27157 left behind. + expect(CurrencyConfigSchema.safeParse({ currencyMode: 'dynamic', defaultCurrency: 'EUR' }).success).toBe(true); + }); + + it('refuses `currencyField` under the defaulted mode (no `currencyMode` written ⇒ dynamic)', () => { + expect(CurrencyConfigSchema.safeParse({ currencyField: 'currency_code' }).success).toBe(false); + }); + + it('`dynamic` and `fixed` outputs are byte-identical to before `record` existed', () => { + const cases: Array<[unknown, string]> = [ + [{}, '{"currencyMode":"dynamic"}'], + [{ currencyMode: 'dynamic' }, '{"currencyMode":"dynamic"}'], + [{ currencyMode: 'dynamic', defaultCurrency: 'EUR' }, '{"currencyMode":"dynamic","defaultCurrency":"EUR"}'], + [{ currencyMode: 'fixed', defaultCurrency: 'USD' }, '{"currencyMode":"fixed","defaultCurrency":"USD"}'], + ]; + for (const [input, before] of cases) { + expect(JSON.stringify(CurrencyConfigSchema.parse(input))).toBe(before); + } + }); + + it('parse is idempotent: `parse(parse(x))` equals `parse(x)` in every mode', () => { + for (const input of [{}, { currencyMode: 'fixed', defaultCurrency: 'USD' }, RECORD]) { + const once = CurrencyConfigSchema.parse(input); + expect(CurrencyConfigSchema.parse(once)).toEqual(once); + } + const object = invoice({ currency_code: CODE_FIELD, amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD } }); + const once = ObjectSchema.parse(object); + expect(ObjectSchema.parse(once)).toEqual(once); + }); +}); + +describe('the object judges what `currencyField` names', () => { + it('accepts a pointer at a declared `text` field with `valueDomain: iso_4217_currency`', () => { + const object = invoice({ currency_code: CODE_FIELD, amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD } }); + expect(objectIssues(object)).toEqual([]); + }); + + it('refuses a pointer into nothing, naming the candidates', () => { + const issues = objectIssues(invoice({ + doc_currency: CODE_FIELD, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, but object `invoice` declares no other field/); + expect(issues[0]!.message).toContain('candidates: `doc_currency`'); + }); + + it('refuses a pointer at the amount itself', () => { + const issues = objectIssues(invoice({ + currency_code: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues.map((i) => i.path)).toEqual([['fields', 'currency_code', 'currencyConfig', 'currencyField']]); + expect(issues[0]!.message).toContain('the object has none; declare one'); + }); + + it('refuses a pointer at a field that is not `text`', () => { + const issues = objectIssues(invoice({ + currency_code: { type: 'number', label: 'Currency' }, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `number` field/); + }); + + it('refuses a pointer at a `text` field without the ISO 4217 value domain', () => { + const issues = objectIssues(invoice({ + currency_code: { type: 'text', label: 'Currency' }, + amount: { type: 'currency', label: 'Amount', currencyConfig: RECORD }, + })); + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toEqual(['fields', 'amount', 'currencyConfig', 'currencyField']); + expect(issues[0]!.message).toMatch(/^`amount\.currencyConfig\.currencyField` names `currency_code`, a `text` field .* does not declare `valueDomain: 'iso_4217_currency'`/); + }); + + it('a `formula` with `returnType: currency` meets the same doors in the same shape', () => { + const formula = (currencyConfig: unknown) => ({ + type: 'formula', label: 'Net', expression: 'record.amount * 0.9', returnType: 'currency', currencyConfig, + }); + expect(FieldSchema.safeParse({ name: 'net', ...formula(RECORD) }).success).toBe(true); + expect(FieldSchema.safeParse({ name: 'net', ...formula({ currencyMode: 'record' }) }).success).toBe(false); + const ok = invoice({ currency_code: CODE_FIELD, net: formula(RECORD) }); + expect(objectIssues(ok)).toEqual([]); + const bad = invoice({ currency_code: { type: 'text', label: 'Currency' }, net: formula(RECORD) }); + expect(objectIssues(bad).map((i) => i.path)).toEqual([['fields', 'net', 'currencyConfig', 'currencyField']]); + }); +}); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index b1fdf31228b..ec717d59816 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -107,13 +107,16 @@ export const fieldForm = defineForm({ field: 'currencyConfig', type: 'composite', visibleWhen: "data.type == 'currency' || (data.type == 'formula' && data.returnType == 'currency')", - helpText: 'Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in either mode.', + helpText: 'Which currency this field is in — a currency field, or a formula whose return type is Currency. Unset: dynamic mode. The value is a bare number in every mode.', fields: [ - { field: 'currencyMode', type: 'select', helpText: 'dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency.', options: [ + { field: 'currencyMode', type: 'select', helpText: 'dynamic (the default): the field has no currency of its own, and amounts display in the tenant default currency (the localization.currency setting). fixed: the field has one currency, defaultCurrency. record: each record has its own currency, stored in the field currencyField names.', options: [ { label: 'Dynamic — the tenant default currency', value: 'dynamic' }, { label: 'Fixed — one currency for this field', value: 'fixed' }, + { label: 'Record — each record names its currency', value: 'record' }, ] }, - { field: 'defaultCurrency', type: 'text', 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.' }, + // ADR-0144 D2 — the record-mode pointer. + { field: 'currencyField', type: 'text', helpText: 'Record mode only, and required there: the field on this object holding each record\'s ISO 4217 code — a text field with valueDomain iso_4217_currency, conventionally currency_code.' }, + { field: 'defaultCurrency', type: 'text', 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; refused in record mode.' }, ], }, // Every `visibleWhen` below is a MEANINGFULNESS gate, not a parse gate: diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 85e3316efb8..3b601830760 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -534,6 +534,50 @@ const CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE: Readonly> + ' Delete the key.', }; +/** + * [ADR-0144 D2] `currencyMode: 'record'` ⇔ `currencyField`, and no + * `defaultCurrency` beside it. A `record`-mode amount has no currency until a + * pointer names the sibling code field; a pointer under `dynamic` / `fixed` is + * read by nothing, and neither is a `defaultCurrency` under `record` — each is + * the declared-but-inert shape ADR-0078 keeps out. `record` is new, so no + * stored row carries the second one (unlike `dynamic`, whose inert code is + * tolerated for the rows #27157 left behind). One located issue per inert or + * missing key; what the pointer NAMES is the object's to judge (object.zod.ts). + */ +function refuseRecordModeKeyMismatch( + config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string }, + ctx: z.RefinementCtx, +): void { + if (config.currencyMode === 'record' && config.defaultCurrency !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['defaultCurrency'], + message: + `\`defaultCurrency\` is not read under \`currencyMode: 'record'\`, where each record names its own currency in ` + + 'the field `currencyField` names: delete `defaultCurrency`, or use `currencyMode: \'fixed\'` if the field has ' + + 'one currency.', + }); + } + if (config.currencyMode === 'record' && config.currencyField === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['currencyField'], + message: + '`currencyMode: \'record\'` needs `currencyField`: name the field on this object that holds each ' + + 'record\'s ISO 4217 code — a `text` field with `valueDomain: \'iso_4217_currency\'`, conventionally ' + + '`currency_code` — or use `currencyMode: \'fixed\'` (one currency) or `\'dynamic\'` (the tenant default).', + }); + } else if (config.currencyMode !== 'record' && config.currencyField !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['currencyField'], + message: + `\`currencyField\` is read only under \`currencyMode: 'record'\`, and this config is \`${config.currencyMode}\`: ` + + 'set `currencyMode: \'record\'` if each record carries its own currency, or delete `currencyField`.', + }); + } +} + /** * The refusal a `fixed` currency config without a code meets (#27157, ruling * 6464202770 on #27119, Q2 = X). Until this ruling the schema filled the code @@ -547,7 +591,7 @@ const CURRENCY_CONFIG_FIXED_WITHOUT_CODE = /** `fixed` ⇒ an authored `defaultCurrency`; `dynamic` reads none, so it needs none. */ function refuseFixedCurrencyWithoutCode( - config: { currencyMode: 'dynamic' | 'fixed'; defaultCurrency?: string }, + config: { currencyMode: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string }, ctx: z.RefinementCtx, ): void { if (config.currencyMode === 'fixed' && config.defaultCurrency === undefined) { @@ -590,14 +634,22 @@ export const CurrencyConfigSchema = lazySchema(() => strictObject({ aliases: { mode: 'currencyMode', currency: 'defaultCurrency', code: 'defaultCurrency', isoCode: 'defaultCurrency' }, guidance: CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE, }, { - 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).optional().describe('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.'), + currencyMode: z.enum(['dynamic', 'fixed', 'record']).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. `record`: each record has its own currency, the ISO 4217 code stored in the sibling field `currencyField` names; `defaultCurrency` is refused. The value is a bare number in every mode (ADR-0104 D1).'), + defaultCurrency: z.string().length(3).optional().describe('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, and refused under `record`, where each record names its own.'), + // ADR-0144 D2 — the pointer of a `record`-mode amount. Optional at the key + // level and never empty; `refuseRecordModeKeyMismatch` below makes it + // required under `record` and refuses it under the other two modes, and the + // object's `superRefine` (`refuseDishonestRecordCurrencyField`, + // object.zod.ts) checks what it names, because only the object holds the + // sibling field map. + currencyField: z.string().min(1, '`currencyField` names a field: an empty name points at nothing — name the field on this object that holds each record\'s ISO 4217 code, conventionally `currency_code`.').optional().describe('`record` mode only, and required there: the name of the field on the same object that holds each record\'s currency — a `text` field declaring `valueDomain: \'iso_4217_currency\'` (conventionally `currency_code`). Refused under `dynamic` and `fixed`, and never empty.'), // #19992 — no `.overwrite()` here any more: the #7918 ISO 4217 contradiction // check and the #11423 default-materializing `.overwrite()` both existed only // for the removed `precision` key (see CURRENCY_CONFIG_DECIMAL_PLACES_GUIDANCE - // above). #27157 — the one refinement left is `fixed` ⇒ a code, below; parse - // output is `{ currencyMode }` plus the `defaultCurrency` the author wrote. -}).superRefine(refuseFixedCurrencyWithoutCode)); + // above). #27157 — `fixed` ⇒ a code; ADR-0144 D2 — `record` ⇔ a pointer and + // no code, both below. Parse output is `{ currencyMode }` plus the `defaultCurrency` and + // `currencyField` the author wrote. +}).superRefine(refuseFixedCurrencyWithoutCode).superRefine(refuseRecordModeKeyMismatch)); /** * Address Schema — structured address for the `address` field type. diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index d1113ead737..edf34334319 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { FieldSchema, FieldType } from './field.zod'; +import { FieldSchema, FieldType, VALUE_DOMAIN_FIELD_TYPES } from './field.zod'; import { ValidationRuleSchema } from './validation.zod'; import { ActionSchema, type ActionParam } from '../ui/action.zod'; import { ObjectListViewSchema } from '../ui/view.zod'; @@ -1837,6 +1837,91 @@ function refuseNonPictureImageField( }); } +/** + * [ADR-0144 D2] A `record`-mode amount's `currencyConfig.currencyField` names + * the field THIS object declares that holds each record's currency: a `text` + * field carrying `valueDomain: 'iso_4217_currency'`. The amount stays a bare + * number (ADR-0104 D1); the code field is the only place its currency lives, + * so a pointer into nothing, into a non-text field or into an unconstrained + * text field would parse and give the amount a currency no write ever checks. + * + * `CurrencyConfigSchema` sees one config at a time and refuses the half it + * can see (a `record` config with no pointer, a pointer under another mode); + * what the pointer names is judged here, beside + * {@link refuseNonPictureImageField}, for the same reason: only the object + * holds the field map. Judged for every field whose config is `record`, so a + * `formula` declaring `returnType: 'currency'` meets it in the same shape. + * + * Three refusals, one located issue per offending field at + * `['fields', , 'currencyConfig', 'currencyField']`, each carrying the + * prescription and the object's candidate fields: + * + * - the name is not a declared field (or is the amount itself); + * - the field's type is not one that may declare a value domain + * (`VALUE_DOMAIN_FIELD_TYPES`, field.zod.ts — `text` alone today; the ADR's + * "text field" rests on that set, so this door reads it rather than + * repeating the literal); + * - the field is of such a type without `valueDomain: 'iso_4217_currency'`. + * + * Judged against the AUTHORED field map. Not judged on + * `ObjectExtensionSchema`: an extension's fields merge into an object it does + * not hold, so its pointer may name a field only the owner declares. + */ +function refuseDishonestRecordCurrencyField( + ownName: unknown, + fields: unknown, + ctx: z.RefinementCtx, +): void { + if (fields === null || typeof fields !== 'object') return; + const objectName = typeof ownName === 'string' ? ownName : ''; + const map = fields as Record; + const isCodeType = (type: unknown): boolean => typeof type === 'string' && VALUE_DOMAIN_FIELD_TYPES.has(type); + const codeTypes = [...VALUE_DOMAIN_FIELD_TYPES].map((t) => `\`${t}\``).join(' or '); + const isCodeField = (f: (typeof map)[string]): boolean => + isCodeType(f?.type) && f?.valueDomain === 'iso_4217_currency'; + for (const [name, field] of Object.entries(map)) { + const config = field?.currencyConfig as { currencyMode?: unknown; currencyField?: unknown } | undefined; + if (config?.currencyMode !== 'record' || typeof config.currencyField !== 'string') continue; + const target = config.currencyField; + const candidates = Object.keys(map).filter((k) => k !== name && isCodeField(map[k]!)); + const prescription = + `\`currencyField\` must name a ${codeTypes} field of this object declaring \`valueDomain: 'iso_4217_currency'\` — ` + + (candidates.length > 0 + ? `candidates: ${candidates.map((c) => `\`${c}\``).join(', ')}.` + : 'the object has none; declare one, conventionally `currency_code: Field.text({ valueDomain: \'iso_4217_currency\' })`.'); + const path = ['fields', name, 'currencyConfig', 'currencyField']; + const declared = target !== name && Object.prototype.hasOwnProperty.call(map, target); + if (!declared) { + ctx.addIssue({ + code: 'custom', + path, + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, but object \`${objectName}\` declares no ` + + `other field \`${target}\`. ${prescription}`, + }); + continue; + } + const type = map[target]?.type; + if (!isCodeType(type)) { + ctx.addIssue({ + code: 'custom', + path, + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`${String(type)}\` field on object ` + + `\`${objectName}\`; a record currency is an ISO 4217 code in a ${codeTypes} field. ${prescription}`, + }); + continue; + } + if (!isCodeField(map[target]!)) { + ctx.addIssue({ + code: 'custom', + path, + message: `\`${name}.currencyConfig.currencyField\` names \`${target}\`, a \`${String(type)}\` field on object \`${objectName}\` ` + + `that does not declare \`valueDomain: 'iso_4217_currency'\`, so a write could store any string as the ` + + `currency. Add \`valueDomain: 'iso_4217_currency'\` to \`${target}\`, or point at another field. ${prescription}`, + }); + } + } +} + /** * [objectui#12489 ruling 6203737086, letter A] A field's * `referenceDeletedWhen: { field, value }` names a SIBLING `select` field on @@ -3482,6 +3567,9 @@ const ObjectSchemaBase = strictObject( // object — the same door, for the same reason: only the object holds the // field map the pointer resolves against. refuseNonPictureImageField(object.name, object.imageField, object.fields, ctx); + // [ADR-0144 D2] A `record`-mode amount's `currencyField` names a declared + // ISO 4217 `text` sibling — the same door: only the object holds the map. + refuseDishonestRecordCurrencyField(object.name, object.fields, ctx); // [objectui#12489 ruling 6203737086, letter A] A `referenceDeletedWhen` // predicate names a declared single-value `select` sibling and one of its // options — the same door: only the object holds the sibling map. diff --git a/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json b/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json index 36d077697e0..056303ba6d1 100644 --- a/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json +++ b/scripts/adr-anchors/packages__spec__src__data__field.zod.ts.json @@ -1,7 +1,8 @@ { "file": "packages/spec/src/data/field.zod.ts", "adrs": [ - "ADR-0120" + "ADR-0120", + "ADR-0144" ], - "invariant": "`UniqueScopeSchema` is the closed scope vocabulary `boolean | 'global' | 'organization'` (ADR-0120 D1). `'organization'` is the explicit synonym of field-level `true` — identical materialization, and `isUniqueDeclared` MUST count it (a word the vocabulary accepts but no driver reads is declarable-but-inert, ADR-0078). `'tenant'` and `'org'` are rejected, never aliased — the parse error names `'organization'` (§Terminology; PD #12: one contract, no dialects). The single-tenant story is D3's NULL-safe COALESCE bucket, NOT the retired 'composite degenerates to single-column' claim #5030 falsified." + "invariant": "`UniqueScopeSchema` is the closed scope vocabulary `boolean | 'global' | 'organization'` (ADR-0120 D1). `'organization'` is the explicit synonym of field-level `true` — identical materialization, and `isUniqueDeclared` MUST count it (a word the vocabulary accepts but no driver reads is declarable-but-inert, ADR-0078). `'tenant'` and `'org'` are rejected, never aliased — the parse error names `'organization'` (§Terminology; PD #12: one contract, no dialects). The single-tenant story is D3's NULL-safe COALESCE bucket, NOT the retired 'composite degenerates to single-column' claim #5030 falsified.\n\nADR-0144 D2 — `CurrencyConfigSchema` has a third mode, `record`: the amount stays a bare number (ADR-0104 D1) and `currencyField` names the sibling field holding each record's ISO 4217 code. The config door (`refuseRecordModeKeyMismatch`) requires a non-empty `currencyField` under `record`, refuses it under `dynamic` / `fixed`, and refuses `defaultCurrency` under `record`. Each refused key would be declared but read by nothing (ADR-0078). Do NOT give `record` a `defaultCurrency` fallback or a platform-chosen code field name: per D8 rules 2 and 4, the code is the record's own, the field is the author's, and nothing fills either in. What the pointer NAMES is judged on the object (object.zod.ts), not here." } diff --git a/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json b/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json index 5b081c48b6b..76300265b17 100644 --- a/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json +++ b/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json @@ -2,7 +2,8 @@ "file": "packages/spec/src/data/object.zod.ts", "adrs": [ "ADR-0055", - "ADR-0120" + "ADR-0120", + "ADR-0144" ], - "invariant": "ADR-0055 §1 — a `controlled_by_parent` object derives its master from EXACTLY ONE `master_detail` field declared `required: true`, and the parse refuses anything else (`refuseControlledByParentWithoutOneMaster`, in `ObjectSchemaBase`'s superRefine): no such field (a non-required `master_detail`, a required `lookup`, nothing) at `sharingModel`, two or more at `fields`. It runs on the PARSE, not only in `ObjectSchema.create()`, because the shapes it refuses were reachable through every door that skips the builder and the security runtime derives no master from them — so an object the parse accepted would be denied every read and refused every write. Do NOT move it back to `create()` only, and do NOT accept a second tier here (any `master_detail`, a required `lookup`): the runtime's two fallback tiers were retired under ADR-0049 with the step-18 semantic migration `cbp-master-relation-tiers-retired`, and a parse wider than the runtime is the three-seam disagreement that retirement closed.\n\n`IndexSchema.unique` scope contract (ADR-0120 D1, amending #3696): `'global'` = verbatim columns (no organization column injected); `'organization'` = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration; bare `true` = REFUSED since protocol 18 (#5082, ADR-0120 D7) with a prescription naming both words — it was the positional spelling of `'global'`; stored and built metadata converts it to `'global'` through the ADR-0087 conversion `declared-index-unique-scope` (byte-identical physical index), retired from the authoring funnel so a live author is refused, never converted silently. Field-level `unique: true` stays valid (D1) — never narrow `UniqueScopeSchema` in step with this one. Do NOT re-broaden the describe() back to 'true and global are synonyms, list the tenant column yourself' — that is the #4986 trap, and the hand-written composite is not NULL-safe (#5030)." + "invariant": "ADR-0055 §1 — a `controlled_by_parent` object derives its master from EXACTLY ONE `master_detail` field declared `required: true`, and the parse refuses anything else (`refuseControlledByParentWithoutOneMaster`, in `ObjectSchemaBase`'s superRefine): no such field (a non-required `master_detail`, a required `lookup`, nothing) at `sharingModel`, two or more at `fields`. It runs on the PARSE, not only in `ObjectSchema.create()`, because the shapes it refuses were reachable through every door that skips the builder and the security runtime derives no master from them — so an object the parse accepted would be denied every read and refused every write. Do NOT move it back to `create()` only, and do NOT accept a second tier here (any `master_detail`, a required `lookup`): the runtime's two fallback tiers were retired under ADR-0049 with the step-18 semantic migration `cbp-master-relation-tiers-retired`, and a parse wider than the runtime is the three-seam disagreement that retirement closed.\n\n`IndexSchema.unique` scope contract (ADR-0120 D1, amending #3696): `'global'` = verbatim columns (no organization column injected); `'organization'` = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration; bare `true` = REFUSED since protocol 18 (#5082, ADR-0120 D7) with a prescription naming both words — it was the positional spelling of `'global'`; stored and built metadata converts it to `'global'` through the ADR-0087 conversion `declared-index-unique-scope` (byte-identical physical index), retired from the authoring funnel so a live author is refused, never converted silently. Field-level `unique: true` stays valid (D1) — never narrow `UniqueScopeSchema` in step with this one. Do NOT re-broaden the describe() back to 'true and global are synonyms, list the tenant column yourself' — that is the #4986 trap, and the hand-written composite is not NULL-safe (#5030).\n\nADR-0144 D2 — a `record`-mode amount's `currencyConfig.currencyField` must name ANOTHER declared field of the same object whose type may carry a value domain (`VALUE_DOMAIN_FIELD_TYPES`, `text` today) and which declares `valueDomain: 'iso_4217_currency'` (`refuseDishonestRecordCurrencyField`, in `ObjectSchemaBase`'s superRefine beside `refuseNonPictureImageField`). It runs on the parse because only the object holds the sibling field map, and it covers a `formula` with `returnType: 'currency'` in the same shape. Do NOT accept a pointer into nothing, at a non-code field, or at a text field without the domain: each lets a write store an amount whose currency is any string or none. It is deliberately not run on `ObjectExtensionSchema`, whose pointer may name a field only the owner declares." } diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 4f929d6c349..2e9cf5472ed 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -172,19 +172,17 @@ Stored as JSON on the parent row — no separate table / FK: ```typescript { type: 'currency', - currencyConfig: { - currencyMode: 'fixed', // 'fixed' = the column is pinned to - // defaultCurrency; 'dynamic' = tenant default - defaultCurrency: 'USD', // ISO 4217 - }, + currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }, // ISO 4217 } +// per record: { currencyMode: 'record', currencyField: 'currency_code' } ``` -**Currency resolution.** The value is a bare number in both modes (ADR-0104 -D1), never `{ value, currency }`. The displayed symbol: under `'fixed'`, -`currencyConfig.defaultCurrency`; under `'dynamic'` (default), the tenant -`localization.currency` setting, not `defaultCurrency`; with neither, a plain -grouped number (never a hardcoded `$`). A measure's own `currency` wins. +**Currency resolution.** The value is a bare number in every mode, never +`{ 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 +`'fixed'`/`'dynamic'` a measure's own `currency` wins. ### Select with Default diff --git a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts index ca5ff20e760..6360c005f30 100644 --- a/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts +++ b/ui/packages/i18n/src/__tests__/resolveFieldCurrency.test.ts @@ -78,6 +78,14 @@ describe('currencyMode decides whether currencyConfig.defaultCurrency is read (o expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBe('USD'); }); + it('record: the field names no currency of its own, so nothing supplies one, the tenant default included', () => { + const parsed = CurrencyConfigSchema.parse({ currencyMode: 'record', currencyField: 'currency_code' }); + expect(resolveFieldCurrency({ currencyConfig: parsed }, TENANT)).toBeUndefined(); + expect(resolveFieldCurrency({ currencyConfig: { ...parsed, defaultCurrency: 'EUR' } }, TENANT)).toBeUndefined(); + expect(resolveFieldCurrency({ defaultCurrency: 'EUR', currencyConfig: parsed }, TENANT)).toBeUndefined(); + expect(resolveFieldCurrency({ currencyConfig: parsed })).toBeUndefined(); + }); + it('parsed-empty: the materialized shape as a renderer receives it', () => { expect( resolveFieldCurrency({ currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'CNY' } }, TENANT), diff --git a/ui/packages/i18n/src/currency.ts b/ui/packages/i18n/src/currency.ts index 075de0ccad9..50f9d0d03e7 100644 --- a/ui/packages/i18n/src/currency.ts +++ b/ui/packages/i18n/src/currency.ts @@ -42,6 +42,13 @@ * its own selected code, that code is the one to win, ahead of the tenant * default. * + * `currencyMode: 'record'` (ADR-0144 D2) returns `undefined` outright: the + * field has no currency of its own — each record's code lives in the sibling + * field `currencyConfig.currencyField` names, which only a renderer holding the + * row can read. No step applies, the tenant default included: a record whose + * code is blank shows no symbol rather than a currency it never named. Reading + * the row's code is the per-row display's job. + * * This is the single resolution every field / measure / cell renderer shares - * ending the per-renderer drift where some read only `currency`, others only * `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n` @@ -53,13 +60,14 @@ export function resolveFieldCurrency( | { currency?: string; defaultCurrency?: string; - currencyConfig?: { currencyMode?: 'dynamic' | 'fixed'; defaultCurrency?: string }; + currencyConfig?: { currencyMode?: 'dynamic' | 'fixed' | 'record'; defaultCurrency?: string; currencyField?: string }; } | null | undefined, tenantDefault?: string, ): string | undefined { const config = field?.currencyConfig; + if (config?.currencyMode === 'record') return undefined; return ( field?.currency || (config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) ||