diff --git a/.changeset/27185-exchange-rate-table.md b/.changeset/27185-exchange-rate-table.md new file mode 100644 index 00000000000..9c924139a34 --- /dev/null +++ b/.changeset/27185-exchange-rate-table.md @@ -0,0 +1,21 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/spec': patch +'@objectstack/cli': patch +--- + +feat(platform-objects): `sys_exchange_rate` — an organization's dated exchange rates, with a Setup list under Localization + +Clause-②: no +Metadata-protocol: none + +A new system object holds the rates multi-currency amounts are converted with, one row per currency and effective date: + +- **Columns.** `currency` (an ISO 4217 code, checked on write), `rate` (exact decimal, 12 decimal places and 24 digits in all, strictly positive), `effective_date` (date) and `source` (`manual` / `import` / `app`, default `manual`). +- **Direction.** One row means 1 unit of the organization's home currency (Localization → Default currency) = `rate` units of the row's currency, so a home amount is the foreign amount divided by `rate`. The sentence is exported as `EXCHANGE_RATE_DIRECTION` and shown on the Setup list page; the scale and precision are exported as `EXCHANGE_RATE_SCALE` and `EXCHANGE_RATE_PRECISION`. +- **One rate per currency per day.** `(currency, effective_date)` is unique within an organization; two organizations may each hold their own EUR rate for the same day. +- **Who.** Per organization (its `organization_id` is injected and the tenant wall scopes it). Organization administrators read and write it; rank-and-file members do not see it. Every change is captured in the audit log and shown on the record's History tab. +- **Where.** Setup → Configuration → Exchange Rates, directly under Localization. `PlatformObjectsPlugin` registers the object with the other platform-infrastructure objects. `PLATFORM_OBJECTS_BY_PACKAGE` (`@objectstack/spec`) lists it under `platform-objects`, so a reference to `sys_exchange_rate` resolves as a real platform object. +- **Upgrade ceremony.** The organization-ownership inventory (`@objectstack/cli`) lists `sys_exchange_rate`, so `os migrate organization-ownership` plans it instead of refusing it as an uninventoried platform table. + +Nothing reads the table yet: the write-time home-currency conversion that uses it ships in the same release. diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 20c7be01bd4..758c5444d08 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -797,11 +797,17 @@ The Setup app's anchors: | `group_people_org` | Users, organizations, business units, teams, invitations | | `group_access_control` | Positions, permission sets, sharing rules, API keys | | `group_approvals` | Approvals inbox, requests, history, delegations | -| `group_configuration` | Settings, localization, authentication, email, storage, AI, feature flags, branding | +| `group_configuration` | Settings, localization, exchange rates, authentication, email, storage, AI, feature flags, branding | | `group_diagnostics` | Sessions, notification events, audit log | | `group_integrations` | Webhooks, cloud connection | | `group_advanced` | OAuth applications | +**Exchange Rates** (`sys_exchange_rate`, under Localization) holds an organization's dated rates, editable by +its administrators. One row means 1 unit of the home currency (Localization → Default currency) = `rate` units +of the row's currency, from its effective date until a later row for that currency. To record a month's rates, +add one row per currency with the first day of the month as the effective date; a correction is an edit to that +row, and every change lands in the audit log. + Setup is for platform administration and needs `setup.access`. For project-specific administration pages, declare an app of your own. diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 354ee2064f7..56617e5aefd 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -765,6 +765,9 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ { object: 'sys_queue', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, { object: 'sys_queue_member', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); names its queue by name, not by id; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_assignment_cursor', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); names its rule by name, not by id; ${D10_SINGLE}`, anchors: [] }, + // Exchange rates (`@objectstack/platform-objects`, #27185): an organization's + // administrators author its own rate table; no parent row anchors a rate. + { object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, { object: 'sys_import_job', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_user_preference', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, diff --git a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts index dfece4d7c95..072258585c7 100644 --- a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts +++ b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts @@ -254,14 +254,14 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( .toContain('nav_connect_agent'); // The Setup tree the platform admin is served BY THIS COMPOSITION: 9 // authored groups less `group_approvals`, which nothing contributes - // into and the filter collapses, plus the 22 entries + // into and the filter collapses, plus the 23 entries // `SETUP_NAV_CONTRIBUTIONS` carries, plus the MCP one. // `nav_settings_branding` is not among them, and not because of a // tenancy wall (this harness boots none): since #24766 it is // contributed by `@objectstack/service-settings` together with the // `branding` namespace, offered only where no organization wall stands, // and this harness composes platform-objects and the MCP bundle alone. - expect(allowedIds).toHaveLength(31); + expect(allowedIds).toHaveLength(32); expect(allowedIds).not.toContain('nav_settings_branding'); // The two per-user stores are reached from the User record page, not // from Setup's menu (#23803). @@ -284,8 +284,8 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(withAccountHalf.statusCode).toBe(200); expect(withoutAccountHalf.statusCode).toBe(200); expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item)); - // 31: the composition the count in the test above states. - expect(navIds(withAccountHalf.body?.item)).toHaveLength(31); + // 32: the composition the count in the test above states. + expect(navIds(withAccountHalf.body?.item)).toHaveLength(32); // The mechanism behind the invariance, asserted rather than assumed: // the bundle aims exactly one contribution at `setup` and one at diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 543d7336df7..755bb7ce502 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -187,7 +187,7 @@ import { } from '../src/metadata/index.js'; // ── System ──────────────────────────────────────────────────────────────── -import { SysSetting, SysSecret, SysSettingAudit, SysMigration } from '../src/system/index.js'; +import { SysSetting, SysSecret, SysSettingAudit, SysMigration, SysExchangeRate } from '../src/system/index.js'; // ── Existing Setup app + dashboards + translations ──────────────────────── import { SETUP_APP } from '../src/apps/setup.app.js'; @@ -317,6 +317,7 @@ const config: ObjectStackDefinition = defineStack({ SysSecret, SysSettingAudit, SysMigration, + SysExchangeRate, ] as any, apps: [SETUP_APP, STUDIO_APP, ACCOUNT_APP] as any, diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index ad98264e484..707e60c76be 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -126,6 +126,12 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // both directly. No `requiredPermissions` — matches Branding (read perm is // the app's base `setup.access`). { id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' }, + // Multi-currency M1-1 (epic #8345): the dated rates from the home + // currency Localization sets, so the entry sits directly under it. The + // list page states the rate direction (`EXCHANGE_RATE_DIRECTION`). No + // `requiredPermissions`: the object grant decides who reads and writes + // (organization admins), and Setup itself already needs `setup.access`. + { id: 'nav_exchange_rates', type: 'object', label: 'Exchange Rates', objectName: 'sys_exchange_rate', viewName: 'all_rates', icon: 'arrow-right-left' }, { id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' }, // `nav_settings_branding` is contributed by `@objectstack/service-settings` // with the `branding` namespace, which is offered only where no diff --git a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts index bc87738e140..54ab6875527 100644 --- a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts +++ b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts @@ -38,7 +38,7 @@ const OWNED_OBJECTS = new Set([ // metadata (sys_view_definition retired as inert, ADR-0131 D13) 'sys_metadata', 'sys_metadata_history', 'sys_metadata_audit', // system - 'sys_setting', 'sys_secret', 'sys_setting_audit', 'sys_migration', + 'sys_setting', 'sys_secret', 'sys_setting_audit', 'sys_migration', 'sys_exchange_rate', ]); describe('objects translation bundle ownership (ADR-0029 D8)', () => { diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 3c10dcf93a5..578eadb5091 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3530,5 +3530,46 @@ export const enObjects: NonNullable = { label: "Updated At" } } + }, + sys_exchange_rate: { + label: "Exchange Rate", + pluralLabel: "Exchange Rates", + description: "Exchange rates from the organization's home currency to a foreign currency, by effective date — the rates foreign-currency amounts are converted to the home currency with.", + fields: { + id: { + label: "ID" + }, + currency: { + label: "Currency", + help: "ISO 4217 code of the foreign currency, uppercase (EUR, JPY, CHF)." + }, + rate: { + label: "Rate", + help: "1 unit of the organization's home currency (Localization → Default currency) = Rate units of this currency. Example: home currency USD, currency EUR, rate 0.92 means 1 USD = 0.92 EUR." + }, + effective_date: { + label: "Effective Date", + help: "First date this rate applies to. It stays in force until a later row for the same currency takes over." + }, + source: { + label: "Source", + help: "How the row arrived: typed in Setup (manual), loaded from a file (import), or written by an app flow (app).", + options: { + manual: "Manual", + import: "Import", + app: "App" + } + } + }, + _views: { + all_rates: { + label: "All", + description: "1 unit of the organization's home currency (Localization → Default currency) = Rate units of this currency. Example: home currency USD, currency EUR, rate 0.92 means 1 USD = 0.92 EUR.", + emptyState: { + title: "No exchange rates yet", + message: "Add one row per currency for each date its rate changes, for example one row per currency at the start of each month." + } + } + } } }; diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 038d5223433..217829e44a0 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -124,6 +124,7 @@ export const en: TranslationData = { // Configuration nav_settings_hub: { label: 'All Settings' }, nav_settings_localization: { label: 'Localization' }, + nav_exchange_rates: { label: 'Exchange Rates' }, nav_settings_company: { label: 'Company' }, nav_settings_mail: { label: 'Email' }, nav_settings_branding: { label: 'Branding' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 9a732d8e98a..2244f814744 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3530,5 +3530,46 @@ export const esESObjects: NonNullable = { label: "Actualizado el" } } + }, + sys_exchange_rate: { + label: "Tipo de cambio", + pluralLabel: "Tipos de cambio", + description: "Tipos de cambio de la moneda local de la organización a una moneda extranjera, por fecha de entrada en vigor: los tipos con los que los importes en moneda extranjera se convierten a la moneda local.", + fields: { + id: { + label: "ID" + }, + currency: { + label: "Moneda", + help: "Código ISO 4217 de la moneda extranjera, en mayúsculas (EUR, JPY, CHF)." + }, + rate: { + label: "Tipo", + help: "1 unidad de la moneda local de la organización (Localización → Moneda predeterminada) = Tipo unidades de esta moneda. Ejemplo: moneda local USD, moneda EUR, tipo 0,92 significa 1 USD = 0,92 EUR." + }, + effective_date: { + label: "Fecha de entrada en vigor", + help: "Primera fecha a la que se aplica este tipo. Sigue vigente hasta que una fila posterior de la misma moneda lo sustituya." + }, + source: { + label: "Origen", + help: "Cómo llegó la fila: introducida en Setup (manual), cargada desde un archivo (importación) o escrita por un flujo de una aplicación (aplicación).", + options: { + manual: "Manual", + import: "Importación", + app: "Aplicación" + } + } + }, + _views: { + all_rates: { + label: "Todos", + description: "1 unidad de la moneda local de la organización (Localización → Moneda predeterminada) = Tipo unidades de esta moneda. Ejemplo: moneda local USD, moneda EUR, tipo 0,92 significa 1 USD = 0,92 EUR.", + emptyState: { + title: "Aún no hay tipos de cambio", + message: "Añade una fila por moneda para cada fecha en que cambie su tipo, por ejemplo una fila por moneda al inicio de cada mes." + } + } + } } }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index e3712999715..4086c3c4669 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -23,6 +23,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", "objects.sys_email_template.fields.variables_json.label": "c2cfff50e8151e3d", + "objects.sys_exchange_rate.fields.id.label": "00b0385c9c152888", + "objects.sys_exchange_rate.fields.source.options.manual": "c2e7a98c066bec4a", "objects.sys_job_run.fields.error.label": "786fed84bd8d5a32", "objects.sys_metadata.fields.bundle_member.help": "6912852b7523f266", "objects.sys_metadata.fields.bundle_member.label": "49b49e6b77397e92", diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 6fea2e06981..394e1091327 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -83,6 +83,7 @@ export const esES: TranslationData = { nav_settings_hub: { label: 'Todos los Ajustes' }, nav_settings_localization: { label: 'Localización' }, + nav_exchange_rates: { label: 'Tipos de cambio' }, nav_settings_company: { label: 'Empresa' }, nav_settings_mail: { label: 'Correo' }, nav_settings_branding: { label: 'Marca' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 3d835cd861e..e7ea8fc062a 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3530,5 +3530,46 @@ export const jaJPObjects: NonNullable = { label: "更新日時" } } + }, + sys_exchange_rate: { + label: "為替レート", + pluralLabel: "為替レート", + description: "組織の基準通貨から外貨への為替レートを適用開始日ごとに記録します。外貨金額を基準通貨に換算する際に使われるレートです。", + fields: { + id: { + label: "ID" + }, + currency: { + label: "通貨", + help: "外貨の ISO 4217 コード(大文字。EUR、JPY、CHF)。" + }, + rate: { + label: "レート", + help: "組織の基準通貨 1 単位(ローカリゼーション → 既定の通貨)= この通貨のレート単位。例:基準通貨 USD、通貨 EUR、レート 0.92 は 1 USD = 0.92 EUR を意味します。" + }, + effective_date: { + label: "適用開始日", + help: "このレートが適用され始める日付。同じ通貨のより新しい行に置き換わるまで有効です。" + }, + source: { + label: "ソース", + help: "行の登録方法:Setup で入力(手動)、ファイルから読み込み(インポート)、またはアプリのフローが書き込み(アプリ)。", + options: { + manual: "手動", + import: "インポート", + app: "アプリ" + } + } + }, + _views: { + all_rates: { + label: "すべて", + description: "組織の基準通貨 1 単位(ローカリゼーション → 既定の通貨)= この通貨のレート単位。例:基準通貨 USD、通貨 EUR、レート 0.92 は 1 USD = 0.92 EUR を意味します。", + emptyState: { + title: "為替レートはまだありません", + message: "通貨ごとに、レートが変わる日付ごとに 1 行追加します。例:毎月初めに通貨ごとに 1 行。" + } + } + } } }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index 3de8b1983d8..1582af3d0ee 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -22,6 +22,7 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_email.fields.cc_addresses.label": "02946d952cf15623", "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", + "objects.sys_exchange_rate.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.bundle_member.help": "6912852b7523f266", "objects.sys_metadata.fields.bundle_member.label": "49b49e6b77397e92", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index da18e58ad85..642907766b7 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -83,6 +83,7 @@ export const jaJP: TranslationData = { nav_settings_hub: { label: 'すべての設定' }, nav_settings_localization: { label: 'ローカリゼーション' }, + nav_exchange_rates: { label: '為替レート' }, nav_settings_company: { label: '会社情報' }, nav_settings_mail: { label: 'メール' }, nav_settings_branding: { label: 'ブランディング' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 879f0bd3e15..07cb18a01a2 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3530,5 +3530,46 @@ export const zhCNObjects: NonNullable = { label: "更新时间" } } + }, + sys_exchange_rate: { + label: "汇率", + pluralLabel: "汇率", + description: "从组织本位币到外币的汇率,按生效日期记录——外币金额折算为本位币时所用的汇率。", + fields: { + id: { + label: "ID" + }, + currency: { + label: "币种", + help: "外币的 ISO 4217 代码,大写(EUR、JPY、CHF)。" + }, + rate: { + label: "汇率", + help: "1 单位组织本位币(本地化 → 默认币种)= 汇率 单位的该币种。示例:本位币 USD、币种 EUR、汇率 0.92,表示 1 USD = 0.92 EUR。" + }, + effective_date: { + label: "生效日期", + help: "此汇率开始适用的日期。在同一币种的更晚一行接替之前一直有效。" + }, + source: { + label: "来源", + help: "该行的来源:在 Setup 中录入(手工)、从文件导入(导入),或由应用流程写入(应用)。", + options: { + manual: "手工", + import: "导入", + app: "应用" + } + } + }, + _views: { + all_rates: { + label: "全部", + description: "1 单位组织本位币(本地化 → 默认币种)= 汇率 单位的该币种。示例:本位币 USD、币种 EUR、汇率 0.92,表示 1 USD = 0.92 EUR。", + emptyState: { + title: "还没有汇率", + message: "每个币种在汇率变动的每个日期各录一行,例如每月初为每个币种录一行。" + } + } + } } }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 0212e9df053..a953e376fb1 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -20,6 +20,7 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_email.fields.message_id.label": "14cd089a4062f4b1", "objects.sys_email_template.fields.id.label": "00b0385c9c152888", + "objects.sys_exchange_rate.fields.id.label": "00b0385c9c152888", "objects.sys_metadata.fields.bundle_member.help": "6912852b7523f266", "objects.sys_metadata.fields.bundle_member.label": "49b49e6b77397e92", "objects.sys_metadata.fields.id.label": "00b0385c9c152888", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 7c350a2f433..4e2bd05b5db 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -90,6 +90,7 @@ export const zhCN: TranslationData = { nav_settings_hub: { label: '全部设置' }, nav_settings_localization: { label: '本地化' }, + nav_exchange_rates: { label: '汇率' }, nav_settings_company: { label: '公司信息' }, nav_settings_mail: { label: '邮件' }, nav_settings_branding: { label: '品牌' }, diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 50326ce0c6c..7034e5bf0c1 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -37,7 +37,7 @@ import { SysMetadata, SysMetadataHistoryObject, } from './metadata/index.js'; -import { SysSecret, SysSetting } from './system/index.js'; +import { SysExchangeRate, SysSecret, SysSetting } from './system/index.js'; import { ACCOUNT_APP, SETUP_APP, SETUP_NAV_CONTRIBUTIONS, STUDIO_APP } from './apps/index.js'; import { AppSchema } from '@objectstack/spec/ui'; import { resolveEffectiveApiMethods, isApiOperationAllowed, canServeApiOperation } from '@objectstack/spec/data'; @@ -555,6 +555,7 @@ describe('@objectstack/platform-objects', () => { sys_account: SysAccount, sys_api_key: SysApiKey, sys_business_unit: SysBusinessUnit, + sys_exchange_rate: SysExchangeRate, sys_invitation: SysInvitation, sys_jwks: SysJwks, sys_oauth_application: SysOauthApplication, diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index 22239e33210..8c3a6c597f6 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; // the predicate into a package that depends on neither side. import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { PlatformObjectsPlugin } from './plugin.js'; -import { SysMetadataActivation, SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; +import { SysExchangeRate, SysMetadataActivation, SysMigration, SysMigrationJournal, SysSecret } from './system/index.js'; /** * Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this @@ -46,7 +46,7 @@ function makeCtx() { } describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4243, #4270)', () => { - it('registers SysMigration, SysMigrationJournal and SysSecret through the manifest service', async () => { + it('registers SysMigration, SysMigrationJournal, SysSecret and SysExchangeRate through the manifest service', async () => { const ctx = makeCtx(); const manifests: any[] = []; ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); @@ -56,7 +56,7 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 const infra = manifests.find((m) => m.id === 'com.objectstack.platform-objects'); expect(infra).toBeDefined(); expect(infra.scope).toBe('system'); - expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret]); + expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate]); expect(infra.objects.map((o: any) => o.name)).toEqual([ 'sys_migration', // ADR-0119 D2 (#4617). Registered UNCONDITIONALLY, alongside the flag @@ -65,8 +65,12 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 // compose and others do not is a journal a boot scanner cannot rely on. 'sys_migration_journal', 'sys_secret', + // Multi-currency M1-1 (epic #8345): read by the engine's write-time + // home-currency projection, so it registers with the engine-facing + // infrastructure, on the project database. + 'sys_exchange_rate', ]); - // ⛔ The three above must NOT acquire the ledger's routing triple. They + // ⛔ The four above must NOT acquire the ledger's routing triple. They // ride the project database today; moving them is a different question. expect(infra.defaultDatasource).toBeUndefined(); }); diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 9dc317736f4..1db41b5cbe8 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -6,6 +6,7 @@ import { SysMetadataActivation } from './system/sys-metadata-activation.object.j import { SysMigration } from './system/sys-migration.object.js'; import { SysMigrationJournal } from './system/sys-migration-journal.object.js'; import { SysSecret } from './system/sys-secret.object.js'; +import { SysExchangeRate } from './system/sys-exchange-rate.object.js'; import { attestFreshDatastore } from './system/migration-flag.js'; import type { II18nService, @@ -78,6 +79,13 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; * ⚠️ It rides its OWN manifest, not the one above, and that is a * smooth-upgrade requirement rather than tidiness — see * {@link ACTIVATION_LEDGER_MANIFEST}. + * - **`sys_exchange_rate`** — the organization's dated exchange rates + * (multi-currency M1-1, epic #8345, ruling 6465431966). Its one reader is + * the ObjectQL engine's write-time home-currency projection — present in + * every composition that can write a record — so it registers with the + * engine-facing infrastructure above, not with an optional service. Rides + * the first manifest (the project database), like every per-organization + * table here. * - **Fresh-datastore attestation** (#3438, ADR-0104 2026-07-30) — * travels with the ledger registration: a store this boot created from * empty is attested once that boot's own data has settled @@ -187,7 +195,7 @@ export class PlatformObjectsPlugin { version: this.version, type: 'plugin', scope: 'system', - objects: [SysMigration, SysMigrationJournal, SysSecret], + objects: [SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate], }); ctx?.getService?.('manifest')?.register?.({ ...ACTIVATION_LEDGER_MANIFEST, diff --git a/packages/platform-objects/src/system/index.ts b/packages/platform-objects/src/system/index.ts index 95839163f25..e2a4e7072e7 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -20,6 +20,14 @@ export { SysSettingAudit } from './sys-setting-audit.object.js'; export { SysPlatformSettingAudit } from './sys-platform-setting-audit.object.js'; export { SysMigration } from './sys-migration.object.js'; export { SysMigrationJournal } from './sys-migration-journal.object.js'; +// Multi-currency M1-1 (epic #8345): the dated rate table the write-time +// home-currency projection reads. +export { + SysExchangeRate, + EXCHANGE_RATE_DIRECTION, + EXCHANGE_RATE_SCALE, + EXCHANGE_RATE_PRECISION, +} from './sys-exchange-rate.object.js'; export { readDataMigrationFlag, isDataMigrationVerified, diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts new file mode 100644 index 00000000000..314204448f1 --- /dev/null +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + IndexSchema, + ObjectSchema, + resolveCrudAffordances, + resolveInjectedSystemColumns, +} from '@objectstack/spec/data'; +import { isValueDomainMember } from '@objectstack/spec/shared'; +import { + EXCHANGE_RATE_DIRECTION, + EXCHANGE_RATE_PRECISION, + EXCHANGE_RATE_SCALE, + SysExchangeRate, +} from './sys-exchange-rate.object.js'; +import { SETUP_NAV_CONTRIBUTIONS } from '../apps/setup-nav.contributions.js'; + +/** + * Multi-currency M1-1 (epic #8345): `sys_exchange_rate`'s declaration. + * + * The four facts the card asks for — uniqueness, tenant isolation, who may + * write, audit — are each decided by a mechanism that lives elsewhere and is + * pinned there: the driver materializes an `'organization'` unique key + * (`driver-sql` tenant-scoped declared-unique suite), the Layer 0 wall scopes + * every tenant-columned object (ADR-0095 D1), `organization_admin`'s wildcard + * reaches every object that is not `access.default: 'private'` while + * `member_default` names no business object (plugin-security + * `default-permission-sets.ts`), and the audit writer captures every object + * not on its skip list (plugin-audit `AUDIT_EXCLUDED_OBJECTS`). What this + * package owns is the DECLARATION each of those mechanisms reads, so that is + * what is pinned here — asserted on the BUILT value, never on source text. + */ +describe('sys_exchange_rate — declaration (multi-currency M1-1)', () => { + const fields = SysExchangeRate.fields as Record; + + it('parses as an ObjectSchema', () => { + expect(() => ObjectSchema.parse(SysExchangeRate)).not.toThrow(); + }); + + it('carries exactly the ruled columns: currency, rate, effective_date, source', () => { + expect(Object.keys(fields).sort()).toEqual(['currency', 'effective_date', 'id', 'rate', 'source']); + for (const name of ['currency', 'rate', 'effective_date', 'source']) { + expect(fields[name].required, name).toBe(true); + } + }); + + describe('uniqueness', () => { + const uniqueIndexes = (SysExchangeRate.indexes ?? []).filter((i: any) => i.unique); + + it('declares exactly one unique key, on (currency, effective_date)', () => { + expect(uniqueIndexes).toHaveLength(1); + expect((uniqueIndexes[0] as any).fields).toEqual(['currency', 'effective_date']); + }); + + it("spells its scope 'organization' — not bare `true`, which is the installation-wide `'global'`", () => { + // Asserted by EQUALITY: a truthy check passes on the very spelling that + // would let one organization's EUR row refuse another organization's. + expect((uniqueIndexes[0] as any).unique).toBe('organization'); + expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ unique: 'organization' }); + }); + }); + + describe('tenant isolation', () => { + it('takes no tenancy opt-out, so organization_id is injected and the key has a column to scope by', () => { + const plan = resolveInjectedSystemColumns(SysExchangeRate); + expect((SysExchangeRate as any).tenancy).toBeUndefined(); + expect((SysExchangeRate as any).systemFields?.tenant).not.toBe(false); + expect(plan.tenant).toBe(true); + expect(plan.names.has('organization_id')).toBe(true); + }); + }); + + describe('who writes', () => { + it('stays reachable by the organization admin wildcard grant — not private, no extra capability gate', () => { + // `access.default: 'private'` opts an object OUT of the `'*'` grant + // organization admins hold; a `requiredPermissions` gate would demand a + // capability `organization_admin` does not carry. Either one would + // leave the rate table with no writer below platform admin. + expect((SysExchangeRate as any).access?.default).not.toBe('private'); + expect((SysExchangeRate as any).requiredPermissions).toBeUndefined(); + }); + + it('offers New / Edit / Delete to whoever holds the grant, and no CSV import until #27190 proves it', () => { + const affordances = resolveCrudAffordances(SysExchangeRate as any); + expect(SysExchangeRate.managedBy).toBe('system-data'); + expect(affordances).toMatchObject({ create: true, edit: true, delete: true, import: false }); + }); + + it('exposes the generic data API, so Setup can list and write it', () => { + expect(SysExchangeRate.enable?.apiEnabled).toBe(true); + expect(SysExchangeRate.enable?.apiMethods).toBeUndefined(); + }); + }); + + describe('audit', () => { + it('surfaces the History tab, with every ruled column summarized', () => { + expect(SysExchangeRate.enable?.trackHistory).toBe(true); + for (const name of ['currency', 'rate', 'effective_date', 'source']) { + expect(fields[name].trackHistory, name).toBe(true); + } + }); + }); + + describe('values', () => { + it('currency is an ISO 4217 code, checked on write by the value domain', () => { + expect(fields.currency.type).toBe('text'); + expect(fields.currency.valueDomain).toBe('iso_4217_currency'); + // The predicate the write seam runs: a real code passes, a shape-valid + // non-member and a lowercase spelling do not. + expect(isValueDomainMember('iso_4217_currency', 'EUR')).toBe(true); + expect(isValueDomainMember('iso_4217_currency', 'ZZZ')).toBe(false); + expect(isValueDomainMember('iso_4217_currency', 'eur')).toBe(false); + }); + + it('rate is an exact decimal, scale 12 and precision 24, strictly positive', () => { + // `number` takes the platform's exact-decimal column; ⛔ never `float`. + // Parameter 4 = twelve digits after the point (ruling 6469382727). + expect(fields.rate.type).toBe('number'); + expect(EXCHANGE_RATE_SCALE).toBe(12); + expect(EXCHANGE_RATE_PRECISION).toBe(24); + expect(fields.rate.scale).toBe(EXCHANGE_RATE_SCALE); + expect(fields.rate.precision).toBe(EXCHANGE_RATE_PRECISION); + expect(fields.rate.min).toBe(10 ** -EXCHANGE_RATE_SCALE); + }); + + it('effective_date is a date, and source is manual / import / app defaulting to manual', () => { + expect(fields.effective_date.type).toBe('date'); + expect(fields.source.type).toBe('select'); + expect(fields.source.options.map((o: any) => o.value)).toEqual(['manual', 'import', 'app']); + expect(fields.source.defaultValue).toBe('manual'); + }); + }); + + describe('rate direction', () => { + it('is one sentence, home → foreign, carried verbatim by the field and the Setup list page', () => { + expect(EXCHANGE_RATE_DIRECTION).toMatch(/^1 unit of the organization's home currency/); + expect(fields.rate.description).toBe(EXCHANGE_RATE_DIRECTION); + expect((SysExchangeRate.listViews as any).all_rates.description).toBe(EXCHANGE_RATE_DIRECTION); + }); + }); + + describe('Setup', () => { + const configuration = SETUP_NAV_CONTRIBUTIONS.find((c) => c.group === 'group_configuration'); + const items = (configuration?.items ?? []) as any[]; + + it('lists the rates directly under Localization, opening the all-rates view', () => { + const at = items.findIndex((i) => i.id === 'nav_exchange_rates'); + expect(at).toBeGreaterThan(0); + expect(items[at - 1].id).toBe('nav_settings_localization'); + expect(items[at]).toMatchObject({ + type: 'object', + objectName: 'sys_exchange_rate', + viewName: 'all_rates', + }); + }); + + it('names a view the object declares, listing the four ruled columns', () => { + const view = (SysExchangeRate.listViews as any).all_rates; + expect(view.data).toEqual({ provider: 'object', object: 'sys_exchange_rate' }); + expect(view.columns).toEqual(['currency', 'rate', 'effective_date', 'source']); + expect(view.emptyState?.title).toBeTruthy(); + }); + }); +}); diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.ts new file mode 100644 index 00000000000..a1c502b81cc --- /dev/null +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * The rate direction, stated once and read by every face that has to say it: + * the `rate` field's description and the Setup list page's description both + * carry this sentence verbatim, so the two can never disagree about which way + * a rate points (ruling 6465431966, parameter 3). + */ +export const EXCHANGE_RATE_DIRECTION = + "1 unit of the organization's home currency (Localization → Default currency) = Rate units of this currency. " + + 'Example: home currency USD, currency EUR, rate 0.92 means 1 USD = 0.92 EUR.'; + +/** + * Decimal places a `rate` keeps — twelve digits after the point (ruling + * 6465431966 parameter 4, its unit settled by ruling 6469382727). A write + * carrying more is refused at the engine's `scale` seam, never rounded. + */ +export const EXCHANGE_RATE_SCALE = 12; + +/** + * Total digits a `rate` may carry, integer and fraction together (ruling + * 6469382727): twelve on each side of the point. A write needing more is + * refused at the engine's `precision` seam (`max_precision`). + */ +export const EXCHANGE_RATE_PRECISION = 24; + +/** + * sys_exchange_rate — Exchange Rate (multi-currency M1-1, epic #8345) + * + * One row per (currency, effective date) per organization: the rate that + * converts the organization's home currency into a foreign currency from that + * date on. It is the only source the write-time home-currency projection reads + * (shape C′, ruling 6465431966): a record-mode amount is converted at write + * from the rate in force on the record's own document date, and a missing + * rate refuses the write. Cross rates derive through the home currency — there + * is no pair table. + * + * - **Direction** — {@link EXCHANGE_RATE_DIRECTION}; one direction only, so + * no reader has to guess whether a row is the rate or its reciprocal. + * - **Precision** — `rate` is a `number` field (the platform's exact-decimal + * column, never a float column) with `scale` {@link EXCHANGE_RATE_SCALE} + * and `precision` {@link EXCHANGE_RATE_PRECISION}. + * A home amount is the record amount DIVIDED by `rate` (parameter 3 as + * arithmetic, recorded by the multi-currency ADR, #27183). + * - **Uniqueness** — `(currency, effective_date)` within an organization. Two + * organizations both holding EUR on the same day is the normal case, so the + * key is spelled `'organization'` (ADR-0120 D1), never bare `true`. + * - **Tenancy** — no tenancy opt-out, so `organization_id` is injected and the + * Layer 0 tenant wall (ADR-0095 D1) scopes every read and write. + * - **Who writes** — organization administrators: `organization_admin`'s + * wildcard grant reaches this object and `member_default` names no business + * object (explicit-allow baseline), so a rank-and-file member neither reads + * nor writes it (ruling 6465431966, parameter 2). Changing a rate changes + * the basis of every amount converted after it, which is why it is an admin + * act. + * - **Audit** — not on the audit writer's skip list, so every create, edit + * and delete lands in `sys_audit_log`; `enable.trackHistory` plus the + * per-field flags surface the History tab with readable diffs. + * - **Sources** — `source` records how the row arrived: typed in Setup + * (`manual`), loaded from a file (`import`), or written by an app flow that + * fetches a provider feed (`app`). The platform ships no provider feed. + * + * @namespace sys + */ +export const SysExchangeRate = ObjectSchema.create({ + name: 'sys_exchange_rate', + label: 'Exchange Rate', + pluralLabel: 'Exchange Rates', + icon: 'arrow-right-left', + isSystem: true, + // Platform-defined schema, organization-administered data. + managedBy: 'system-data', + description: + "Exchange rates from the organization's home currency to a foreign currency, by effective date — " + + 'the rates foreign-currency amounts are converted to the home currency with.', + displayNameField: 'currency', + nameField: 'currency', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + titleFormat: '{currency} · {effective_date}', + highlightFields: ['currency', 'rate', 'effective_date', 'source'], + + fields: { + id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), + + currency: Field.text({ + label: 'Currency', + required: true, + searchable: true, + maxLength: 3, + valueDomain: 'iso_4217_currency', + trackHistory: true, + description: 'ISO 4217 code of the foreign currency, uppercase (EUR, JPY, CHF).', + group: 'Rate', + }), + + rate: Field.number({ + label: 'Rate', + required: true, + // The smallest positive value at this scale: a zero or negative rate has + // no meaning, and a conversion through one would silently zero or flip + // every amount it touched. + min: 10 ** -EXCHANGE_RATE_SCALE, + scale: EXCHANGE_RATE_SCALE, + precision: EXCHANGE_RATE_PRECISION, + trackHistory: true, + description: EXCHANGE_RATE_DIRECTION, + group: 'Rate', + }), + + effective_date: Field.date({ + label: 'Effective Date', + required: true, + trackHistory: true, + description: + 'First date this rate applies to. It stays in force until a later row for the same currency takes over.', + group: 'Rate', + }), + + source: Field.select( + [ + { value: 'manual', label: 'Manual' }, + { value: 'import', label: 'Import' }, + { value: 'app', label: 'App' }, + ], + { + label: 'Source', + required: true, + defaultValue: 'manual', + trackHistory: true, + description: + 'How the row arrived: typed in Setup (manual), loaded from a file (import), or written by an app flow (app).', + group: 'Rate', + }, + ), + }, + + indexes: [ + { fields: ['currency', 'effective_date'], unique: 'organization' }, + ], + + listViews: { + all_rates: { + type: 'grid', + name: 'all_rates', + label: 'All', + description: EXCHANGE_RATE_DIRECTION, + data: { provider: 'object', object: 'sys_exchange_rate' }, + columns: ['currency', 'rate', 'effective_date', 'source'], + sort: [ + { field: 'effective_date', order: 'desc' }, + { field: 'currency', order: 'asc' }, + ], + pagination: { pageSize: 50 }, + emptyState: { + title: 'No exchange rates yet', + message: + 'Add one row per currency for each date its rate changes, for example one row per currency at the start of each month.', + }, + }, + }, + + enable: { + trackHistory: true, + searchable: false, + apiEnabled: true, + // A rate is reference data, not a discussion surface. + feeds: false, + }, +}); diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index eb1ab60ebf5..75646f06041 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -52,6 +52,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly