diff --git a/.changeset/11132-master-detail-totals-currency.md b/.changeset/11132-master-detail-totals-currency.md new file mode 100644 index 0000000000..aea369c2da --- /dev/null +++ b/.changeset/11132-master-detail-totals-currency.md @@ -0,0 +1,29 @@ +--- +'@object-ui/plugin-form': patch +--- + +fix(plugin-form): the master-detail form's Subtotal / Tax / Total show the amount's currency, not a hard-coded yen sign (objectui#11132) + +The document totals stack under a master-detail form's line items printed a +literal `¥` in front of every amount, whatever the amount field's currency or the +tenant's. A USD tenant's invoice read `¥1,234.50` under lines in dollars. + +The stack now resolves its currency through `resolveFieldCurrency`, the one +precedence every currency face shares: the amount field's fixed currency +(`currencyConfig` with `currencyMode: 'fixed'`), else the tenant's default +currency. The field definition is the one the form already loads from the child +object to derive or hydrate its columns. The amount is `Intl`'s currency format in +the display locale, so: + +- the sign sits where the locale puts it: `$1,234.50` in English, `1.234,50 $` in + German; +- the decimal places are the currency's own: two for USD and EUR, none for JPY + (a JPY tenant's stack read `¥1,234.50` and now reads `¥1,235`), three for KWD; +- the digits still follow the display locale, as objectui#9909 made them. + +With no currency known, or when the stack adds entries whose amounts resolve to +different currencies, the lines are plain numbers at two places with no sign. + +A fully configured detail entry (relationship field and every column typed) loads +no child schema, so its amount field's own currency is not read and the stack +shows the tenant's currency for it. diff --git a/packages/plugin-form/src/MasterDetailForm.totalsCurrency-11132.test.tsx b/packages/plugin-form/src/MasterDetailForm.totalsCurrency-11132.test.tsx new file mode 100644 index 0000000000..9d8ef658d3 --- /dev/null +++ b/packages/plugin-form/src/MasterDetailForm.totalsCurrency-11132.test.tsx @@ -0,0 +1,207 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The master-detail form's document totals stack (Subtotal / Tax / Total) + * shows the currency of the amounts it adds, never a constant sign + * (objectui#11132). It used to prefix a literal `¥` whatever the amount field's + * or the tenant's currency. + * + * The currency comes from `resolveFieldCurrency` on the entry's amount field, + * the one precedence every currency face shares: the field's fixed currency, + * else the tenant default (ADR-0053). The amount is `Intl`'s currency format in + * the display locale, so the width is the currency's own minor unit. + * + * Every case enters the same line (1234.5) under a 10% header tax rate, so the + * three lines read 1234.5 / 123.45 / 1357.95 before formatting. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, waitFor, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { registerAllFields } from '@object-ui/fields'; +import { MasterDetailForm } from './MasterDetailForm'; + +registerAllFields(); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const PARENT = 'po'; +const PARENT_SCHEMA = { + name: PARENT, + fields: { + ref: { type: 'text', label: 'Ref' }, + // Present so the Subtotal / Tax / Total stack renders. + tax_rate: { type: 'number', label: 'Tax Rate' }, + }, +}; + +/** A child whose `line_total` amount field carries `lineTotal`'s extra keys. */ +function lineSchema(name: string, lineTotal: Record = {}) { + return { + name, + fields: { + po: { type: 'master_detail', label: 'PO', reference: PARENT }, + line_total: { type: 'currency', label: 'Line Total', ...lineTotal }, + }, + }; +} + +function dataSource(children: Record>) { + return { + getObjectSchema: vi.fn(async (obj: string) => (obj === PARENT ? PARENT_SCHEMA : children[obj])), + find: vi.fn().mockResolvedValue({ data: [] }), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + bulk: vi.fn(), + } as never; +} + +/** + * The three ways an entry reaches the form, one per branch of its resolve + * effect: fully configured (no child schema is fetched), identity-only columns + * hydrated from the child schema (the shape `inlineColumns` produces), and a + * bare `childObject` whose FK, columns and amount field are all derived. + */ +const FULL = { + childObject: 'po_line', + relationshipField: 'po', + amountField: 'line_total', + columns: [{ name: 'line_total', label: 'Line Total', type: 'currency' }], +}; +const HYDRATED = { + childObject: 'po_line', + relationshipField: 'po', + amountField: 'line_total', + columns: [{ name: 'line_total' }], +}; +const DERIVED = { childObject: 'po_line' }; + +interface Stack { + subtotal: string; + tax: string; + total: string; +} + +/** Render, enter one 1234.5 line and a 10% tax rate, and read the three lines. */ +async function renderStack(opts: { + details: unknown[]; + children: Record>; + tenantCurrency?: string; + locale?: string; +}): Promise { + const view = render( + + + + + , + ); + const cell = await waitFor(() => screen.getAllByLabelText('Line Total')[0] as HTMLInputElement); + fireEvent.change(cell, { target: { value: '1234.5' } }); + const rate = await waitFor(() => { + const el = view.container.querySelector('input[name="tax_rate"]') as HTMLInputElement | null; + expect(el).not.toBeNull(); + return el!; + }); + fireEvent.change(rate, { target: { value: '10' } }); + // Settled once the tax line carries the rate's amount (123.45 in any width). + return waitFor(() => { + const read = (id: string) => (screen.getByTestId(id).textContent ?? '').replace(/\s+/g, ' ').trim(); + const stack = { subtotal: read('md-subtotal'), tax: read('md-tax'), total: read('md-grand-total') }; + expect(stack.tax).toMatch(/12[34]/); + expect(stack.subtotal).toMatch(/1.23[45]/); + return stack; + }); +} + +describe('the master-detail totals stack shows the amounts’ own currency (objectui#11132)', () => { + it.each([ + ['fully configured', FULL], + ['hydrated columns', HYDRATED], + ['derived', DERIVED], + ])('a USD tenant reads `$` on all three lines — %s entry', async (_shape, detail) => { + const stack = await renderStack({ + details: [detail], + children: { po_line: lineSchema('po_line') }, + tenantCurrency: 'USD', + }); + expect(stack).toEqual({ subtotal: '$1,234.50', tax: '$123.45', total: '$1,357.95' }); + }); + + it.each([ + ['hydrated columns', HYDRATED], + ['derived', DERIVED], + ])('an amount field with a FIXED currency shows that one, not the tenant’s — %s entry', async (_shape, detail) => { + const stack = await renderStack({ + details: [detail], + children: { po_line: lineSchema('po_line', { currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }) }, + tenantCurrency: 'USD', + }); + expect(stack).toEqual({ subtotal: '€1,234.50', tax: '€123.45', total: '€1,357.95' }); + }); + + it('a DYNAMIC currency config is not a fixed one: the tenant’s currency wins over its defaultCurrency', async () => { + // The resolver's own `currencyMode` rule (objectui#10422) reaches the stack: + // it reads the field through `resolveFieldCurrency`, not a hand-read key. + const stack = await renderStack({ + details: [DERIVED], + children: { po_line: lineSchema('po_line', { currencyConfig: { currencyMode: 'dynamic', defaultCurrency: 'EUR' } }) }, + tenantCurrency: 'USD', + }); + expect(stack).toEqual({ subtotal: '$1,234.50', tax: '$123.45', total: '$1,357.95' }); + }); + + it('the width is the currency’s minor unit: a JPY tenant reads whole yen, where the stack used to print two places', async () => { + const stack = await renderStack({ + details: [FULL], + children: { po_line: lineSchema('po_line') }, + tenantCurrency: 'JPY', + }); + expect(stack).toEqual({ subtotal: '¥1,235', tax: '¥123', total: '¥1,358' }); + }); + + it('the sign sits where the display locale puts it, with that locale’s grouping (de-DE)', async () => { + const stack = await renderStack({ + details: [FULL], + children: { po_line: lineSchema('po_line') }, + tenantCurrency: 'USD', + locale: 'de-DE', + }); + expect(stack).toEqual({ subtotal: '1.234,50 $', tax: '123,45 $', total: '1.357,95 $' }); + }); + + it('with no currency known anywhere, the lines are plain numbers: no guessed sign', async () => { + const stack = await renderStack({ + details: [FULL], + children: { po_line: lineSchema('po_line') }, + }); + expect(stack).toEqual({ subtotal: '1,234.50', tax: '123.45', total: '1,357.95' }); + }); + + it('entries whose amounts resolve to DIFFERENT currencies share no sign: the subtotal adds both', async () => { + const fees = { childObject: 'po_fee', title: 'Fees' }; + const stack = await renderStack({ + details: [DERIVED, fees], + children: { + po_line: lineSchema('po_line'), + po_fee: lineSchema('po_fee', { currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }), + }, + tenantCurrency: 'USD', + }); + expect(stack).toEqual({ subtotal: '1,234.50', tax: '123.45', total: '1,357.95' }); + }); +}); diff --git a/packages/plugin-form/src/MasterDetailForm.tsx b/packages/plugin-form/src/MasterDetailForm.tsx index 436ebb6d60..dd5dd2e8da 100644 --- a/packages/plugin-form/src/MasterDetailForm.tsx +++ b/packages/plugin-form/src/MasterDetailForm.tsx @@ -35,7 +35,14 @@ import type { BatchTransactionOperation, DataSource, I18nLabel } from '@object-u import { runBatchTransaction } from '@object-ui/core'; import { LineItemsField, type GridColumn } from '@object-ui/fields'; import { Button, Card, CardContent, CardHeader, CardTitle, cn, toast } from '@object-ui/components'; -import { pickLocalized, useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; +import { + formatDisplayNumber, + pickLocalized, + resolveFieldCurrency, + useDisplayLocale, + useLocalization, + useObjectTranslation, +} from '@object-ui/i18n'; import { usePermissions } from '@object-ui/permissions'; import { dataChangeMatches, subscribeDataChanges } from '@object-ui/react'; import { ObjectForm } from './ObjectForm'; @@ -351,6 +358,87 @@ interface DetailEntry { /** The authored config, with derived columns / FK folded in once resolved. */ config: MasterDetailDetailConfig; status: DetailResolution; + /** + * The child object's own definition of `config.amountField`, kept from the + * schema the resolve effect loaded, so the document totals stack can read the + * amount's currency off the FIELD (objectui#11132). Absent when no schema was + * loaded for this entry (a fully configured entry skips the fetch) or the + * child declares no such field; the stack then resolves to the tenant's + * currency. Internal state, never part of the authored config. + */ + amountFieldDef?: CurrencyFieldDef; +} + +/** The field shape `resolveFieldCurrency` reads — the one currency resolver. */ +type CurrencyFieldDef = Parameters[0]; + +/** + * The child object's definition of `fieldName`, or `undefined` when the name is + * unset or the child declares no such field. + */ +function childFieldDef( + childSchema: { fields?: Record } | undefined, + fieldName: string | undefined, +): CurrencyFieldDef { + if (!fieldName) return undefined; + const def = childSchema?.fields?.[fieldName]; + return def && typeof def === 'object' ? (def as CurrencyFieldDef) : undefined; +} + +/** + * The currency the document totals stack is denominated in (objectui#11132). + * + * Each entry that switches the stack on (one with an `amountField`) resolves its + * amount's currency through `resolveFieldCurrency`, the one precedence every + * currency face shares: the field's fixed currency, else the tenant default. + * ⛔ No constant: the stack used to print a literal `¥` whatever either said. + * + * The stack adds every entry's amounts into ONE subtotal, so it has one + * currency only when every entry resolves to the same code. When they differ, + * or none is known, this answers `undefined` and the stack shows plain numbers, + * the resolver's own answer for an amount with no known currency: never a + * guessed sign. + */ +function totalsCurrency(entries: DetailEntry[], tenantCurrency: string | undefined): string | undefined { + const codes = new Set( + entries + .filter((e) => !!e.config.amountField) + .map((e) => resolveFieldCurrency(e.amountFieldDef, tenantCurrency)), + ); + return codes.size === 1 ? codes.values().next().value : undefined; +} + +/** + * The fraction width of a totals line with no currency: the historical two + * places the stack always showed, which is also `CurrencyField`'s width when + * no currency resolves. + */ +const PLAIN_AMOUNT_DIGITS = 2; + +/** + * One line of the document totals stack, in the display locale (objectui#9909). + * + * With a currency, the amount is `Intl`'s own currency format through + * `formatDisplayNumber`, the formatter the line grid's currency cells use. So + * the sign sits where the locale puts it (`$1,234.50`, `1.234,50 $` in de-DE) + * and the width is the currency's ISO 4217 minor unit, the default `Intl` + * applies to `style: 'currency'`: 2 for USD, 0 for JPY, 3 for KWD. A + * currency's decimal places are the currency's, not a setting. + * + * Without one, a plain number at {@link PLAIN_AMOUNT_DIGITS}. A code `Intl` + * refuses (`RangeError: Invalid currency code`) is shown as the code beside + * that plain number, as `CurrencyField` and the grid cells show one, rather + * than taking the form down; the digits stay in the display locale. + */ +function formatTotalsAmount(n: number, currency: string | undefined, locale: string): string { + const plain = () => + formatDisplayNumber(n, { locale, minimumFractionDigits: PLAIN_AMOUNT_DIGITS, maximumFractionDigits: PLAIN_AMOUNT_DIGITS }); + if (!currency) return plain(); + try { + return formatDisplayNumber(n, { locale, currency }); + } catch { + return `${currency} ${plain()}`; + } } /** @@ -561,6 +649,9 @@ const MasterDetailLines: React.FC = ({ // used to pass `toLocaleString` an explicit `undefined`, i.e. the MACHINE's // locale (objectui#9909). const displayLocale = useDisplayLocale(); + // The tenant default currency (ADR-0053): the totals stack's currency when + // the amount field fixes none (objectui#11132). + const { currency: tenantCurrency } = useLocalization(); // The collection placeholder, the document totals stack and the in-form // collection's default add label, in the session locale (objectui#11071). const { t } = useFormChromeTranslation(); @@ -614,7 +705,10 @@ const MasterDetailLines: React.FC = ({ const taxPct = taxRate ?? 0; const taxAmount = subtotal * (taxPct / 100); const grandTotal = subtotal + taxAmount; - const money = (n: number) => `¥${n.toLocaleString(displayLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + // The amounts' own currency, else the tenant's; never a constant sign + // (objectui#11132). See `totalsCurrency` and `formatTotalsAmount`. + const currency = totalsCurrency(entries, tenantCurrency); + const money = (n: number) => formatTotalsAmount(n, currency, displayLocale); return ( <> @@ -901,13 +995,19 @@ export const MasterDetailForm: React.FC = ({ // untyped — hydrate just their widget types from the schema, keeping // their exact column set / order / labels (don't re-derive columns). if (d.relationshipField && d.columns?.length) { - return { ...entry, config: { ...d, columns: hydrateColumns(d.columns, childSchema) }, status: 'ready' }; + return { + ...entry, + config: { ...d, columns: hydrateColumns(d.columns, childSchema) }, + status: 'ready', + amountFieldDef: childFieldDef(childSchema, d.amountField), + }; } const derived = deriveDetail(d.childObject, childSchema, schema.objectName, { relationshipField: d.relationshipField, columns: d.columns, amountField: d.amountField, }); + const amountField = d.amountField ?? derived.amountField; return { ...entry, status: 'ready', @@ -917,9 +1017,10 @@ export const MasterDetailForm: React.FC = ({ columns: derived.columns, formFields: d.formFields ?? derived.formFields, inlineMode: d.inlineMode ?? derived.mode, - amountField: d.amountField ?? derived.amountField, + amountField, sortField: d.sortField ?? derived.sortField, }, + amountFieldDef: childFieldDef(childSchema, amountField), }; } catch (err) { // THE DERIVE FAILED, on a schema that loaded fine — almost always diff --git a/packages/plugin-form/src/formFaces.displayLocale-9909.test.tsx b/packages/plugin-form/src/formFaces.displayLocale-9909.test.tsx index bb36625aaf..168b58083a 100644 --- a/packages/plugin-form/src/formFaces.displayLocale-9909.test.tsx +++ b/packages/plugin-form/src/formFaces.displayLocale-9909.test.tsx @@ -35,10 +35,10 @@ afterEach(() => { vi.restoreAllMocks(); }); -function session(locale: string, node: React.ReactNode): React.ReactElement { +function session(locale: string, node: React.ReactNode, currency?: string): React.ReactElement { return ( - {node} + {node} ); } @@ -112,6 +112,8 @@ interface Surface { read: () => Promise; de: RegExp; en: RegExp; + /** The tenant's default currency, for a surface that shows money. */ + currency?: string; } const SURFACES: Surface[] = [ @@ -134,8 +136,12 @@ const SURFACES: Surface[] = [ return text; }); }, - de: /^¥1\.234,50$/, - en: /^¥1,234\.50$/, + // The grouping is the display locale's; the sign is the tenant's USD, where + // the locale puts it (objectui#11132). It used to be a literal `¥` prefix + // whatever the currency. + currency: 'USD', + de: /^1\.234,50 \$$/, + en: /^\$1,234\.50$/, }, { name: "useOccSave — the conflict dialog's “their save” time", @@ -152,7 +158,7 @@ const SURFACES: Surface[] = [ ]; async function faceUnder(locale: string, surface: Surface): Promise { - render(session(locale, surface.node())); + render(session(locale, surface.node(), surface.currency)); const text = (await surface.read()).replace(/\s+/g, ' ').trim(); cleanup(); return text; @@ -176,7 +182,7 @@ describe('form number and time faces follow the declared session locale (objectu it.each(SURFACES)('$name — every locale-taking call receives the declared tag', async (surface) => { const calls = await recordLocaleArgumentsAsync(async () => { - render(session('de-DE', surface.node())); + render(session('de-DE', surface.node(), surface.currency)); await surface.read(); }); cleanup();