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
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,22 @@ export const ExpenseReport = ObjectSchema.create({
// ── Filtered roll-ups (the point of this object) ─────────────────────────
// Child FK is auto-detected (showcase_expense_line.expense_report), so none
// of these need `relationshipField`.
//
// The three money roll-ups declare `scale: 2`. A summary has no currency
// of its own, and with no `scale` a sum renders at the natural precision of
// its value, so one column read `225.75`, `917` and `1,500.5`. Two
// decimals match the line `amount` they add up.

/** Baseline: unfiltered — the grand total of every line. */
total_amount: Field.summary({
label: 'Total',
scale: 2,
summaryOperations: { object: 'showcase_expense_line', field: 'amount', function: 'sum' },
}),
/** Filtered SUM (equality) — only lines a manager has approved. */
approved_amount: Field.summary({
label: 'Approved',
scale: 2,
summaryOperations: {
object: 'showcase_expense_line',
field: 'amount',
Expand All @@ -88,6 +95,7 @@ export const ExpenseReport = ObjectSchema.create({
/** Filtered SUM (boolean) — only lines billable back to a client. */
reimbursable_amount: Field.summary({
label: 'Reimbursable',
scale: 2,
summaryOperations: {
object: 'showcase_expense_line',
field: 'amount',
Expand Down
26 changes: 24 additions & 2 deletions examples/app-showcase/src/data/objects/project.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,33 @@ export const Project = ObjectSchema.create({
{ label: 'Red', value: 'red', color: '#EF4444' },
],
}),
budget: Field.currency({ label: 'Budget', min: 0 }),
spent: Field.currency({ label: 'Spent', min: 0, defaultValue: 0 }),
// Money in one fixed currency, as on `showcase_account.annual_revenue`: with
// no currency code a currency field reads as a bare number (`200,000.00`)
// beside that column's `US$12,000,000.00`. `spent` declares the same
// currency as `budget`, so `budget_remaining` subtracts two amounts in one
// currency.
budget: Field.currency({
label: 'Budget',
min: 0,
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
}),
spent: Field.currency({
label: 'Spent',
min: 0,
defaultValue: 0,
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
}),
// A money result (#22727): `returnType: 'currency'` with the formula's own
// `currencyConfig` renders it as its sources render, not as a plain number
// (`200,000`). This pair is what `inferFormulaReturn` stamps for this
// expression over two USD amounts. `scale: 2` rounds the computed result
// to cents, so a float subtraction never returns a long fraction.
budget_remaining: Field.formula({
label: 'Budget Remaining',
expression: cel`(record.budget == null ? 0 : record.budget) - (record.spent == null ? 0 : record.spent)`,
returnType: 'currency',
currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' },
scale: 2,
}),
start_date: Field.date({ label: 'Start Date' }),
end_date: Field.date({ label: 'Target End Date' }),
Expand Down
22 changes: 12 additions & 10 deletions examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,17 @@ export const ShowcaseProjectDataset = defineDataset({
],
measures: [
{ name: 'project_count', label: 'Projects', aggregate: 'count' },
// `budget`/`spent` are currency fields with NO fixed currency (no
// `currencyConfig`, so no currency of their own). The column's currency
// resolves through the measure's own `currency` → the field's
// `currencyConfig.defaultCurrency`, read only under `currencyMode: 'fixed'`
// → the tenant `localization.currency` default; with none set it shows a
// plain grouped number (never a hardcoded "$"). To pin a symbol, set a
// measure `currency`, or give the field a fixed currency:
// `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }`.
{ name: 'budget_sum', label: 'Total Budget', aggregate: 'sum', field: 'budget', format: '0,0' },
{ name: 'spent_sum', label: 'Total Spent', aggregate: 'sum', field: 'spent', format: '0,0' },
// `budget`/`spent` are currency fields with a FIXED currency
// (`currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }`).
// The column's currency resolves through the measure's own `currency` →
// the field's `currencyConfig.defaultCurrency`, read only under
// `currencyMode: 'fixed'` → the tenant `localization.currency` default, so
// these measures need no `currency` of their own (never a hardcoded "$"
// in `format`). `0,0.00` writes a total the way the Projects list writes
// an amount, with two decimals. The Command Center's `cc_k6` tile, which
// aggregates `budget` without this dataset, declares the same pattern and
// currency, so both dashboards read one total budget alike.
{ name: 'budget_sum', label: 'Total Budget', aggregate: 'sum', field: 'budget', format: '0,0.00' },
{ name: 'spent_sum', label: 'Total Spent', aggregate: 'sum', field: 'spent', format: '0,0.00' },
],
});
15 changes: 12 additions & 3 deletions examples/app-showcase/src/ui/pages/command-center.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,23 @@ function band(id: string, cols: number, children: any[], gap = '16px'): any {
}

/** A LIVE KPI — object-metric in the new `bare` variant (big tinted number + label). */
function kpi(id: string, object: string, label: string, colorVariant: string, aggregate: any, filter?: any, format?: string): any {
function kpi(id: string, object: string, label: string, colorVariant: string, aggregate: any, filter?: any, format?: string, currency?: string): any {
return {
id, type: 'object-metric',
responsiveStyles: { large: { minWidth: '0' } },
properties: { objectName: object, label, colorVariant, variant: 'bare', aggregate, ...(filter ? { filter } : {}), ...(format ? { format } : {}) },
properties: { objectName: object, label, colorVariant, variant: 'bare', aggregate, ...(filter ? { filter } : {}), ...(format ? { format } : {}), ...(currency ? { currency } : {}) },
};
}

/**
* How a total budget is written: the pattern and currency of the
* `showcase_project_metrics` dataset's `budget_sum` measure, which the
* Delivery Operations "Total Budget" tile reads. `object-metric` takes no
* currency from the field, so `cc_k6` names it.
*/
const BUDGET_FORMAT = '0,0.00';
const BUDGET_CURRENCY = 'USD';

/**
* A dataset-bound chart with the shared brand palette. `integerYAxis` pins the
* tick step to 1 — without it, count aggregates (small integers like 0-5)
Expand Down Expand Up @@ -159,7 +168,7 @@ export const CommandCenterPage = definePage({
kpi('cc_k3', 'showcase_task', '待复审 Review', 'purple', { field: 'id', function: 'count' }, IN_REVIEW),
kpi('cc_k4', 'showcase_project', '风险项目 At-Risk', 'danger', { field: 'id', function: 'count' }, [{ field: 'health', operator: 'equals', value: 'red' }]),
kpi('cc_k5', 'showcase_account', '客户 Accounts', 'orange', { field: 'id', function: 'count' }),
kpi('cc_k6', 'showcase_project', '总预算 Budget', 'success', { field: 'budget', function: 'sum' }, undefined, '0.0a'),
kpi('cc_k6', 'showcase_project', '总预算 Budget', 'success', { field: 'budget', function: 'sum' }, undefined, BUDGET_FORMAT, BUDGET_CURRENCY),
], '10px'),
}),

Expand Down
112 changes: 112 additions & 0 deletions examples/app-showcase/test/money-format-declarations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { inferFormulaReturn } from '@objectstack/formula';

import { Account, ExpenseReport, Project } from '../src/data/objects/index.js';
import { ShowcaseProjectDataset } from '../src/ui/datasets/index.js';
import { CommandCenterPage } from '../src/ui/pages/index.js';
import { OpsDashboard } from '../src/ui/dashboards/index.js';

/**
* The showcase writes money one way.
*
* Every declaration this pins is display metadata the renderer already reads,
* so leaving one out raises nothing at `os validate` or at boot: the column
* just reads differently from the one beside it. The four readings it guards
* against:
*
* - the expense report roll-ups summed at their natural precision, so one
* column read `225.75`, `917` and `1,500.5` — they declare `scale: 2`;
* - `showcase_project.budget` had no currency and read `200,000.00` beside
* Accounts' `US$12,000,000.00` — it declares the same fixed currency;
* - `budget_remaining`, a formula over two amounts, read as a plain number
* (`200,000`) — it declares a currency result;
* - the Command Center KPI wrote the total budget as `109万` and Delivery
* Operations wrote the same sum as `1,090,000` — both write it with one
* pattern and one currency.
*/

type AnyRec = Record<string, unknown>;
const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v);

const fields = (o: unknown): Record<string, AnyRec> => (o as { fields: Record<string, AnyRec> }).fields;

const USD_FIXED = { currencyMode: 'fixed', defaultCurrency: 'USD' };

/** The node with this `id`, at any depth. */
function nodeById(root: unknown, id: string): AnyRec | undefined {
let found: AnyRec | undefined;
const visit = (v: unknown): void => {
if (found) return;
if (Array.isArray(v)) {
for (const item of v) visit(item);
return;
}
if (!isRec(v)) return;
if (v.id === id) {
found = v;
return;
}
for (const child of Object.values(v)) visit(child);
};
visit(root);
return found;
}

describe('showcase money declarations', () => {
it('the expense report money roll-ups declare two decimals', () => {
const f = fields(ExpenseReport);
for (const name of ['total_amount', 'approved_amount', 'reimbursable_amount']) {
expect(f[name]?.type, name).toBe('summary');
expect(f[name]?.scale, `${name}.scale`).toBe(2);
}
});

it('project budget and spent carry the fixed currency Accounts uses', () => {
const revenue = fields(Account).annual_revenue?.currencyConfig;
expect(revenue).toEqual(USD_FIXED);
const f = fields(Project);
expect(f.budget?.currencyConfig).toEqual(revenue);
expect(f.spent?.currencyConfig).toEqual(revenue);
});

it('budget_remaining declares a currency result, rounded to cents', () => {
const remaining = fields(Project).budget_remaining;
expect(remaining?.type).toBe('formula');
expect(remaining?.returnType).toBe('currency');
expect(remaining?.currencyConfig).toEqual(USD_FIXED);
expect(remaining?.scale).toBe(2);
});

it('the currency result is the one authoring would stamp for its expression', () => {
const f = fields(Project);
const remaining = f.budget_remaining!;
expect(inferFormulaReturn(remaining.expression as never, f as never)).toEqual({
returnType: remaining.returnType,
currencyConfig: remaining.currencyConfig,
});
});

it('both dashboards write the total budget with one format and one currency', () => {
const tile = nodeById(CommandCenterPage.regions, 'cc_k6');
const props = (tile?.properties ?? {}) as AnyRec;
expect(tile?.type).toBe('object-metric');
expect(props.aggregate).toEqual({ field: 'budget', function: 'sum' });

const widget = (OpsDashboard.widgets as AnyRec[]).find((w) => w.id === 'kpi_total_budget');
expect(widget?.values).toEqual(['budget_sum']);
const measure = ShowcaseProjectDataset.measures.find((m) => m.name === 'budget_sum');
expect(measure).toMatchObject({ aggregate: 'sum', field: 'budget' });

// The dataset-bound tile takes its currency from the measure, else from
// the aggregated field's fixed currency; the object-metric tile from its
// own `currency` only.
const datasetCurrency = measure?.currency ?? (fields(Project).budget?.currencyConfig as AnyRec | undefined)?.defaultCurrency;

expect(props.format).toBeDefined();
expect(props.format).toBe(measure?.format);
expect(props.currency).toBeDefined();
expect(props.currency).toBe(datasetCurrency);
});
});
Loading