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([]),