Skip to content

Commit 0e4febf

Browse files
os-zhuangclaude
andcommitted
feat(runtime): resolve tenant default currency onto ExecutionContext
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 <noreply@anthropic.com>
1 parent f4b102d commit 0e4febf

5 files changed

Lines changed: 60 additions & 4 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/rest": minor
5+
---
6+
7+
Resolve the tenant default currency onto ExecutionContext.
8+
9+
Adds `ExecutionContext.currency` (ISO 4217) and resolves it from the
10+
`localization.currency` setting alongside `timezone`/`locale` — in both the
11+
runtime `resolveExecutionContext` and the REST mirror. This is the foundation
12+
for the documented "applied when a currency field omits its own" fallback: the
13+
tenant default is now carried on every request context, so analytics enrichment,
14+
formatters, and renderers can resolve a measure/field currency down to the org
15+
default instead of hard-coding it. Undefined when no tenant default is
16+
configured (consumers then render a plain number).

packages/rest/src/rest-server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1044,20 +1044,24 @@ export class RestServer {
10441044
// `resolveLocalization` (runtime/security/resolve-execution-context.ts).
10451045
let timezone: string | undefined;
10461046
let locale: string | undefined;
1047+
let currency: string | undefined;
10471048
try {
10481049
const settings = this.settingsServiceProvider
10491050
? await this.settingsServiceProvider(environmentId).catch(() => undefined)
10501051
: undefined;
10511052
if (settings && typeof settings.get === 'function') {
10521053
const sctx = { tenantId, userId };
1053-
const [tzRes, localeRes] = await Promise.all([
1054+
const [tzRes, localeRes, currencyRes] = await Promise.all([
10541055
settings.get('localization', 'timezone', sctx).catch(() => undefined),
10551056
settings.get('localization', 'locale', sctx).catch(() => undefined),
1057+
settings.get('localization', 'currency', sctx).catch(() => undefined),
10561058
]);
10571059
const tzVal = tzRes?.value;
10581060
const localeVal = localeRes?.value;
1061+
const currencyVal = currencyRes?.value;
10591062
if (typeof tzVal === 'string' && tzVal.trim()) timezone = tzVal.trim();
10601063
if (typeof localeVal === 'string' && localeVal.trim()) locale = localeVal.trim();
1064+
if (typeof currencyVal === 'string' && currencyVal.trim()) currency = currencyVal.trim().toUpperCase();
10611065
}
10621066
} catch { /* best-effort — fall back to engine UTC default */ }
10631067
return {
@@ -1071,6 +1075,7 @@ export class RestServer {
10711075
org_user_ids,
10721076
...(timezone ? { timezone } : {}),
10731077
...(locale ? { locale } : {}),
1078+
...(currency ? { currency } : {}),
10741079
} as any;
10751080
} catch {
10761081
return undefined;

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,22 +198,37 @@ describe('resolveExecutionContext — localization (timezone + locale)', () => {
198198
settingsService: makeSettings({
199199
'localization.timezone': 'Europe/Paris',
200200
'localization.locale': 'zh-CN',
201+
'localization.currency': 'EUR',
201202
}),
202203
}));
203204
expect(ctx.userId).toBe('u1');
204205
expect(ctx.timezone).toBe('Europe/Paris');
205206
expect(ctx.locale).toBe('zh-CN');
207+
expect(ctx.currency).toBe('EUR');
208+
});
209+
210+
it('resolves the tenant default currency (ISO 4217, upper-cased) and ignores junk', async () => {
211+
const ok = await resolveExecutionContext(makeTzOpts({
212+
settingsService: makeSettings({ 'localization.currency': 'cny' }),
213+
}));
214+
expect(ok.currency).toBe('CNY');
215+
const junk = await resolveExecutionContext(makeTzOpts({
216+
settingsService: makeSettings({ 'localization.currency': 'not-a-code' }),
217+
}));
218+
expect(junk.currency).toBeUndefined();
206219
});
207220

208221
it('falls back to a direct tenant-scoped sys_setting read when no settings service', async () => {
209222
const ctx = await resolveExecutionContext(makeTzOpts({
210223
settings: [
211224
{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' },
212225
{ namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' },
226+
{ namespace: 'localization', key: 'currency', scope: 'tenant', value: 'JPY' },
213227
],
214228
}));
215229
expect(ctx.timezone).toBe('Asia/Tokyo');
216230
expect(ctx.locale).toBe('ja-JP');
231+
expect(ctx.currency).toBe('JPY');
217232
});
218233

219234
it('ignores per-user sys_user_preference rows (organization-level only)', async () => {

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ function coerceLocale(value: unknown): string | undefined {
9090
return s || undefined;
9191
}
9292

93+
/** Coerce a stored currency value to an ISO 4217 (3-letter) code, or undefined. */
94+
function coerceCurrency(value: unknown): string | undefined {
95+
const s = typeof value === 'string' ? value.trim().toUpperCase() : '';
96+
return /^[A-Z]{3}$/.test(s) ? s : undefined;
97+
}
98+
9399
/**
94100
* Resolve the workspace localization defaults onto the ExecutionContext
95101
* (ADR-0053 Phase 2): reference `timezone` and `locale`.
@@ -108,20 +114,22 @@ async function resolveLocalization(
108114
opts: ResolveOptions,
109115
ql: any,
110116
sctx: { tenantId?: string; userId?: string },
111-
): Promise<{ timezone: string; locale: string }> {
117+
): Promise<{ timezone: string; locale: string; currency?: string }> {
112118
// 1. Canonical — the `localization` manifest via the settings service.
113119
try {
114120
const settings: any = await opts.getService('settings');
115121
if (settings && typeof settings.get === 'function') {
116-
const [tzRes, localeRes] = await Promise.all([
122+
const [tzRes, localeRes, currencyRes] = await Promise.all([
117123
settings.get('localization', 'timezone', sctx).catch(() => undefined),
118124
settings.get('localization', 'locale', sctx).catch(() => undefined),
125+
settings.get('localization', 'currency', sctx).catch(() => undefined),
119126
]);
120127
const tz = coerceTimeZone(tzRes?.value);
121128
const locale = coerceLocale(localeRes?.value);
129+
const currency = coerceCurrency(currencyRes?.value);
122130
// A resolved value (incl. the manifest default) means the namespace is
123131
// live — trust it and skip the legacy direct read.
124-
if (tz || locale) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US' };
132+
if (tz || locale || currency) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency };
125133
}
126134
} catch {
127135
// settings service unavailable → fall through to the direct read
@@ -131,9 +139,11 @@ async function resolveLocalization(
131139
// registered, or namespace not loaded).
132140
const tzRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'timezone', scope: 'tenant' }, 1);
133141
const localeRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'locale', scope: 'tenant' }, 1);
142+
const currencyRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'currency', scope: 'tenant' }, 1);
134143
return {
135144
timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC',
136145
locale: coerceLocale(localeRows[0]?.value) ?? 'en-US',
146+
currency: coerceCurrency(currencyRows[0]?.value),
137147
};
138148
}
139149

@@ -363,6 +373,7 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
363373
const localization = await resolveLocalization(opts, ql, { tenantId, userId });
364374
ctx.timezone = localization.timezone;
365375
ctx.locale = localization.locale;
376+
if (localization.currency) ctx.currency = localization.currency;
366377

367378
return ctx;
368379
}

packages/spec/src/kernel/execution-context.zod.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ export const ExecutionContextSchema = lazySchema(() => z.object({
5959
*/
6060
locale: z.string().optional(),
6161

62+
/**
63+
* Active default currency (ISO 4217, e.g. `USD`, `CNY`), resolved from the
64+
* `localization` settings alongside `timezone`/`locale`. The tenant-level
65+
* fallback applied when a currency field/measure omits its own (the
66+
* `localization.currency` manifest contract). Undefined when no tenant
67+
* default is configured — consumers then render a plain number.
68+
*/
69+
currency: z.string().optional(),
70+
6271
/** User role names (resolved from Member + Role) */
6372
roles: z.array(z.string()).default([]),
6473

0 commit comments

Comments
 (0)