diff --git a/examples/app-showcase/src/data/objects/expense-report.object.ts b/examples/app-showcase/src/data/objects/expense-report.object.ts index c24e7e489c2..227f6c8f634 100644 --- a/examples/app-showcase/src/data/objects/expense-report.object.ts +++ b/examples/app-showcase/src/data/objects/expense-report.object.ts @@ -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', @@ -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', diff --git a/examples/app-showcase/src/data/objects/project.object.ts b/examples/app-showcase/src/data/objects/project.object.ts index 6a42b0bd62b..fdc0b0126f0 100644 --- a/examples/app-showcase/src/data/objects/project.object.ts +++ b/examples/app-showcase/src/data/objects/project.object.ts @@ -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' }), diff --git a/examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts b/examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts index cdd2d48ac0b..b245c4df467 100644 --- a/examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts +++ b/examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts @@ -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' }, ], }); diff --git a/examples/app-showcase/src/ui/pages/command-center.page.ts b/examples/app-showcase/src/ui/pages/command-center.page.ts index a98af74d611..42df4e0db65 100644 --- a/examples/app-showcase/src/ui/pages/command-center.page.ts +++ b/examples/app-showcase/src/ui/pages/command-center.page.ts @@ -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) @@ -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'), }), diff --git a/examples/app-showcase/test/money-format-declarations.test.ts b/examples/app-showcase/test/money-format-declarations.test.ts new file mode 100644 index 00000000000..aed79d996a4 --- /dev/null +++ b/examples/app-showcase/test/money-format-declarations.test.ts @@ -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; +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +const fields = (o: unknown): Record => (o as { fields: Record }).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); + }); +});