Skip to content

Commit 291bf44

Browse files
os-billclaude
andauthored
feat(service-settings): the default currency locks once an exchange rate or a home-currency amount uses it, and Setup names what uses it (multi-currency M1-3) (#28297)
epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5 <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgJwvCPPb41CT1Drvj7TwxCo)_ Fixes #27198 Ruling-ref: 6522614678 #28096 merged (`73b72709`); `main` merged into this branch at `317b646d` (no rebase); head `e4dc4e97`. ⛔ not released alone — R2: one Version Packages release with #27185, #28096 (#27191), #27192, #27190, #27193, #27196 (ADR-0144 D9). Clause-②: yes (widening) — new exports on `@objectstack/service-settings` (`SettingsService.registerWritePredicate`, the types `SettingsWritePredicate`, `SettingsWritePredicateInput`, `SettingsWriteRefusal`, `SettingsDisplayOptions`, and an optional display argument on `setMany` / `liveStatusOf`). The new refusal narrows no released accept set: it fires only while a `sys_exchange_rate` row or a `homeAmount` declaration exists, and neither is in any published version (npm `@objectstack/platform-objects` is 18.2.0, without the rate table; `homeAmount`, #28096, is merged but unreleased). See Acceptance notes for the one case that flips the arm to `(narrowing)`. Metadata-protocol: none ## 维护者速读 - **改了什么:** 组织默认币种(`localization.currency`)一旦被使用就锁定:本组织有任何一条汇率记录,或任何对象在字段上声明了 `currencyConfig.homeAmount`,再改成别的币种或清空都会被 400 `SETTINGS_VALIDATION` 拒绝,拒绝落在 currency 字段上并点名使用者(N 个对象、M 条汇率)。Setup → Localization 页顶部用横幅说明锁定及使用者,英、简中、日、西四语随请求语言。 - **为什么改:** 改本位币等于给所有已换算、已存储的本位币金额换标签,而没有任何东西会重算它们(ADR-0144 D4)。 - **风险与回滚:** 新机制「settings 写侧谓词」只在写入真正改变该键时才问;重存当前值永不被拒,同页其他设置照常保存。未设置时首次选择币种不拦(Open item 4 本卡这一半)。锁不在启动时记录,每次保存和读页都现查,最后一条汇率删掉、最后一个 `homeAmount` 声明移除后自动解除。回滚即撤 PR。 - **先看:** `packages/services/service-settings/src/localization-currency-lock.ts`(谓词、横幅、四语文案),再看 `settings-service.ts` 的 `refuseByWritePredicates` 与 `setMany` 里调用它的一行。 - **两分钟验证:** `pnpm --filter @objectstack/service-settings exec vitest run src/localization-currency-lock.test.ts src/settings-write-predicate.test.ts`,预期 31 条全绿:首条汇率前、首个 `homeAmount` 前可改;之后各自被拒并点名;四语文案;横幅;PUT 路由 400。 - **需要你做的:** 无需批准(不碰受管路径)。请留意:18.3.0 的 Version Packages(#26517)已收进汇率表 #27185 的 changeset;若它先于 R2 发布,本 PR 的拒绝就会收窄一个已发布状态,`Clause-②` 须改为 `(narrowing)` 并补 ADR-0087 处置。 ## What changes **New mechanism: the settings write predicate** (`SettingsService.registerWritePredicate(namespace, key, predicate)`, ADR-0144 D4). The card names the gap: `service-settings` had no write-side rule that can refuse a value change on engine state. The declared constraints (`options`, `pattern`, `valueDomain`, the value window) judge a value by itself; a predicate judges whether the value may change at all, given what the rest of the system holds. - `setMany` asks a key's predicate only when the write CHANGES the key: the submitted value (`null` / `undefined` read as a clear) against the value the caller's cascade resolves today. Re-saving the value in force is never refused, so a form that posts back what it read, or the rest of the page, still saves. - It is asked after `validatePatch` (a malformed value hears about its own fault first) and before the crypto pass and every write, so a refused batch writes nothing. All refusals of a batch come back at once as `SettingsValidationError`, one `FieldError` per key: `invalid_value` (ADR-0114's slot for a reason no other member names), the predicate's sentence as `message`, its facts as `constraint`. - The predicate gets `{ namespace, key, next, current, organizationId, ctx, locale }`. `organizationId` is the organization the row lands in, the same owner the row write computes. A predicate that throws fails the write: the state it guards could not be read, so the change is not made (500 through the route's existing fault arm). - The live-status provider input gains `organizationId()` (resolved only when called) and `locale`. - `SettingsDisplayOptions { locale }` travels BESIDE the caller's context, as a new optional last argument of `setMany` and `liveStatusOf`, never inside `SettingsContext`: the door pins hold that the service sees the very context the resolver returned, and a display language is not identity. `GET` and `PUT /api/settings/:namespace` pass the request's language (`Accept-Language` via `preferredLocaleFromHeader`, else `?locale`), the same reading REST's metadata door takes. **The home-currency lock** (`localization-currency-lock.ts`), registered by `SettingsServicePlugin` on `kernel:ready` right after the engine binds: - **Used** = the organization's `sys_exchange_rate` rows (a system `count` with the organization filter explicit; a composition that does not register the table holds none), or any registered object whose metadata declares `currencyConfig.homeAmount` on a field (`engine.registry.getAllObjects()`). A record-mode amount without `homeAmount` does not count. - **Predicate** on `localization.currency`: a change to a SET currency (another code, or a clear) is refused while used. The message names what uses it: "The default currency is locked at USD because it is in use by 2 objects (Invoice, Order) and 14 exchange rates. Changing it would relabel every amount already converted into it. It can change again only once no exchange rate is recorded and no object declares a home-currency amount." `constraint: { lockedBy: 'in_use', objects: [names], exchangeRates: n }`. Up to five object labels are listed, then an ellipsis. - **Unset currency**: the first value passes. Nothing has been converted into a currency that does not exist, and the lock never invents a default or blocks choosing one (ADR-0144 Open item 4, this card's half; #27192 owns the converting-write half). - **Setup copy**: the Localization page's live-status banner. Locked: "Default currency locked: USD — In use by 1 object (Invoice) and 1 exchange rate. Converted amounts are stored in this currency, so it can no longer be changed." (`info`). Unset but needed: "No default currency set — …Choose it below; once saved, it cannot be changed." (`warning`). Nothing while nothing uses it. - **Locales**: `CURRENCY_LOCK_COPY` carries en, zh-CN, ja-JP and es-ES, the locales `settingsBuiltinTranslations` ships; a test holds the two key sets equal. The copy lives beside the lock and not in the settings bundle because it is computed per request with counts and names, and the console's banner lookup would replace a computed live-status text with a fixed one (`SETTINGS_LIVE_STATUS_BANNER_ID`'s note). Locale pick: exact, then by language (`ja` → ja-JP, `es-MX` → es-ES), else English. - Nothing is recorded at boot: every save and every page read asks the engine, so the lock lifts by itself. **Changeset** `.changeset/27198-home-currency-lock.md`: `@objectstack/service-settings` patch (a non-protocol change; `Metadata-protocol: none`), with both declaration lines and the R2 pairing sentence. ## Why ADR-0144 D4 (ruled letter A): the home currency is `localization.currency`, "locked once a rate row or a home-amount declaration exists". Ruling 6522614678 on #8345 (letter A): build R2 now, release R2 after the customer confirms the consolidation terms. ## Verification - New `settings-write-predicate.test.ts` (7): asked with current / next / organization; not asked on a re-save; refuses the whole batch before any write with the refusal at the key; a clear is a change; declared constraints first (`ZZZ` → `value_domain`, predicate not asked); a throwing predicate fails the write; registration refused for an unknown namespace or key. - New `localization-currency-lock.test.ts` (24): saves and changes before the first rate row and before any `homeAmount`; a record-mode amount without `homeAmount` and another organization's rows do not lock; refused after the first rate row and after a `homeAmount` declaration, each naming its user (code, path, label, `constraint`, the message's first sentence); both named together; a clear and the namespace reset refused; a re-save and its batch pass; the first value passes while unset; the lock lifts once both are gone; no rate table ⇒ no count; a failing count fails the write; `readCurrencyUsage` scopes by organization under a system read; copy covers exactly the bundle's locales; the refusal in zh-CN, `ja`, `es-MX` and `fr-FR` (English); the page states nothing while unused, the locked banner in en and zh-CN, the unset warning in ja-JP; `PUT` answers 400 `SETTINGS_VALIDATION` in es-ES; no `localization` namespace ⇒ nothing registered. - Ablations (each restored to HEAD after): the `setMany` call removed → 16 of 31 red; `isUsed` → `false` → 13 red; the `homeAmount` detector blinded → 8 red; the organization filter dropped → 2 red; the change gate dropped → 2 red. - Package: `@objectstack/service-settings` 67 files / 1107 tests passed; `tsc --noEmit` exit 0; eslint on the touched files exit 0. Re-run after the main merge (install, closure rebuilt) on `e4dc4e97`: 67 files / 1107 passed, `tsc` exit 0. - Gates: `dispatch-gates --commands` on `5e49fa73` (9 paths against #28096's head; the main merge leaves the delta the same 9 files, byte for byte): 63 commands, each exit code captured before any pipe; 62 exit 0, 1 exit 3 (`check:dual-build-cjs-loads`, PREREQUISITE NOT MET: it reads every package's `dist/`, and only this package's closure is built here; CI's Build Core measures it). `dispatch-gates --ran`: 63 accounted, 62 run, 1 NOT-MEASURED, exit 0. ## Acceptance notes - **The arm, and the one case that flips it.** Version Packages #26517 (18.3.0) already consumed `.changeset/27185-exchange-rate-table.md` (read on `changeset-release/main` at `1f5d5a8e`, 2026-10-31 00:18Z). If 18.3.0 is released before R2, organizations can hold rate rows and change their currency freely in 18.3.x, and this PR then refuses that change: a narrowing of a released accept set. The declaration would have to become `Clause-②: yes (narrowing)` with an ADR-0087 disposition before R2 ships. Today it narrows nothing released. - **Known trade-off: `homeAmount` is deployment metadata, so it locks every organization.** The card's predicate is kept as written: "any object whose metadata declares `homeAmount`". Objects are shared by every organization of a deployment, so once one is declared, every organization whose currency is set is locked, including an organization that holds no converted rows yet. A new organization can still pick its first currency. Loosening a lock later is easy and adding one after the fact is not, so this can be relaxed per organization later (an organization with no converted rows) if needed. - **The namespace reset is refused while the currency is used.** A reset clears `currency`, and a clear is a change. The other Localization keys can still be cleared one by one. - **In-process writers are unaffected in practice.** plugin-auth's organization-default write and the dev seed write a currency only where none is set on any rung, which the lock always lets through. They call `set` without a display language, so a refusal they ever met would read in English. - **The Localization page's live-status slot is taken.** `registerLiveStatus` keeps the last registration per namespace; nothing else registers one for `localization` today. - **Not touched:** `packages/objectql` write paths, the spec, `skills/**`, `docs/adr/**`, `.claude/**`, and the console under `ui/` (the banner and the field error render through the existing paths: `info_banner` at the page head, `details.fields` under the input). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint passes on every touched file (the whole-repo `pnpm lint` is left to CI). - [x] 4. One issue, one branch, one pull request (started on #28096's head; `main` merged in after #28096 landed, no rebase). - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CYUAEJV7QpzuJm62Te --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent de2a2e4 commit 291bf44

