Skip to content

Commit 2ac698b

Browse files
feat(console): the workspace prompt also asks an administrator for a still-unset default currency (#28953)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb3vHCKqWaXUoF2WbmXvJHiD)_ Fixes #28872 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:组织还没有默认货币时,控制台原有的「设置系统时区」一次性提示现在也会问货币,预填浏览器所在地区的货币(如 人民币(CNY));时区和货币都没设时只弹一个对话框、一次保存。区域→货币的 CLDR 表从 plugin-auth 搬进 `@objectstack/spec/shared`,两边读同一份,控制台不再需要第二份表。 - **为什么改**:裁决 B9 允许设置页有一个向导。今天组织创建时推不出货币(`es-419` 这类无国家的语言、容器没设 `LANG`)就什么都不写,所有金额静默显示为裸数字,没人被告知。 - **风险与回滚**:低。只对能写 localization 的管理员、且货币在任何层级都没有值时才问;确认走设置页同一个 `PUT /api/settings/localization`,拒绝什么都不写;没有新设置键、没有服务端 dismissed 标记、没有门禁。回滚即回退本 PR,表搬回 plugin-auth。 - **先看**:`ui/apps/console/src/pages/settings/workspaceTimezonePromptLogic.ts`(`findWorkspacePromptOffer` 与 `currencyArm` 的判定);`ui/apps/console/src/pages/settings/WorkspaceTimezonePrompt.tsx`(两个字段合一个对话框、只写有值的键);`packages/spec/src/shared/region-currency.ts`(原样搬来的表与查找)。 - **两分钟验证**:`pnpm --dir ui exec vitest run apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.currency-28872.test.tsx`,预期全部通过;其中「both unset」组断言只有一个对话框、一次 PUT 同时带 `timezone` 与 `currency`。 - **需要你做的**:无,席位落地。 ## What changes **The table moves to spec (premise checked first).** `REGION_CURRENCY` and `currencyForLocale` move unchanged from `packages/plugins/plugin-auth/src/organization-default-currency.ts` to a new `packages/spec/src/shared/region-currency.ts`, exported from the `@objectstack/spec/shared` entry. plugin-auth imports `currencyForLocale` from there and keeps no copy; neither name was exported from the plugin-auth package entry. The console already imports `@objectstack/spec/shared` (the prompt logic reads `isValueDomainMember` from it), so the console reads the same table with no second hand-written copy and no new entry. The module imports nothing. **One prompt for both keys.** `findWorkspacePromptOffer` (replacing `findTimezoneOffer`) reads the Settings client once and answers either arm, or both, or nothing: - timezone: unchanged condition (still the manifest default, unlocked, browser zone admissible); - currency: `localization.currency` resolves from `source: 'default'` with no value (the key declares no default, so no rung holds one), unlocked, and the browser region's currency is a member of the specifier's value domain. A manifest that declares a default currency has one, so it is not asked about. The browser region's currency is `currencyForLocale` of the FIRST browser language only (`browserLanguage`): a later preference names a language the person reads, not where they are. A tag with no country region (`es-419`) offers nothing. Both arms need the namespace's `writePermission`, as before, so a non-admin is never asked and never reads the namespace. The dialog shows a `SettingsField` per arm (the Settings page's own control and error slot). Confirm writes the keys whose field holds a value in one `saveSettingsNamespace('localization', …)` call, the Settings page's write path; clearing the currency field in the combined dialog leaves it unset. Decline ("Not now" when the currency is asked; "Keep the default" when only the timezone is) writes nothing. The prompt's existing per-device record (`os:workspace-timezone-prompt:ORG:USER`) covers both arms. No settings key, server-side dismissed flag or gate is added. **Copy.** Eight `console.workspaceTimezonePrompt.*` strings in all ten built-in locales: `currencyTitle`, `currencyDescription`, `currencyConfirm`, `currencySaved`, `bothTitle`, `bothConfirm`, `bothSaved`, `notNow`. `currencyDescription` names the offer by the currency's `Intl.DisplayNames` name in the UI language and its code (`name` and `code` placeholders), for example 人民币(CNY) in zh and "Chinese Yuan (CNY)" in en. The timezone-only copy is unchanged. ## Tests - `packages/spec/src/shared/region-currency.test.ts` (new): the table and lookup pins moved from plugin-auth, plus a pin that the shared entry re-exports the same bindings. - `packages/plugins/plugin-auth/src/organization-default-currency.test.ts`: the two moved describe blocks are removed; the rest is unchanged. `organization-created-default-currency.test.ts` (the composed path through the real settings service) is unchanged and green. - `ui/apps/console/src/pages/settings/__tests__/WorkspaceTimezonePrompt.currency-28872.test.tsx` (new): the currency arm alone (en and zh copy, pre-fill, one PUT carrying the currency alone, an edited value, decline and no second ask, a refusal in the field's error slot, asked with no browser zone), the combined case (one dialog, both fields, one read, one PUT with both keys, a cleared currency field written as timezone only, decline), and the no-prompt cases (tenant, global and env currency, a manifest default, a non-admin, an `es-419` browser, nothing to offer means no request). - `WorkspaceTimezonePrompt.test.tsx`: one edit. "no browser zone at all → no prompt and no request" now also stubs the browser language to `es-419`, because a browser with a currency to offer now reads the settings. Every other timezone pin is untouched. ## Acceptance notes - The record is the existing one, as the card asks ("under the prompt's existing record"). So an administrator who was already shown the timezone prompt on this device before this lands is not asked about the currency there. The Setup → Localization page still shows the unset key. - A browser whose first language has no country region (`es-419`) is not asked, by the card's "offering the browser region's currency"; no guess is made from later preferences. - "Amounts then show the symbol" follows from the existing renderer reading `localization.currency`; this PR writes the key through the Settings page's path and does not change the renderer. ## Verification Branch base `97680bbb`; readings on head `93694e2e` unless named. `origin/main` has moved 7 commits since, none touching a file this diff changes. <details><summary>Tests</summary> - `pnpm --filter @objectstack/spec exec vitest run src/shared/region-currency.test.ts src/shared/value-domain.test.ts`: 2 files, 41/41 passed (at `2c068e8f`; `packages/spec/src` unchanged since). - `pnpm --filter @objectstack/plugin-auth exec vitest run src/organization-default-currency.test.ts src/organization-created-default-currency.test.ts`: 2 files, 26/26 passed (at `2c068e8f`; plugin-auth unchanged since). - `pnpm --dir ui exec vitest run` on the three `WorkspaceTimezonePrompt*` files plus `packages/i18n/`: 97 files, 1477 passed, 13 skipped, 0 failed. - `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped; 4900 tests passed. - Ablation (one-time, not kept): `scripts/ablation-replace.mjs` replaced `if (currency) offer.currency = currency;` in `findWorkspacePromptOffer`; the currency test file went 11 failed / 9 passed, and the restore leg read blob == HEAD with an empty `git diff HEAD`. </details> <details><summary>Type-check, lint, generated artifacts</summary> - `turbo run type-check --filter=@object-ui/console --filter=@object-ui/i18n`: 72/72 tasks; `turbo run typecheck --filter=@objectstack/spec --filter=@objectstack/plugin-auth`: 33/33 tasks. - eslint on every changed source and test file: exit 0 (root and `ui/`). - `pnpm --filter @objectstack/spec check:generated`: two artifacts stale after the export (`api-surface/shared.json`, `export-origins/shared.json`), regenerated with `gen:api-surface` and `gen:export-origins`, then all gates up to date. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --ran`: 87 derived families accounted for, 86 run, 1 NOT MEASURED. `pnpm check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (exit 3: it reads every package's `dist/`, and this worktree did not run a full `pnpm build`); CI's Lint and Repo Gates runs it. `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone's missing fixture commit, then 0 after fetching that commit. The PR-body gates (`check-changeset-no-major`, `check-closing-keyword-parity`, `check-partof-closing-keyword`, `check-adr-0087-registration`, `check-empty-changeset`) ran on this body: exit 0. `pnpm check:new-line-citations` under `ui/`: 0 new citations. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jUHdaZrLT9hey9hCyL --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUHdaZrLT9hey9hCyL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4748bac commit 2ac698b

23 files changed

Lines changed: 1000 additions & 212 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-auth': patch
4+
'@object-ui/console': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
feat(console): the workspace prompt also asks an administrator for a default currency that is still unset, offering the browser region's currency
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
`@objectstack/spec/shared` gains `REGION_CURRENCY` and `currencyForLocale`, the checked-in CLDR region → currency table and its lookup. They move there from `@objectstack/plugin-auth`, which gives a new organization its creator's currency through them and no longer keeps its own copy. The table and the mapping are unchanged. Neither name was exported from the plugin-auth package entry, so nothing an importer of that package used moves.
14+
15+
The console's one-time workspace prompt (Setup → Localization's keys, asked once per administrator per workspace) now covers `localization.currency` too. While that key has no value on any rung, code-less amounts show as plain numbers; an administrator who may write the namespace is asked once, with the field pre-filled with the currency of the browser's first preferred language (`zh-CN` → CNY, shown as "人民币(CNY)" in Chinese). Confirm writes it through the Settings page's own `PUT /api/settings/localization`; "Not now" writes nothing. When the timezone is still the default as well, it is one dialog with both fields and one write. A browser language with no country region (`es-419`) offers no currency, and the prompt does not ask about it. No settings key, server-side dismissed flag or gate is added: the prompt keeps its existing per-device record, so an administrator already asked about the timezone is not asked again.
16+
17+
`@object-ui/i18n` gains eight `console.workspaceTimezonePrompt.*` strings in every built-in locale.

