From 0e4febf5d4cafe0acc7e0626eada926b3619d2dd Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:33:47 +0800 Subject: [PATCH] feat(runtime): resolve tenant default currency onto ExecutionContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0053 follow-up. The `localization.currency` setting (tenant-scoped, default USD, documented as "applied when a currency field omits its own") was declared but had ZERO runtime consumers — resolveExecutionContext read only timezone + locale, so no code path could reach the tenant default. This carries it onto the context, mirroring timezone/locale exactly: - spec: `ExecutionContext.currency` (ISO 4217, optional). - runtime: `resolveLocalization` reads `localization.currency` (canonical settings path + the direct sys_setting fallback), coerced to a 3-letter code; assigned to `ctx.currency`. - rest: the REST context mirror reads + carries it too. Foundation for the unified currency-resolution chain (field currencyConfig → tenant localization.currency); analytics/template/renderer consumers land in follow-ups. Undefined when unconfigured → consumers render a plain number. Tests: resolve-execution-context 17 passed (+2: canonical + fallback currency, ISO-code coercion/junk rejection). Co-Authored-By: Claude Opus 4.8 --- .changeset/execution-context-currency.md | 16 ++++++++++++++++ packages/rest/src/rest-server.ts | 7 ++++++- .../security/resolve-execution-context.test.ts | 15 +++++++++++++++ .../src/security/resolve-execution-context.ts | 17 ++++++++++++++--- .../spec/src/kernel/execution-context.zod.ts | 9 +++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 .changeset/execution-context-currency.md diff --git a/.changeset/execution-context-currency.md b/.changeset/execution-context-currency.md new file mode 100644 index 0000000000..55c815b719 --- /dev/null +++ b/.changeset/execution-context-currency.md @@ -0,0 +1,16 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": minor +"@objectstack/rest": minor +--- + +Resolve the tenant default currency onto ExecutionContext. + +Adds `ExecutionContext.currency` (ISO 4217) and resolves it from the +`localization.currency` setting alongside `timezone`/`locale` — in both the +runtime `resolveExecutionContext` and the REST mirror. This is the foundation +for the documented "applied when a currency field omits its own" fallback: the +tenant default is now carried on every request context, so analytics enrichment, +formatters, and renderers can resolve a measure/field currency down to the org +default instead of hard-coding it. Undefined when no tenant default is +configured (consumers then render a plain number). diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 50e741135b..d448dc8240 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1044,20 +1044,24 @@ export class RestServer { // `resolveLocalization` (runtime/security/resolve-execution-context.ts). let timezone: string | undefined; let locale: string | undefined; + let currency: string | undefined; try { const settings = this.settingsServiceProvider ? await this.settingsServiceProvider(environmentId).catch(() => undefined) : undefined; if (settings && typeof settings.get === 'function') { const sctx = { tenantId, userId }; - const [tzRes, localeRes] = await Promise.all([ + const [tzRes, localeRes, currencyRes] = await Promise.all([ settings.get('localization', 'timezone', sctx).catch(() => undefined), settings.get('localization', 'locale', sctx).catch(() => undefined), + settings.get('localization', 'currency', sctx).catch(() => undefined), ]); const tzVal = tzRes?.value; const localeVal = localeRes?.value; + const currencyVal = currencyRes?.value; if (typeof tzVal === 'string' && tzVal.trim()) timezone = tzVal.trim(); if (typeof localeVal === 'string' && localeVal.trim()) locale = localeVal.trim(); + if (typeof currencyVal === 'string' && currencyVal.trim()) currency = currencyVal.trim().toUpperCase(); } } catch { /* best-effort — fall back to engine UTC default */ } return { @@ -1071,6 +1075,7 @@ export class RestServer { org_user_ids, ...(timezone ? { timezone } : {}), ...(locale ? { locale } : {}), + ...(currency ? { currency } : {}), } as any; } catch { return undefined; diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 330a8a100e..ddb06eaf04 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -198,11 +198,24 @@ describe('resolveExecutionContext — localization (timezone + locale)', () => { settingsService: makeSettings({ 'localization.timezone': 'Europe/Paris', 'localization.locale': 'zh-CN', + 'localization.currency': 'EUR', }), })); expect(ctx.userId).toBe('u1'); expect(ctx.timezone).toBe('Europe/Paris'); expect(ctx.locale).toBe('zh-CN'); + expect(ctx.currency).toBe('EUR'); + }); + + it('resolves the tenant default currency (ISO 4217, upper-cased) and ignores junk', async () => { + const ok = await resolveExecutionContext(makeTzOpts({ + settingsService: makeSettings({ 'localization.currency': 'cny' }), + })); + expect(ok.currency).toBe('CNY'); + const junk = await resolveExecutionContext(makeTzOpts({ + settingsService: makeSettings({ 'localization.currency': 'not-a-code' }), + })); + expect(junk.currency).toBeUndefined(); }); it('falls back to a direct tenant-scoped sys_setting read when no settings service', async () => { @@ -210,10 +223,12 @@ describe('resolveExecutionContext — localization (timezone + locale)', () => { settings: [ { namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }, { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' }, + { namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' }, ], })); expect(ctx.timezone).toBe('Asia/Tokyo'); expect(ctx.locale).toBe('ja-JP'); + expect(ctx.currency).toBe('JPY'); }); it('ignores per-user sys_user_preference rows (organization-level only)', async () => { diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index e75d18d071..f0f024424f 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -90,6 +90,12 @@ function coerceLocale(value: unknown): string | undefined { return s || undefined; } +/** Coerce a stored currency value to an ISO 4217 (3-letter) code, or undefined. */ +function coerceCurrency(value: unknown): string | undefined { + const s = typeof value === 'string' ? value.trim().toUpperCase() : ''; + return /^[A-Z]{3}$/.test(s) ? s : undefined; +} + /** * Resolve the workspace localization defaults onto the ExecutionContext * (ADR-0053 Phase 2): reference `timezone` and `locale`. @@ -108,20 +114,22 @@ async function resolveLocalization( opts: ResolveOptions, ql: any, sctx: { tenantId?: string; userId?: string }, -): Promise<{ timezone: string; locale: string }> { +): Promise<{ timezone: string; locale: string; currency?: string }> { // 1. Canonical — the `localization` manifest via the settings service. try { const settings: any = await opts.getService('settings'); if (settings && typeof settings.get === 'function') { - const [tzRes, localeRes] = await Promise.all([ + const [tzRes, localeRes, currencyRes] = await Promise.all([ settings.get('localization', 'timezone', sctx).catch(() => undefined), settings.get('localization', 'locale', sctx).catch(() => undefined), + settings.get('localization', 'currency', sctx).catch(() => undefined), ]); const tz = coerceTimeZone(tzRes?.value); const locale = coerceLocale(localeRes?.value); + const currency = coerceCurrency(currencyRes?.value); // A resolved value (incl. the manifest default) means the namespace is // live — trust it and skip the legacy direct read. - if (tz || locale) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US' }; + if (tz || locale || currency) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }; } } catch { // settings service unavailable → fall through to the direct read @@ -131,9 +139,11 @@ async function resolveLocalization( // registered, or namespace not loaded). const tzRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'timezone', scope: 'tenant' }, 1); const localeRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'locale', scope: 'tenant' }, 1); + const currencyRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'currency', scope: 'tenant' }, 1); return { timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC', locale: coerceLocale(localeRows[0]?.value) ?? 'en-US', + currency: coerceCurrency(currencyRows[0]?.value), }; } @@ -363,6 +373,7 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise z.object({ */ locale: z.string().optional(), + /** + * Active default currency (ISO 4217, e.g. `USD`, `CNY`), resolved from the + * `localization` settings alongside `timezone`/`locale`. The tenant-level + * fallback applied when a currency field/measure omits its own (the + * `localization.currency` manifest contract). Undefined when no tenant + * default is configured — consumers then render a plain number. + */ + currency: z.string().optional(), + /** User role names (resolved from Member + Role) */ roles: z.array(z.string()).default([]),