9 files changed

Lines changed: 943 additions & 3 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
feat(service-settings): the default currency (`localization.currency`) is locked once an exchange rate or a home-currency amount uses it, and the Localization page names what uses it (ADR-0144 D4)
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
⛔ Not released alone: this ships in the same release as the exchange-rate table and the home-currency amount it guards (ADR-0144 D9, release R2).
11+
12+
The organization's default currency is its home currency: exchange rates are entered against it, and home-currency amounts are converted into it and stored. Changing it afterwards would relabel every amount already converted. So:
13+
14+
- **The save is refused once the currency is used.** A change to a set default currency, to another code or to empty, is refused with `400 SETTINGS_VALIDATION` while the organization holds any exchange rate row, or any object declares `currencyConfig.homeAmount` on a field. The refusal sits on the `currency` field (`invalid_value`), names what uses it ("in use by 2 objects (Invoice, Order) and 14 exchange rates"), and carries the same facts as `constraint: { lockedBy: 'in_use', objects, exchangeRates }`. Re-saving the value in force is never refused, so the rest of the page still saves.
15+
- **Choosing a first currency is never blocked.** While the currency is unset, nothing has been converted into it, so the first save passes; the page then asks for one.
16+
- **The page says so.** Settings → Localization opens with a banner naming the lock and what uses the currency, or a warning while it is unset and already needed, in English, 简体中文, 日本語 or Español, following the request's language.
17+
- **It lifts by itself** once the last exchange rate row is deleted and the last `homeAmount` declaration is removed. Nothing is recorded at boot; every save and every page read asks the engine.
18+
19+
New for packages that own state a setting depends on: `SettingsService.registerWritePredicate(namespace, key, predicate)`, a rule over engine state asked when a write changes that key. A refusal refuses the whole batch before any row is written. Exported with its types `SettingsWritePredicate`, `SettingsWritePredicateInput`, `SettingsWriteRefusal` and `SettingsDisplayOptions` (the display language `setMany` and `liveStatusOf` now accept beside the caller's context).

‎packages/services/service-settings/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ export {
3737
type SettingsActionHandler,
3838
type SettingsLiveStatus,
3939
type SettingsLiveStatusProvider,
40+
// ADR-0144 D4 — the write-side rule a package registers on one key.
41+
type SettingsWritePredicate,
42+
type SettingsWritePredicateInput,
43+
type SettingsWriteRefusal,
44+
type SettingsDisplayOptions,
4045
SETTINGS_LIVE_STATUS_BANNER_ID,
4146
type SettingsAuditSink,
4247
type SettingsContext,

‎packages/services/service-settings/src/localization-currency-lock.test.ts‎

Lines changed: 336 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The home-currency lock (ADR-0144 D4, multi-currency M1-3).
5+
*
6+
* The organization's home currency is `localization.currency`, and it is
7+
* locked "once a rate row or a home-amount declaration exists". Changing it
8+
* after that relabels every amount already converted into it: a stored home
9+
* amount and its rate were computed against the old code, and nothing would
10+
* recompute them. So once the currency is used:
11+
*
12+
* - **The write is refused.** {@link localizationCurrencyWritePredicate} is the
13+
* key's settings write predicate. A change to a set value — another code,
14+
* or a clear — is refused while any `sys_exchange_rate` row of the
15+
* organization exists, or any registered object declares
16+
* `currencyConfig.homeAmount` on a field. The refusal names both.
17+
* - **The page says so.** {@link localizationCurrencyLiveStatus} heads the
18+
* Localization page with what uses the currency, in the caller's language.
19+
*
20+
* Setting the currency while it is UNSET is not a change of home currency:
21+
* nothing has been converted into a currency that does not exist, so that
22+
* write passes, and the page asks for it (ADR-0144 Open item 4, this card's
23+
* half: the lock never invents a default, and never blocks choosing one).
24+
*
25+
* "Used" reads the engine at each ask, never a flag recorded at boot: the lock
26+
* lifts by itself once the last rate row is deleted and the last `homeAmount`
27+
* declaration is removed.
28+
*/
29+
30+
import { resolveLocale } from '@objectstack/core';
31+
import type {
32+
SettingsLiveStatus,
33+
SettingsLiveStatusProvider,
34+
SettingsWritePredicate,
35+
} from './settings-service.types.js';
36+
37+
/** The rate table of multi-currency M1-1, owned by `@objectstack/platform-objects`. */
38+
export const EXCHANGE_RATE_OBJECT = 'sys_exchange_rate';
39+
40+
/** The engine surface the lock reads: a count, and the object registry. */
41+
export interface CurrencyUsageEngine {
42+
count(
43+
objectName: string,
44+
query?: { where?: Record<string, unknown>; context?: Record<string, unknown> },
45+
): Promise<number>;
46+
registry?: {
47+
getAllObjects?(): ReadonlyArray<unknown>;
48+
getObject?(name: string): unknown;
49+
};
50+
}
51+
52+
/** What uses the home currency of one organization. */
53+
export interface CurrencyUsage {
54+
/** Objects declaring `currencyConfig.homeAmount` on a field, in registry order. */
55+
objects: Array<{ name: string; label: string }>;
56+
/** The organization's `sys_exchange_rate` rows. */
57+
exchangeRates: number;
58+
}
59+
60+
const SYSTEM_CONTEXT = Object.freeze({ isSystem: true as const });
61+
62+
type AnyRecord = Record<string, unknown>;
63+
const isRecord = (v: unknown): v is AnyRecord => typeof v === 'object' && v !== null && !Array.isArray(v);
64+
65+
/** Does any field of this object declare a home amount? */
66+
function declaresHomeAmount(schema: AnyRecord): boolean {
67+
const fields = isRecord(schema.fields) ? schema.fields : {};
68+
return Object.values(fields).some(
69+
(def) => isRecord(def) && isRecord(def.currencyConfig) && isRecord(def.currencyConfig.homeAmount),
70+
);
71+
}
72+
73+
/**
74+
* Read what uses the home currency. `organizationId` is the organization whose
75+
* rate rows count: an id, or `null` in a composition with no organization
76+
* object, whose rows carry none. Objects are deployment metadata, so every
77+
* organization sees the same ones.
78+
*
79+
* A composition that does not register `sys_exchange_rate` holds no rate rows.
80+
* Any other failure of the count propagates: the lock does not guess.
81+
*/
82+
export async function readCurrencyUsage(
83+
engine: CurrencyUsageEngine,
84+
organizationId: string | null,
85+
): Promise<CurrencyUsage> {
86+
const objects: CurrencyUsage['objects'] = [];
87+
for (const schema of engine.registry?.getAllObjects?.() ?? []) {
88+
if (!isRecord(schema) || typeof schema.name !== 'string' || !declaresHomeAmount(schema)) continue;
89+
objects.push({ name: schema.name, label: typeof schema.label === 'string' ? schema.label : schema.name });
90+
}
91+
const registered = typeof engine.registry?.getObject === 'function'
92+
? Boolean(engine.registry.getObject(EXCHANGE_RATE_OBJECT))
93+
: true;
94+
const exchangeRates = registered
95+
? await engine.count(EXCHANGE_RATE_OBJECT, {
96+
// The organization filter is explicit: a system read carries no
97+
// organization, so nothing else scopes it.
98+
...(organizationId !== null ? { where: { organization_id: organizationId } } : {}),
99+
context: SYSTEM_CONTEXT,
100+
})
101+
: 0;
102+
return { objects, exchangeRates };
103+
}
104+
105+
const isUsed = (usage: CurrencyUsage): boolean => usage.objects.length > 0 || usage.exchangeRates > 0;
106+
107+
const isUnset = (value: unknown): boolean => value === null || value === undefined || value === '';
108+
109+
// ── Copy ─────────────────────────────────────────────────────────────────
110+
111+
/** How many object labels a sentence lists before it elides the rest. */
112+
const LISTED_OBJECTS = 5;
113+
114+
interface CurrencyLockCopy {
115+
objects(count: number, labels: string): string;
116+
rates(count: number): string;
117+
and: string;
118+
listSeparator: string;
119+
refusal(current: string, usage: string): string;
120+
lockedLabel(current: string): string;
121+
lockedText(usage: string): string;
122+
unsetLabel: string;
123+
unsetText(usage: string): string;
124+
}
125+
126+
/**
127+
* The lock's copy, one entry per locale the settings bundle ships
128+
* (`settingsBuiltinTranslations`). Kept here and not in that bundle: the
129+
* sentences are computed per request with counts and names, and the console's
130+
* banner lookup would replace a computed live-status text with a fixed one
131+
* (`SETTINGS_LIVE_STATUS_BANNER_ID`).
132+
*/
133+
export const CURRENCY_LOCK_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', CurrencyLockCopy> = {
134+
en: {
135+
objects: (n, labels) => `${n} ${n === 1 ? 'object' : 'objects'} (${labels})`,
136+
rates: (n) => `${n} ${n === 1 ? 'exchange rate' : 'exchange rates'}`,
137+
and: ' and ',
138+
listSeparator: ', ',
139+
refusal: (current, usage) =>
140+
`The default currency is locked at ${current} because it is in use by ${usage}. ` +
141+
'Changing it would relabel every amount already converted into it. It can change again only once ' +
142+
'no exchange rate is recorded and no object declares a home-currency amount.',
143+
lockedLabel: (current) => `Default currency locked: ${current}`,
144+
lockedText: (usage) =>
145+
`In use by ${usage}. Converted amounts are stored in this currency, so it can no longer be changed.`,
146+
unsetLabel: 'No default currency set',
147+
unsetText: (usage) =>
148+
'Exchange rates and home-currency amounts need a default currency, and none is set. ' +
149+
`In use: ${usage}. Choose it below; once saved, it cannot be changed.`,
150+
},
151+
'zh-CN': {
152+
objects: (n, labels) => `${n} 个对象(${labels})`,
153+
rates: (n) => `${n} 条汇率`,
154+
and: '和 ',
155+
listSeparator: '、',
156+
refusal: (current, usage) =>
157+
`默认币种已锁定为 ${current}:它已被 ${usage}使用。修改它会给所有已换算成该币种的金额换上错误的币种。` +
158+
'只有在不再有任何汇率记录、也没有任何对象声明本位币金额之后,才能修改。',
159+
lockedLabel: (current) => `默认币种已锁定:${current}`,
160+
lockedText: (usage) => `已被 ${usage}使用。换算后的金额都以该币种存储,因此不能再修改。`,
161+
unsetLabel: '尚未设置默认币种',
162+
unsetText: (usage) =>
163+
`汇率和本位币金额都需要默认币种,但目前尚未设置。使用情况:${usage}。请在下方选择;保存后将无法修改。`,
164+
},
165+
'ja-JP': {
166+
objects: (n, labels) => `${n} 件のオブジェクト(${labels})`,
167+
rates: (n) => `${n} 件の為替レート`,
168+
and: 'と ',
169+
listSeparator: '、',
170+
refusal: (current, usage) =>
171+
`既定の通貨は ${current} でロックされています。${usage}で使用されているためです。` +
172+
'変更すると、この通貨に換算済みのすべての金額に誤った通貨が付きます。' +
173+
'為替レートが 1 件もなく、基準通貨の金額を宣言するオブジェクトもなくなった場合にのみ変更できます。',
174+
lockedLabel: (current) => `既定の通貨はロックされています: ${current}`,
175+
lockedText: (usage) => `${usage}で使用中です。換算後の金額はこの通貨で保存されるため、変更できません。`,
176+
unsetLabel: '既定の通貨が設定されていません',
177+
unsetText: (usage) =>
178+
'為替レートと基準通貨の金額には既定の通貨が必要ですが、まだ設定されていません。' +
179+
`使用状況: ${usage}。下で選択してください。保存後は変更できません。`,
180+
},
181+
'es-ES': {
182+
objects: (n, labels) => `${n} ${n === 1 ? 'objeto' : 'objetos'} (${labels})`,
183+
rates: (n) => `${n} ${n === 1 ? 'tipo de cambio' : 'tipos de cambio'}`,
184+
and: ' y ',
185+
listSeparator: ', ',
186+
refusal: (current, usage) =>
187+
`La moneda predeterminada está bloqueada en ${current} porque está en uso en ${usage}. ` +
188+
'Cambiarla volvería a etiquetar todos los importes ya convertidos a ella. Solo podrá cambiarse ' +
189+
'cuando no quede ningún tipo de cambio y ningún objeto declare un importe en la moneda principal.',
190+
lockedLabel: (current) => `Moneda predeterminada bloqueada: ${current}`,
191+
lockedText: (usage) =>
192+
`En uso en ${usage}. Los importes convertidos se guardan en esta moneda, por lo que ya no se puede cambiar.`,
193+
unsetLabel: 'No hay moneda predeterminada',
194+
unsetText: (usage) =>
195+
'Los tipos de cambio y los importes en la moneda principal necesitan una moneda predeterminada, ' +
196+
`y no hay ninguna. En uso: ${usage}. Elíjala abajo; una vez guardada, no se podrá cambiar.`,
197+
},
198+
};
199+
200+
const COPY_LOCALES = Object.keys(CURRENCY_LOCK_COPY);
201+
202+
/** The copy for a caller's locale: exact, then by language, else English. */
203+
export function currencyLockCopy(locale: string | undefined): CurrencyLockCopy {
204+
const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined;
205+
return CURRENCY_LOCK_COPY[(picked ?? 'en') as keyof typeof CURRENCY_LOCK_COPY];
206+
}
207+
208+
/** "2 objects (Invoice, Order) and 14 exchange rates", in the copy's language. */
209+
export function describeCurrencyUsage(copy: CurrencyLockCopy, usage: CurrencyUsage): string {
210+
const parts: string[] = [];
211+
if (usage.objects.length > 0) {
212+
const listed = usage.objects.slice(0, LISTED_OBJECTS).map((o) => o.label);
213+
const labels = listed.join(copy.listSeparator) + (usage.objects.length > LISTED_OBJECTS ? '…' : '');
214+
parts.push(copy.objects(usage.objects.length, labels));
215+
}
216+
if (usage.exchangeRates > 0) parts.push(copy.rates(usage.exchangeRates));
217+
return parts.join(copy.and);
218+
}
219+
220+
/** The discrete values a refusal carries (`FieldError.constraint`). */
221+
function usageConstraint(usage: CurrencyUsage): Record<string, unknown> {
222+
return {
223+
lockedBy: 'in_use',
224+
objects: usage.objects.map((o) => o.name),
225+
exchangeRates: usage.exchangeRates,
226+
};
227+
}
228+
229+
// ── The predicate and the statement ──────────────────────────────────────
230+
231+
/**
232+
* `localization.currency`'s write predicate: a change to a SET currency is
233+
* refused while the currency is used. Setting it while unset passes.
234+
*/
235+
export function localizationCurrencyWritePredicate(engine: CurrencyUsageEngine): SettingsWritePredicate {
236+
return async ({ current, organizationId, locale }) => {
237+
if (isUnset(current)) return undefined;
238+
const usage = await readCurrencyUsage(engine, organizationId ?? null);
239+
if (!isUsed(usage)) return undefined;
240+
const copy = currencyLockCopy(locale);
241+
return {
242+
message: copy.refusal(String(current), describeCurrencyUsage(copy, usage)),
243+
constraint: usageConstraint(usage),
244+
};
245+
};
246+
}
247+
248+
/**
249+
* The Localization page's statement: what uses the home currency, and that it
250+
* is locked — or, while it is unset, that it is needed. Nothing to state while
251+
* nothing uses it.
252+
*
253+
* Reads the currency the caller's page resolves through `currentCurrency`, so
254+
* the statement and the form below it name the same value.
255+
*/
256+
export function localizationCurrencyLiveStatus(
257+
engine: CurrencyUsageEngine,
258+
currentCurrency: (ctx: Parameters<SettingsLiveStatusProvider>[0]['ctx']) => Promise<unknown>,
259+
): SettingsLiveStatusProvider {
260+
return async ({ ctx, organizationId, locale }): Promise<SettingsLiveStatus | undefined> => {
261+
const organization = await organizationId();
262+
if (organization === undefined) return undefined;
263+
const usage = await readCurrencyUsage(engine, organization);
264+
if (!isUsed(usage)) return undefined;
265+
const copy = currencyLockCopy(locale);
266+
const described = describeCurrencyUsage(copy, usage);
267+
const current = await currentCurrency(ctx);
268+
if (isUnset(current)) {
269+
return { label: copy.unsetLabel, bannerText: copy.unsetText(described), bannerSeverity: 'warning' };
270+
}
271+
return { label: copy.lockedLabel(String(current)), bannerText: copy.lockedText(described), bannerSeverity: 'info' };
272+
};
273+
}
274+
275+
/** The service surface the lock registers on. */
276+
export interface CurrencyLockHost {
277+
getManifest(namespace: string): unknown;
278+
registerWritePredicate(namespace: string, key: string, predicate: SettingsWritePredicate): void;
279+
registerLiveStatus(namespace: string, provider: SettingsLiveStatusProvider): void;
280+
get(namespace: string, key: string, ctx?: Parameters<SettingsLiveStatusProvider>[0]['ctx']): Promise<{ value: unknown }>;
281+
}
282+
283+
/**
284+
* Register the lock on a settings service bound to `engine`. Returns `false`,
285+
* registering nothing, when the service carries no `localization` namespace
286+
* (a host that replaced the built-in manifests).
287+
*/
288+
export function registerLocalizationCurrencyLock(host: CurrencyLockHost, engine: CurrencyUsageEngine): boolean {
289+
try {
290+
host.getManifest('localization');
291+
} catch {
292+
return false;
293+
}
294+
host.registerWritePredicate('localization', 'currency', localizationCurrencyWritePredicate(engine));
295+
host.registerLiveStatus(
296+
'localization',
297+
localizationCurrencyLiveStatus(engine, async (ctx) => (await host.get('localization', 'currency', ctx)).value),
298+
);
299+
return true;
300+
}

‎packages/services/service-settings/src/settings-routes.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
shouldDenyAnonymous,
4545
} from '@objectstack/core';
4646
import type { ErrorCode } from '@objectstack/spec/api';
47+
import { preferredLocaleFromHeader } from '@objectstack/spec/system';
4748
// The declared envelope is written in ONE place for the whole platform (#3973).
4849
// Its `extra` is `ApiError`'s own optional fields, so the undeclared siblings
4950
// #4224 retired from this module cannot come back through it either.
@@ -200,6 +201,21 @@ function refuseHeldSession(res: IHttpResponse, ctx: SettingsContext | undefined)
200201
return true;
201202
}
202203

