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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/27185-exchange-rate-table.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion content/docs/ui/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/utils/organization-ownership-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },

Expand Down
8 changes: 4 additions & 4 deletions packages/cli/test/connect-agent-both-halves-wire.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/platform-objects/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -317,6 +317,7 @@ const config: ObjectStackDefinition = defineStack({
SysSecret,
SysSettingAudit,
SysMigration,
SysExchangeRate,
] as any,

apps: [SETUP_APP, STUDIO_APP, ACCOUNT_APP] as any,
Expand Down
6 changes: 6 additions & 0 deletions packages/platform-objects/src/apps/setup-nav.contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3530,5 +3530,46 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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."
}
}
}
}
};
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3530,5 +3530,46 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
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."
}
}
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3530,5 +3530,46 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
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 行。"
}
}
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 'ブランディング' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3530,5 +3530,46 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
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: "每个币种在汇率变动的每个日期各录一行,例如每月初为每个币种录一行。"
}
}
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
export const zhCNGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '品牌' },
Expand Down
Loading
Loading