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
11 changes: 11 additions & 0 deletions .changeset/analytics-measure-currency-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/service-analytics": minor
---

Resolve a monetary measure's display currency via the field→tenant chain.

A dataset measure-currency now resolves through: explicit measure `currency` →
source-field `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`).
A measure is monetary iff it declares a currency or aggregates a `currency`-type
field, so count/avg-of-number measures never receive a code. Wires a
`measureCurrency` field-metadata resolver from the data engine's object schema.
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,45 @@ describe('AnalyticsService.queryDataset', () => {
expect(revenueField?.format).toBe('0,0');
});

// ── ADR-0053 currency chain (measure → field currencyConfig → tenant ctx) ──
function pricedSvc(rows: Array<Record<string, unknown>>, measureCurrency?: (o: string, f: string) => { type?: string; defaultCurrency?: string } | undefined) {
return new AnalyticsService({
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
executeRawSql: async () => rows,
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
...(measureCurrency ? { measureCurrency } : {}),
});
}
const moneyDataset = (measure: Record<string, unknown>) => DatasetSchema.parse({
name: 'money', label: 'Money', object: 'opportunity', include: [],
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', label: 'Revenue', ...measure }],
});

it('chain: a currency-FIELD measure (no explicit currency) inherits the field defaultCurrency', async () => {
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency', defaultCurrency: 'EUR' } : undefined);
const r = await svc.queryDataset(moneyDataset({}), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o' } as ExecutionContext) as any;
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('EUR');
});

it('chain: a currency field with no defaultCurrency falls back to the tenant ctx.currency', async () => {
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency' } : undefined);
const r = await svc.queryDataset(moneyDataset({}), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any;
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('GBP');
});

it('chain: an explicit measure currency wins over the field default and the tenant ctx', async () => {
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency', defaultCurrency: 'EUR' } : undefined);
const r = await svc.queryDataset(moneyDataset({ currency: 'JPY' }), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any;
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('JPY');
});

it('chain: a NON-currency field measure never gets a currency (even with a tenant default)', async () => {
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'number' } : undefined);
const r = await svc.queryDataset(moneyDataset({}), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'USD' } as ExecutionContext) as any;
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBeUndefined();
});

it('enriches dimension columns with their dataset display label', async () => {
const labeled = DatasetSchema.parse({
name: 'sales2', label: 'Sales', object: 'opportunity', include: ['account'],
Expand Down
27 changes: 24 additions & 3 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ export interface AnalyticsServiceConfig {
* provided, `queryDataset` validates that every declared `include` exists.
*/
relationshipResolver?: RelationshipResolver;
/**
* ADR-0053 currency chain — resolve a measure's SOURCE FIELD currency
* metadata so a monetary measure that omits an explicit `currency` falls back
* to the field's declared currency, then the tenant default (`ctx.currency`).
* Returns the source field's `type` and (fixed-mode) `defaultCurrency`;
* `undefined` for an unknown field. Non-`currency` fields never get a code.
*/
measureCurrency?: (object: string, field: string) => { type?: string; defaultCurrency?: string } | undefined;
/** Pre-defined datasets to compile + register at construction (ADR-0021). */
datasets?: Dataset[];
/**
Expand Down Expand Up @@ -204,6 +212,7 @@ export class AnalyticsService implements IAnalyticsService {
private readonly datasetRegistry = new Map<string, CompiledDataset>();
/** Optional object-graph resolver used when compiling datasets. */
private readonly relationshipResolver?: RelationshipResolver;
private readonly measureCurrency?: AnalyticsServiceConfig['measureCurrency'];
/** Optional dimension display-label resolver (select options / lookup names). */
private readonly labelResolver?: DimensionLabelDeps;
/** ADR-0037 P3: pending-seed row resolver for draft data preview. */
Expand All @@ -222,6 +231,7 @@ export class AnalyticsService implements IAnalyticsService {

this.readScopeProvider = config.getReadScope;
this.relationshipResolver = config.relationshipResolver;
this.measureCurrency = config.measureCurrency;
this.labelResolver = config.labelResolver;
this.draftRowsResolver = config.draftRowsResolver;

Expand Down Expand Up @@ -524,11 +534,22 @@ export class AnalyticsService implements IAnalyticsService {
if (!m) continue;
if (f.label == null && typeof m.label === 'string') f.label = m.label;
if (f.format == null && m.format) f.format = m.format;
// Carry the measure's declared currency so the renderer can render a
// locale-correct symbol via Intl (never a "$" baked into `format`).
// ADR-0053 currency chain. A MONETARY measure resolves its display
// currency from: explicit measure `currency` → source-field
// `currencyConfig.defaultCurrency` → tenant default (`ctx.currency`). A
// measure is monetary if it declares a currency OR aggregates a
// `currency`-type field; non-monetary measures (count, avg of a plain
// number) never receive a currency code.
const fc = f as { currency?: string };
const mc = m as { currency?: string };
if (fc.currency == null && mc.currency) fc.currency = mc.currency;
if (fc.currency == null) {
const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : undefined;
const monetary = !!mc.currency || meta?.type === 'currency';
if (monetary) {
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
if (resolved) fc.currency = resolved;
}
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,13 @@ export class AnalyticsServicePlugin implements Plugin {
coerceTemporalFilterValue,
relationshipResolver,
labelResolver,
// ADR-0053 — source-field currency metadata for the measure currency chain.
measureCurrency: (object: string, field: string) => {
const f = dataEngine()?.getObject?.(object)?.fields?.[field] as
| { type?: string; currencyConfig?: { defaultCurrency?: string } }
| undefined;
return f ? { type: f.type, defaultCurrency: f.currencyConfig?.defaultCurrency } : undefined;
},
draftRowsResolver,
};

Expand Down
Loading