204+
/**
205+
* The caller's display language, read the way REST's metadata door reads it:
206+
* the top `Accept-Language` tag, else `?locale`. It only picks the language of
207+
* prose a write predicate or a live-status provider composes
208+
* (`SettingsDisplayOptions`); it authorizes nothing, and it never enters the
209+
* caller's context.
210+
*/
211+
function requestLocaleOf(req: IHttpRequest): string | undefined {
212+
const header = req.headers?.['accept-language'] ?? req.headers?.['Accept-Language'];
213+
const preferred = preferredLocaleFromHeader(typeof header === 'string' ? header : undefined);
214+
if (preferred) return preferred;
215+
const query = req.query?.locale;
216+
return typeof query === 'string' && query.length > 0 ? query : undefined;
217+
}
218+
203219
export function registerSettingsRoutes(
204220
http: IHttpServer,
205221
service: SettingsService,
@@ -280,7 +296,7 @@ export function registerSettingsRoutes(
280296
// `getNamespace`, which in-process consumers call to read values: the
281297
// mail plugin reads `getNamespace('mail')` while rebuilding the very
282298
// transport its statement waits on.
283-
const liveStatus = await service.liveStatusOf(ns, ctx);
299+
const liveStatus = await service.liveStatusOf(ns, ctx, { locale: requestLocaleOf(req) });
284300
const manifest = liveStatus
285301
? { ...payload.manifest, specifiers: [liveStatus, ...payload.manifest.specifiers] }
286302
: payload.manifest;
@@ -335,7 +351,13 @@ export function registerSettingsRoutes(
335351
// namespace — the same 404 `setMany` would have raised one line later, in
336352
// the same order relative to the 403 authz check.
337353
const secretKeys = service.secretKeysOf(ns);
338-
const result = await service.setMany(ns, dropEchoedSecretMasks(body, secretKeys), ctx);
354+
const result = await service.setMany(
355+
ns,
356+
dropEchoedSecretMasks(body, secretKeys),
357+
ctx,
358+
// The language a write predicate's refusal is written in.
359+
{ locale: requestLocaleOf(req) },
360+
);
339361
// The write response carries resolved values too — including cascade
340362
// entries the caller never submitted (an upper-scope secret it may not
341363
// have set). Same boundary, same redaction.

0 commit comments

Comments
 (0)