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
29 changes: 29 additions & 0 deletions .changeset/11132-master-detail-totals-currency.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
return {
name,
fields: {
po: { type: 'master_detail', label: 'PO', reference: PARENT },
line_total: { type: 'currency', label: 'Line Total', ...lineTotal },
},
};
}

function dataSource(children: Record<string, ReturnType<typeof lineSchema>>) {
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<string, ReturnType<typeof lineSchema>>;
tenantCurrency?: string;
locale?: string;
}): Promise<Stack> {
const view = render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<LocalizationProvider value={{ locale: opts.locale ?? 'en', currency: opts.tenantCurrency }}>
<MasterDetailForm
schema={{ objectName: PARENT, mode: 'create', fields: ['ref', 'tax_rate'], details: opts.details } as never}
dataSource={dataSource(opts.children)}
/>
</LocalizationProvider>
</I18nProvider>,
);
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' });
});
});
109 changes: 105 additions & 4 deletions packages/plugin-form/src/MasterDetailForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof resolveFieldCurrency>[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<string, unknown> } | 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()}`;
}
}

/**
Expand Down Expand Up @@ -561,6 +649,9 @@ const MasterDetailLines: React.FC<MasterDetailLinesProps> = ({
// 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();
Expand Down Expand Up @@ -614,7 +705,10 @@ const MasterDetailLines: React.FC<MasterDetailLinesProps> = ({
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 (
<>
Expand Down Expand Up @@ -901,13 +995,19 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
// 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',
Expand All @@ -917,9 +1017,10 @@ export const MasterDetailForm: React.FC<MasterDetailFormProps> = ({
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
Expand Down
Loading
Loading