‎packages/plugins/plugin-auth/src/organization-default-currency.test.ts‎

Lines changed: 4 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,17 @@
22

33
/**
44
* [#25495] The derivation and composition halves of a new organization's
5-
* default currency, in isolation: the CLDR table, the locale → currency
6-
* mapping, the host-locale reading, the never-overwrite rule and the wrapping
7-
* of a host's `onOrganizationCreated`. The composed behaviour, through a real
5+
* default currency, in isolation: the host-locale reading, the never-overwrite
6+
* rule and the wrapping of a host's `onOrganizationCreated`. The CLDR table and
7+
* the locale → currency mapping live in `@objectstack/spec/shared`, and
8+
* `region-currency.test.ts` there pins them. The composed behaviour, through a real
89
* `POST /organization/create` and the real settings service, is
910
* `organization-created-default-currency.test.ts`.
1011
*/
1112

1213
import { describe, expect, it, vi } from 'vitest';
13-
import { isValueDomainMember } from '@objectstack/spec/shared';
1414
import {
15-
REGION_CURRENCY,
1615
creatorLocaleForNewOrganization,
17-
currencyForLocale,
1816
hostLocaleFromEnv,
1917
seedOrganizationCurrency,
2018
withOrganizationDefaultCurrency,
@@ -36,47 +34,6 @@ function door(source: string) {
3634

3735
const silent = { info: () => {}, warn: () => {}, debug: () => {} };
3836

39-
describe('REGION_CURRENCY — the checked-in CLDR snapshot', () => {
40-
it('holds only codes the settings key accepts (the iso_4217_currency value domain)', () => {
41-
const outside = Object.entries(REGION_CURRENCY).filter(([, code]) => !isValueDomainMember('iso_4217_currency', code));
42-
expect(outside).toEqual([]);
43-
});
44-
45-
it('carries the regions the card names, and none of the CLDR regions with no current tender', () => {
46-
expect(REGION_CURRENCY).toMatchObject({ CN: 'CNY', JP: 'JPY', GB: 'GBP', DE: 'EUR', FR: 'EUR', US: 'USD' });
47-
for (const region of ['AQ', 'ZZ', 'SU', 'YU']) expect(REGION_CURRENCY).not.toHaveProperty(region);
48-
expect(Object.keys(REGION_CURRENCY)).toHaveLength(255);
49-
});
50-
});
51-
52-
describe('currencyForLocale', () => {
53-
it.each([
54-
['zh-CN', 'CNY'],
55-
['zh', 'CNY'],
56-
['ja', 'JPY'],
57-
['ja-JP', 'JPY'],
58-
['en-GB', 'GBP'],
59-
['de', 'EUR'],
60-
['fr-FR', 'EUR'],
61-
['en', 'USD'],
62-
['zh-Hant', 'TWD'],
63-
[' zh-CN ', 'CNY'],
64-
])('%s → %s', (tag, currency) => {
65-
expect(currencyForLocale(tag)).toBe(currency);
66-
});
67-
68-
it.each([
69-
['a region that is not a country', 'es-419'],
70-
['another region that is not a country', 'en-150'],
71-
['a malformed tag', 'not a tag!'],
72-
['an empty string', ''],
73-
['undefined', undefined],
74-
['a non-string', 42],
75-
])('%s maps to nothing', (_label, tag) => {
76-
expect(currencyForLocale(tag)).toBeUndefined();
77-
});
78-
});
79-
8037
describe('hostLocaleFromEnv', () => {
8138
it('reads LANG the POSIX way, dropping the codeset and modifier', () => {
8239
expect(hostLocaleFromEnv({ LANG: 'zh_CN.UTF-8' })).toBe('zh-CN');

‎packages/plugins/plugin-auth/src/organization-default-currency.ts‎

Lines changed: 4 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
* - **The mapping** ({@link currencyForLocale}): the tag's region, or, for a
2929
* bare language, the region `Intl.Locale#maximize()` gives it (`zh` → `CN`,
3030
* `ja` → `JP`, `en` → `US`), then that region's currency from
31-
* {@link REGION_CURRENCY}, a checked-in CLDR snapshot. A tag with no country
31+
* `REGION_CURRENCY`, a checked-in CLDR snapshot. Both live in
32+
* `@objectstack/spec/shared` (`region-currency.ts`), which the console's
33+
* workspace prompt reads too, so there is one table. A tag with no country
3234
* region (`es-419`, `en-150`) or a region with no current currency maps to
3335
* nothing.
3436
* - **Nothing to derive from ⇒ nothing is written.** There is no fallback to
@@ -48,90 +50,10 @@
4850

4951
import { tryGetCurrentAuthEndpointContext } from '@better-auth/core/context';
5052
import type { IDataEngine } from '@objectstack/spec/contracts';
53+
import { currencyForLocale } from '@objectstack/spec/shared';
5154
import { preferredLocaleFromHeader, SystemObjectName } from '@objectstack/spec/system';
5255
import { acceptLanguageHeader } from './auth-manager.js';
5356

54-
/**
55-
* Region (ISO 3166-1 alpha-2 and the CLDR territory codes beside it) → the
56-
* region's current ISO 4217 currency.
57-
*
58-
* ## Provenance: CLDR `currencyData`, checked in, not hand-written
59-
*
60-
* Generated on 2026-10-24 from CLDR 48 `supplemental/currencyData.json`
61-
* (`cldr-core@48.0.0` on npm, sha256 of that file
62-
* `ab007c5bb654a11601d6328f73a9b5b504334c556f3edb760020d6e2cc408472`), the
63-
* same CLDR release as `CURRENCY_FRACTION_DIGITS` in `@objectstack/spec`.
64-
* For each two-letter region the value is the FIRST entry of its
65-
* `currencyData.region` list that has no `_to` date and is not
66-
* `_tender: "false"`. CLDR orders that list so the first current tender is the
67-
* region's primary currency (`PS` → `ILS` ahead of `JOD`). Entries are kept
68-
* only when the code is in the `iso_4217_currency` value domain, the set the
69-
* settings key accepts. Eleven regions have no current tender and are absent:
70-
* `AQ` and `ZZ` (`XXX`), and the retired `BU` `CP` `CS` `DD` `SU` `TP` `YD`
71-
* `YU` `ZR`.
72-
*
73-
* `Intl` has no region → currency lookup, which is why this is a table. To
74-
* refresh it for a later CLDR, regenerate it by the rule above and update this
75-
* provenance block. Do not edit entries by hand.
76-
*/
77-
export const REGION_CURRENCY: Readonly<Record<string, string>> = {
78-
AC: 'SHP', AD: 'EUR', AE: 'AED', AF: 'AFN', AG: 'XCD', AI: 'XCD', AL: 'ALL', AM: 'AMD',
79-
AO: 'AOA', AR: 'ARS', AS: 'USD', AT: 'EUR', AU: 'AUD', AW: 'AWG', AX: 'EUR', AZ: 'AZN',
80-
BA: 'BAM', BB: 'BBD', BD: 'BDT', BE: 'EUR', BF: 'XOF', BG: 'EUR', BH: 'BHD', BI: 'BIF',
81-
BJ: 'XOF', BL: 'EUR', BM: 'BMD', BN: 'BND', BO: 'BOB', BQ: 'USD', BR: 'BRL', BS: 'BSD',
82-
BT: 'BTN', BV: 'NOK', BW: 'BWP', BY: 'BYN', BZ: 'BZD', CA: 'CAD', CC: 'AUD', CD: 'CDF',
83-
CF: 'XAF', CG: 'XAF', CH: 'CHF', CI: 'XOF', CK: 'NZD', CL: 'CLP', CM: 'XAF', CN: 'CNY',
84-
CO: 'COP', CR: 'CRC', CU: 'CUP', CV: 'CVE', CW: 'XCG', CX: 'AUD', CY: 'EUR', CZ: 'CZK',
85-
DE: 'EUR', DG: 'USD', DJ: 'DJF', DK: 'DKK', DM: 'XCD', DO: 'DOP', DZ: 'DZD', EA: 'EUR',
86-
EC: 'USD', EE: 'EUR', EG: 'EGP', EH: 'MAD', ER: 'ERN', ES: 'EUR', ET: 'ETB', EU: 'EUR',
87-
FI: 'EUR', FJ: 'FJD', FK: 'FKP', FM: 'USD', FO: 'DKK', FR: 'EUR', GA: 'XAF', GB: 'GBP',
88-
GD: 'XCD', GE: 'GEL', GF: 'EUR', GG: 'GBP', GH: 'GHS', GI: 'GIP', GL: 'DKK', GM: 'GMD',
89-
GN: 'GNF', GP: 'EUR', GQ: 'XAF', GR: 'EUR', GS: 'GBP', GT: 'GTQ', GU: 'USD', GW: 'XOF',
90-
GY: 'GYD', HK: 'HKD', HM: 'AUD', HN: 'HNL', HR: 'EUR', HT: 'HTG', HU: 'HUF', IC: 'EUR',
91-
ID: 'IDR', IE: 'EUR', IL: 'ILS', IM: 'GBP', IN: 'INR', IO: 'USD', IQ: 'IQD', IR: 'IRR',
92-
IS: 'ISK', IT: 'EUR', JE: 'GBP', JM: 'JMD', JO: 'JOD', JP: 'JPY', KE: 'KES', KG: 'KGS',
93-
KH: 'KHR', KI: 'AUD', KM: 'KMF', KN: 'XCD', KP: 'KPW', KR: 'KRW', KW: 'KWD', KY: 'KYD',
94-
KZ: 'KZT', LA: 'LAK', LB: 'LBP', LC: 'XCD', LI: 'CHF', LK: 'LKR', LR: 'LRD', LS: 'ZAR',
95-
LT: 'EUR', LU: 'EUR', LV: 'EUR', LY: 'LYD', MA: 'MAD', MC: 'EUR', MD: 'MDL', ME: 'EUR',
96-
MF: 'EUR', MG: 'MGA', MH: 'USD', MK: 'MKD', ML: 'XOF', MM: 'MMK', MN: 'MNT', MO: 'MOP',
97-
MP: 'USD', MQ: 'EUR', MR: 'MRU', MS: 'XCD', MT: 'EUR', MU: 'MUR', MV: 'MVR', MW: 'MWK',
98-
MX: 'MXN', MY: 'MYR', MZ: 'MZN', NA: 'NAD', NC: 'XPF', NE: 'XOF', NF: 'AUD', NG: 'NGN',
99-
NI: 'NIO', NL: 'EUR', NO: 'NOK', NP: 'NPR', NR: 'AUD', NU: 'NZD', NZ: 'NZD', OM: 'OMR',
100-
PA: 'PAB', PE: 'PEN', PF: 'XPF', PG: 'PGK', PH: 'PHP', PK: 'PKR', PL: 'PLN', PM: 'EUR',
101-
PN: 'NZD', PR: 'USD', PS: 'ILS', PT: 'EUR', PW: 'USD', PY: 'PYG', QA: 'QAR', RE: 'EUR',
102-
RO: 'RON', RS: 'RSD', RU: 'RUB', RW: 'RWF', SA: 'SAR', SB: 'SBD', SC: 'SCR', SD: 'SDG',
103-
SE: 'SEK', SG: 'SGD', SH: 'SHP', SI: 'EUR', SJ: 'NOK', SK: 'EUR', SL: 'SLE', SM: 'EUR',
104-
SN: 'XOF', SO: 'SOS', SR: 'SRD', SS: 'SSP', ST: 'STN', SV: 'USD', SX: 'XCG', SY: 'SYP',
105-
SZ: 'SZL', TA: 'GBP', TC: 'USD', TD: 'XAF', TF: 'EUR', TG: 'XOF', TH: 'THB', TJ: 'TJS',
106-
TK: 'NZD', TL: 'USD', TM: 'TMT', TN: 'TND', TO: 'TOP', TR: 'TRY', TT: 'TTD', TV: 'AUD',
107-
TW: 'TWD', TZ: 'TZS', UA: 'UAH', UG: 'UGX', UM: 'USD', US: 'USD', UY: 'UYU', UZ: 'UZS',
108-
VA: 'EUR', VC: 'XCD', VE: 'VES', VG: 'USD', VI: 'USD', VN: 'VND', VU: 'VUV', WF: 'XPF',
109-
WS: 'WST', XK: 'EUR', YE: 'YER', YT: 'EUR', ZA: 'ZAR', ZM: 'ZMW', ZW: 'ZWG',
110-
};
111-
112-
/**
113-
* The ISO 4217 currency of a BCP 47 tag's region, or `undefined`.
114-
*
115-
* The region is the tag's own (`zh-CN` → `CN`), or for a tag without one the
116-
* likely region `Intl.Locale#maximize()` gives (`zh` → `CN`, `ja` → `JP`,
117-
* `zh-Hant` → `TW`). A malformed tag, a tag whose region is not a country
118-
* (`es-419`, `en-150`), and a region with no current currency all answer
119-
* `undefined`: no guess is made.
120-
*/
121-
export function currencyForLocale(tag: unknown): string | undefined {
122-
if (typeof tag !== 'string') return undefined;
123-
const value = tag.trim();
124-
if (!value) return undefined;
125-
let region: string | undefined;
126-
try {
127-
region = new Intl.Locale(value).maximize().region;
128-
} catch {
129-
return undefined;
130-
}
131-
if (!region || !Object.prototype.hasOwnProperty.call(REGION_CURRENCY, region)) return undefined;
132-
return REGION_CURRENCY[region];
133-
}
134-
13557
/**
13658
* The host's locale as a BCP 47 tag, read from the POSIX environment, or
13759
* `undefined`.

‎packages/spec/api-surface/shared.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"Protection (type)",
7575
"ProtectionSchema (const)",
7676
"QUALIFIED_ITEM_NAME_PATTERN (const)",
77+
"REGION_CURRENCY (const)",
7778
"RETIRED_KEY_UPGRADE_GUIDE_SENTENCE (const)",
7879
"RateLimitConfig (type)",
7980
"RateLimitConfigParsed (type)",
@@ -104,6 +105,7 @@
104105
"canonicalMetaUrlType (function)",
105106
"cel (function)",
106107
"cron (function)",
108+
"currencyForLocale (function)",
107109
"expression (function)",
108110
"findClosestMatches (function)",
109111
"formatSuggestion (function)",

‎packages/spec/export-origins/shared.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"Protection": "src/shared/protection.zod.ts#Protection (type)",
7070
"ProtectionSchema": "src/shared/protection.zod.ts#ProtectionSchema (const)",
7171
"QUALIFIED_ITEM_NAME_PATTERN": "src/shared/identifiers.zod.ts#QUALIFIED_ITEM_NAME_PATTERN (const)",
72+
"REGION_CURRENCY": "src/shared/region-currency.ts#REGION_CURRENCY (const)",
7273
"RETIRED_KEY_UPGRADE_GUIDE_SENTENCE": "src/shared/protocol-upgrade-guide.ts#RETIRED_KEY_UPGRADE_GUIDE_SENTENCE (const)",
7374
"RateLimitConfig": "src/shared/http.zod.ts#RateLimitConfig (type)",
7475
"RateLimitConfigParsed": "src/shared/http.zod.ts#RateLimitConfigParsed (type)",
@@ -99,6 +100,7 @@
99100
"canonicalMetaUrlType": "src/meta-spelling/metadata-url-spelling.ts#canonicalMetaUrlType (function)",
100101
"cel": "src/shared/expression.zod.ts#cel (function)",
101102
"cron": "src/shared/expression.zod.ts#cron (function)",
103+
"currencyForLocale": "src/shared/region-currency.ts#currencyForLocale (function)",
102104
"expression": "src/shared/expression.zod.ts#expression (function)",
103105
"findClosestMatches": "src/shared/suggestions.zod.ts#findClosestMatches (function)",
104106
"formatSuggestion": "src/shared/suggestions.zod.ts#formatSuggestion (function)",

‎packages/spec/src/shared/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export * from './resilient-fetch';
3737
// specifiers and object fields (maintainer ruling 2026-09-02). Declared here so
3838
// `system/` and `data/` both reference one schema instead of carrying a copy.
3939
export * from './value-domain.zod';
40+
// The CLDR region → currency table and its lookup, read by plugin-auth (a new
41+
// organization's default currency) and by the console's workspace prompt, so
42+
// neither carries its own copy. Imports nothing.
43+
export * from './region-currency';
4044
// [#15676] The shared epoch-milliseconds INSTANT (`EpochMs`), and the first of
4145
// the two structural exemptions ruling B of #14478 declares ON THE SCHEMA
4246
// rather than in a gate ledger: a key whose value is this schema is an instant,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `shared/region-currency.ts`: the checked-in CLDR region → currency table and
5+
* its lookup, read by plugin-auth (a new organization's currency) and by the
6+
* console (the workspace prompt's offer). Both readers' own tests drive them
7+
* through this module; the table and the mapping are pinned here, once.
8+
*/
9+
10+
import { describe, expect, it } from 'vitest';
11+
import { REGION_CURRENCY, currencyForLocale } from './region-currency';
12+
import { isValueDomainMember } from './value-domain.zod';
13+
import * as shared from './index';
14+
15+
describe('REGION_CURRENCY — the checked-in CLDR snapshot', () => {
16+
it('holds only codes the settings key accepts (the iso_4217_currency value domain)', () => {
17+
const outside = Object.entries(REGION_CURRENCY).filter(([, code]) => !isValueDomainMember('iso_4217_currency', code));
18+
expect(outside).toEqual([]);
19+
});
20+
21+
it('carries the regions the card names, and none of the CLDR regions with no current tender', () => {
22+
expect(REGION_CURRENCY).toMatchObject({ CN: 'CNY', JP: 'JPY', GB: 'GBP', DE: 'EUR', FR: 'EUR', US: 'USD' });
23+
for (const region of ['AQ', 'ZZ', 'SU', 'YU']) expect(REGION_CURRENCY).not.toHaveProperty(region);
24+
expect(Object.keys(REGION_CURRENCY)).toHaveLength(255);
25+
});
26+
});
27+
28+
describe('currencyForLocale', () => {
29+
it.each([
30+
['zh-CN', 'CNY'],
31+
['zh', 'CNY'],
32+
['ja', 'JPY'],
33+
['ja-JP', 'JPY'],
34+
['en-GB', 'GBP'],
35+
['de', 'EUR'],
36+
['fr-FR', 'EUR'],
37+
['en', 'USD'],
38+
['zh-Hant', 'TWD'],
39+
[' zh-CN ', 'CNY'],
40+
])('%s → %s', (tag, currency) => {
41+
expect(currencyForLocale(tag)).toBe(currency);
42+
});
43+
44+
it.each([
45+
['a region that is not a country', 'es-419'],
46+
['another region that is not a country', 'en-150'],
47+
['a malformed tag', 'not a tag!'],
48+
['an empty string', ''],
49+
['undefined', undefined],
50+
['a non-string', 42],
51+
])('%s maps to nothing', (_label, tag) => {
52+
expect(currencyForLocale(tag)).toBeUndefined();
53+
});
54+
});
55+
56+
describe('the @objectstack/spec/shared entry', () => {
57+
it('re-exports the one table and the one lookup, not copies', () => {
58+
expect(shared.REGION_CURRENCY).toBe(REGION_CURRENCY);
59+
expect(shared.currencyForLocale).toBe(currencyForLocale);
60+
});
61+
});

0 commit comments

Comments
 (0)