Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/execution-context-currency.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 6 additions & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -1071,6 +1075,7 @@ export class RestServer {
org_user_ids,
...(timezone ? { timezone } : {}),
...(locale ? { locale } : {}),
...(currency ? { currency } : {}),
} as any;
} catch {
return undefined;
Expand Down
15 changes: 15 additions & 0 deletions packages/runtime/src/security/resolve-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,22 +198,37 @@ 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 () => {
const ctx = await resolveExecutionContext(makeTzOpts({
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 () => {
Expand Down
17 changes: 14 additions & 3 deletions packages/runtime/src/security/resolve-execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand All @@ -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),
};
}

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

return ctx;
}
Expand Down
9 changes: 9 additions & 0 deletions packages/spec/src/kernel/execution-context.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ export const ExecutionContextSchema = lazySchema(() => 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([]),

Expand Down
Loading