Skip to content

fix(plugin-form): the master-detail form's Subtotal / Tax / Total show the amount field's currency, not a hard-coded ¥ (objectui#11132) - #11142

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11132-totals-currency
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11132-totals-currency

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11132

Clause-②: no
Why no (carried from the claim, comment 5897361654): a display sign follows the field's resolved currency. No schema, prop, type or accepted set moves.

Dispatched implementation, domain:ui seat 2, session https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec. Draft until the seat lands it. Direction: triage comment 5896646901.

What changed

MasterDetailForm's document totals stack (Subtotal / Tax / Total, test ids md-subtotal, md-tax, md-grand-total) built every line as a literal ¥ followed by the display-locale digits at two places, whatever the amount field's or the tenant's currency.

  • Currency. The stack now resolves its currency through resolveFieldCurrency (@object-ui/i18n) on the entry's amount field definition, with the tenant default (useLocalization().currency, ADR-0053) as the resolver's last step. No constant, no second resolver.
  • Where the field definition comes from. The child object's own field, read from the schema the resolve effect already fetches to derive or hydrate the entry's columns. It is kept on the internal DetailEntry record as amountFieldDef; the authored MasterDetailDetailConfig is untouched.
  • Format. formatDisplayNumber(n, { locale, currency }): Intl's currency style in the display locale. The sign sits where the locale puts it, and the width is the currency's ISO 4217 minor unit, which is Intl's default for style: 'currency'.
  • No currency known. A plain number at the historical two places (also CurrencyField's no-currency width), no sign.
  • Several entries. One sign only when every entry with an amount field resolves to the same code. When they differ, plain numbers: the resolver's own answer for an amount whose currency is not known. See open question 1 in the report.
  • A code Intl refuses. The code beside the plain number, digits still in the display locale, instead of a thrown RangeError taking the form down.

Measured: which formatter the currency faces use

Two formatters exist in the family, both reading resolveFieldCurrency:

  1. formatCurrency (@object-ui/fields entry): CurrencyCellRenderer, the grid summary footer (useColumnSummary) and the metric tile. A whole amount drops its fraction ($1,234).
  2. formatDisplayNumber with currency and the currency's minor unit on both bounds: CurrencyField (formatAmount) and GridField's currency cells (currencyText). Those grid cells are the line items directly above this stack.

The stack takes the second. It agrees with the line cells above it by construction and keeps the stack's own always-show-the-fraction form. With the first, a whole subtotal would read $1,000 under line cells reading $1,000.00.

currencyFractionDigits is not on the @object-ui/fields package entry (its index.tsx re-exports only resolveFieldCurrency and formatCurrency from currency.ts), so the stack does not pass a width. Intl's default for style: 'currency' is the same minor unit that helper reads: resolvedOptions() for JPY answers 0 / 0, and 1234.5 formats as $1,234.50 (en, USD), 1.234,50 $ (de-DE, USD), ¥1,235 (en, JPY), KWD 1,234.500 (en, KWD).

What a reader sees change

Tenant / field Locale Before After
USD tenant en ¥1,234.50 $1,234.50
USD tenant de-DE ¥1.234,50 1.234,50 $
JPY tenant en ¥1,234.50 ¥1,235 (width 2 becomes 0)
fixed EUR field, USD tenant en ¥1,234.50 €1,234.50
no currency anywhere en ¥1,234.50 1,234.50

A CNY tenant reads CN¥1,234.50 in an English session and ¥1,234.50 in zh-CN: that is Intl's symbol, as on every other currency face.

Pins

  • formFaces.displayLocale-9909.test.tsx: the master-detail surface now declares a USD tenant and asserts the de/en grouping plus the resolved sign where the locale puts it (1.234,50 $, $1,234.50). The de/en difference and the no-machine-locale tripwire still run on it.
  • New MasterDetailForm.totalsCurrency-11132.test.tsx (10 cases, one line of 1234.5 under a 10% tax rate, all three lines asserted together):
    • USD tenant: $1,234.50 / $123.45 / $1,357.95, once per resolve branch (fully configured, hydrated identity-only columns, derived);
    • fixed EUR field under a USD tenant: € on all three lines (hydrated and derived branches);
    • currencyMode: 'dynamic' with a defaultCurrency of EUR: the tenant's $, so the stack reads the field through the resolver's currencyMode rule (objectui#10422);
    • JPY tenant: ¥1,235 / ¥123 / ¥1,358, the width change pinned;
    • de-DE: 1.234,50 $ / 123,45 $ / 1.357,95 $;
    • no currency: 1,234.50 / 123.45 / 1,357.95;
    • two entries resolving EUR and USD: plain numbers.

Reverse verification (one-shot, not kept). With the tests committed, MasterDetailForm.tsx was replaced by its blob at the branch point db11afd49 (on-disk hash equal to that blob; the ¥ literal count went 0 to 1, formatTotalsAmount 3 to 0), and both pin files were run: 12 failed, 10 passed of 22. The 12 are all 10 new cases plus the two rewritten 9909 master-detail rows, each failing on the stack text (for example ¥1,234.50 received where $1,234.50 was expected). Restored with git checkout HEAD, proven by the on-disk hash equal to the HEAD blob and an empty git diff HEAD.

Acceptance notes

  1. Boundary: a fully configured entry reads the tenant's currency. An entry with a relationship field and every column typed skips the child schema fetch, so no field definition is in hand and the stack resolves to the tenant default, even when the amount field fixes another currency. Every measured producer takes a fetching branch. Open question 2 in the report.
  2. Reach correction on the card. The card names objectstack's examples/app-showcase invoice as the producer that switches the stack on. A render probe (not committed) of MasterDetailForm with the subform the console builds from that relationship (relationshipField: 'invoice', identity-only inlineColumns, no inlineAmountField) and a tax_rate header rendered no md-totals: the hydrate branch of the resolve effect never derives an amountField. With amountField: 'amount' added, the stack renders. So the stack is reached where an entry carries an amount field (the spec's inlineAmountField, or derived when no inlineColumns are declared) under a tax_rate header. No example combines those two today: only the showcase invoice has tax_rate, and the CRM opportunity's line items declare inlineAmountField under a parent without one.
  3. Seen, not fixed here (reported to the seat). That same hydrate branch skips the sortField derivation too. In the probe, a line created through the showcase-shaped subform carried no position, while the derived-branch control stamped position: 0.
  4. Line cells and stack can differ for a fixed-currency field. GridField resolves a currency column against the tenant default only (its columnCurrency hands the resolver no field, by design). So under a fixed-EUR amount field in a USD tenant, the line cells read $ while the stack now reads €, as the ruling asks. Before this change they read $ and ¥. Owner: none.
  5. Docs. No page or README describes the stack's formatting: taxRateField, Subtotal and tax_rate have no hits in packages/plugin-form/README.md or in content/docs outside an unrelated formula example. Nothing to update.

Gates (run at 86935655d, the head of this PR)

  • pnpm exec vitest run --maxWorkers=2 packages/plugin-form/: exit 0, Test Files 146 passed (146), Tests 1738 passed | 1 skipped (1739).
  • pnpm --filter @object-ui/plugin-form type-check: exit 0, after building the dependency closure (pnpm --filter '@object-ui/plugin-form^...' run build, exit 0). tsc -p tsconfig.test.json --listFiles lists both test files and MasterDetailForm.tsx.
  • pnpm exec eslint on the three changed code files (one source, two tests): exit 0, --format json counts 3 files, 0 errors, 23 warnings. The 23 are all no-explicit-any in MasterDetailForm.tsx, and the branch-point blob of that file gives the same 23 (via --stdin). No line this PR adds contains any. eslint.config.js sets no parserOptions.project or projectService: type-aware linting is off, so this diff cannot move a verdict on any untouched file. The repo-wide pnpm lint is CI's.
  • node scripts/check-changeset-presence.mjs: exit 0 (1 changeset, 1 released package).
  • node scripts/check-control-bytes.mjs: exit 0.
  • node scripts/check-new-cross-file-line-citations.mjs: exit 0, 0 new citation(s).
  • node scripts/check-changeset-claims.mjs, node scripts/check-pending-changeset-literals.mjs, node scripts/check-test-path-roots.mjs: exit 0.
  • node scripts/check-governed-queue-guard.mjs --test over the four changed paths: NOT GOVERNED.

Generated by Claude Code

…t's resolved currency

The Subtotal / Tax / Total stack prefixed a literal yen sign whatever the
amount field's or the tenant's currency. It now resolves the currency through
resolveFieldCurrency on each entry's amount field (the child field definition
the resolve effect already loads), falling back to the tenant default, and
formats with Intl's currency style in the display locale: the sign sits where
the locale puts it and the width is the currency's minor unit. With no
currency known, or entries that disagree, the lines are plain numbers.

Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec
Co-authored-by: Claude <noreply@anthropic.com>
…the 9909 pin off the yen sign

The objectui#9909 display-locale pin now declares a USD tenant and asserts the
de/en grouping plus the resolved sign where the locale puts it. A new pin
covers a USD tenant on all three lines for every resolve branch, a fixed
field currency winning over the tenant's, a dynamic config deferring to the
tenant, the JPY width (whole yen), the de-DE sign placement, no currency at
all, and two entries in different currencies.

Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3113.2 KB 3149.4 KB
Main entry chunk (gzip) 149.5 KB 350 KB
Entry file index-Dl0TFCGE.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.88KB 6.25KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 559.68KB 134.21KB
core (index.js) 9.94KB 3.94KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 227.99KB 63.22KB
fields (index.js) 261.11KB 66.33KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.35KB 9.18KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.32KB 11.09KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.58KB 4.90KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.25KB 2.17KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 52.17KB 15.06KB
plugin-charts (index.js) 84.09KB 22.93KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 138.73KB 37.05KB
plugin-designer (index.js) 215.78KB 44.42KB
plugin-detail (index.js) 234.55KB 62.25KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 171.04KB 43.71KB
plugin-gantt (index.js) 171.88KB 42.61KB
plugin-grid (index.js) 229.83KB 63.15KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 115.82KB 28.67KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 31.93KB 9.33KB
plugin-tree (index.js) 11.21KB 3.89KB
plugin-view (index.js) 90.43KB 22.76KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.55KB 39.23KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.17KB 2.73KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 22.61KB 7.40KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 3.83KB 1.49KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 3.19KB 1.62KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.26KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.30KB 6.99KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 86935655d305578690e68a81ebd843db11807075
Local-runs: none

Inputs: card #11132 (body; triage 5896646901; claim 5897361654; os-dev-report 5898326992), PR #11142 (body, the 4-file list, the net diff against main), and the latest check-runs on the head. Context reads at the head, none run: packages/i18n/src/currency.ts, packages/i18n/src/index.ts, packages/core/src/utils/number-display.ts, packages/fields/src/currency.ts, packages/fields/src/index.tsx, packages/fields/src/widgets/GridField.tsx, packages/fields/src/widgets/CurrencyField.tsx, packages/plugin-form/src/deriveMasterDetail.ts, packages/app-shell/src/providers/MetadataProvider.tsx, AGENTS.md; at objectstack main 5757463: examples/app-showcase/src/data/objects/invoice.object.ts, packages/spec/src/data/field.zod.ts; the sibling rulings objectui#10319, #10348, #10422 and PR #10418's changeset.

Gate verdicts on the head (filter=latest, read complete at 2026-09-29T20:47Z after a 10-minute foreground wait): 43 runs, 40 success, 3 skipped by design (Test (coverage), Test (coverage shard), dependabot), 0 failure, 0 in progress. Among the successes: Test and Test (shard 1/8) to (shard 8/8), Test (dist pins), Type Check, Lint, Build & E2E, Build Docs, Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Claim Re-read, Changeset Overwrite Report, Line Citation Gate, Control Byte Scan, Governed Surface Queue Guard, Spec Main Shape Gate, Inert vi.mock Specifier Check.

① Derived judgments

Every accept-set and public-surface change the diff implies, each named:

  1. No public surface moves — right. MasterDetailDetailConfig and MasterDetailFormSchema are untouched. amountFieldDef lands on DetailEntry, a non-exported interface (MasterDetailForm.tsx:352); CurrencyFieldDef, childFieldDef, totalsCurrency, PLAIN_AMOUNT_DIGITS and formatTotalsAmount are module-private. The three new imports (formatDisplayNumber, resolveFieldCurrency, useLocalization) come from @object-ui/i18n, which the file already imported from (pickLocalized, useDisplayLocale, useObjectTranslation), and all three sit on that package's entry (packages/i18n/src/index.ts:156-164). No new dependency edge, no export moves, no schema key.

  2. The one resolver — right. totalsCurrency maps every entry that switches the stack on (config.amountField set, the same predicate showTaxStack uses) through resolveFieldCurrency(e.amountFieldDef, tenantCurrency) and answers one code only when the set has one member. That is the resolver the triage names (objectui#10422, packages/i18n/src/currency.ts:46) with its precedence intact: the dynamic-mode pin (currencyMode: 'dynamic' with defaultCurrency: 'EUR' under a USD tenant reads $) proves the stack reads the field through the resolver, not a hand-read key. No constant remains: the yen literal is gone from the source, and the only yen sign in the diff is the JPY expected output of a pin. No second resolver: formatTotalsAmount resolves nothing, it formats a code the resolver returned.

  3. Where the field definition comes from — right. childFieldDef(childSchema, name) reads the child object's own field off the schema the resolve effect already fetched, on both fetching branches: hydrate (relationshipField && columns, reading d.amountField) and derive (d.amountField ?? derived.amountField, the fallback the config already used). The amountField derivation itself is unchanged (deriveDetail → pickAmountField). The fully configured early return (relationshipField && columnsTyped) and the !needsDerive short-circuit hand the resolver no definition, so those entries resolve to the tenant; that boundary is judged under ③ c.

  4. Tenant default — right. useLocalization().currency (LocalizationValue.currency, ADR-0053) read in MasterDetailLines, the same context every currency face reads (CurrencyCellRenderer, CurrencyField, GridField.computeRow). With no provider the value is undefined and the stack shows plain numbers, never a guessed sign.

  5. Formatter choice, formatDisplayNumber(n, { locale, currency }) over the family's formatCurrency — right, and inside the family rulings.

    • Decimals from the currency (ruling 乙, via objectui#10319 / fix(plugin-grid,plugin-dashboard,app-shell): a currency amount's decimals come from its currency, never from scale (#10221) #10348): Intl's style: 'currency' default width is the ISO 4217 minor unit on both bounds, which is the very probe currencyFractionDigits makes (packages/fields/src/currency.ts:66, resolvedOptions().maximumFractionDigits of the same construction), so the width equals what GridField.currencyText passes explicitly. No scale, precision or literal 2 is read on the currency path. JPY going from 2 places to 0 is the ruling applied; it is pinned (¥1,235) and declared in the changeset.
    • Two shapes exist in the family, both by ruling: formatCurrency (cell renderer, summary footer, metric tile) drops a whole amount's fraction; formatDisplayNumber at the minor unit (CurrencyField.formatAmount, GridField.currencyText) always shows it. objectui#10348 recorded that split as deliberate. The stack sits directly under LineItemsField, which IS GridField (GridField.tsx:1433, export const LineItemsField = GridField), so taking the cells' shape keeps $1,000.00 cells over a $1,000.00 subtotal rather than over $1,000. That is the right neighbour to agree with.
    • Not a second resolver, and not an export move: currencyFractionDigits is not on the @object-ui/fields entry (index.tsx:500-501 imports it and exports only resolveFieldCurrency and formatCurrency), so the stack passes no width and lets Intl supply the same one; importing the helper would have moved an export set and flipped Clause-②. The wrapper's shape (try formatDisplayNumber with the code, catch → the code beside a plain number) is the shape CurrencyField.formatAmount and GridField.currencyText already have, and formatDisplayNumber documents that it throws for a bad code (number-display.ts:221), so the catch is both reachable and needed.
    • No-currency fallback: formatDisplayNumber(n, { locale, min 2, max 2 }), grouping kept (no scale), so the digits are the former toLocaleString(displayLocale, 2/2) output minus the sign: the historical width, and CurrencyField's no-currency width. Right.
    • The one divergence found: the bad-code fallback formats its plain number in the display locale (XXX 1,234.50) where GridField uses toFixed (XXX 1234.50). objectui#10348 already records both shapes as pre-existing in the family; reachable only with a code Intl refuses. Not a finding.
  6. The 9909 pin — right. formFaces.displayLocale-9909.test.tsx now declares a USD tenant on the master-detail surface and asserts the grouping plus the resolved sign where the locale puts it (1.234,50 $ de, $1,234.50 en); the de/en difference and the locale-argument tripwire still run over that surface. The pin carries no yen sign.

  7. The 11132 pins cover the triage's three and more — right. USD on all three lines, once per resolve branch (fully configured, hydrated, derived); fixed EUR under a USD tenant on the hydrated and derived branches; dynamic mode; JPY width; de-DE placement; no currency; two entries resolving to different codes. Every case reads all three test ids together. The dev's reverse verification (12 of 22 red on the base blob) is reported, not re-run here.

  8. One pre-existing quirk, not introduced here, on the record: the subtotal reducer sums e.config.amountField || 'amount' for EVERY entry, while totalsCurrency (rightly) consults only entries with an amountField. An entry with no amountField but an amount column adds to the subtotal without a vote on its sign. That is the objectui#6371 reducer's shape, outside this card; no producer measured, no carrier asked.

② Semver level

Clause-②: no — confirmed. No schema, prop, type, export or accepted set moves (① item 1); what changes is what the stack prints.

Changeset .changeset/11132-master-detail-totals-currency.md: '@object-ui/plugin-form': patch, one changeset, and packages/plugin-form is the only released package the diff touches. Right:

  • the closest sibling, PR objectui#10418 (the grid cell and footer's literal yen fallback replaced by the resolver, objectui#10354), shipped as patch;
  • objectui#10319 / fix(plugin-grid,plugin-dashboard,app-shell): a currency amount's decimals come from its currency, never from scale (#10221) #10348 went minor because each reversed a rule the published CHANGELOG stated, or withdrew a designer control; no page, README or CHANGELOG entry states this stack's sign or width (PR Acceptance note 5), so nothing published is reversed;
  • the visible width change for JPY tenants is the family ruling applied and is spelled out in the changeset body; nothing here is breaking in the sense AGENTS.md's version rule reserves minor for;
  • Changeset Declaration, Changeset Bump Policy, Changeset Fixed Group Check, Changeset Claim Re-read and Changeset Overwrite Report are all success on the head.

③ Boundary flags

Every dev flag and both open_questions, answered or escalated:

a. Reach correction (PR Acceptance note 2) — confirmed at the head; the fix stands. Read at objectstack main 5757463: showcase_invoice_line.invoice declares inlineEdit: 'grid', seven identity-only inlineColumns ({ name }), and no inlineAmountField. attachInlineSubforms (MetadataProvider.tsx:526-527) carries inlineColumns to columns and inlineAmountField to amountField, nothing else, so the console's subform for the showcase invoice takes the hydrate branch, which sets no amountField; showTaxStack is then false and no md-totals renders. The card's reach: line ("a named producer") was read from source, as the card says, and is wrong. The stack IS reachable through two declared paths: a master_detail field with inlineAmountField (spec field.zod.ts:1462) under a parent carrying tax_rate, or an authored object-master-detail-form entry with a bare childObject (the derive branch picks the amount field). No example combines either with a tax_rate header today. ESCALATED to the seat: the p2 grade cites the showcase invoice as the real producer; the card should carry this correction (a seat comment) so the grade rests on the declared path, not on the example. Not a reason to refuse the fix.

b. Open question 1 (entries resolving different currencies show plain numbers, no sign) — accepted within the triage direction; no ruling needed. "The field's currency, else the tenant's, never a constant": with two fields naming two currencies there is no single field currency, and a plain number is resolveFieldCurrency's own documented answer for an amount whose currency is not known ("never a guessed sign"). Option C would print a false sign for part of the sum, which the family forbids; option B is a new UI state on a path no producer takes (each example parent has at most one inline child). A adds no rule of its own and is pinned. If a producer with two amount-bearing children appears, the hide-or-diagnose question is that producer's card.

c. Open question 2 (a fully configured entry shows the tenant sign even when its amount field fixes a currency) — a pre-existing branch gap shared with GridField's line cells; needs its own carrier; not a breach of this card. The triage's fixed-currency pin holds on both branches where the form holds the field definition (hydrated and derived, pinned). On the third branch the resolver is handed no field, exactly as GridField.columnCurrency hands it none on every branch (GridField.tsx:351-352, by design since objectui#10355), so cells and stack agree there. One correction to the dev's framing, which the carrier needs: the form DOES fetch every child's schema for every entry, fully configured ones included, in the sibling childSchemasRef effect (MasterDetailForm.tsx:1085-1104, for submit-time sanitisation); it is held in a ref, not state, so the header ObjectForm is not re-rendered (#1581). Option B therefore costs no extra getObjectSchema; it costs promoting one field definition into resolvedEntries, state that already re-renders once. ESCALATED to the seat: one card covering field currency on the fully configured branch for BOTH the line cells (columnCurrency) and the stack, so the two never disagree; the changeset already declares the gap.

d. Out-of-scope finding (the hydrate branch derives neither sortField nor amountField) — real at the head by code reading; a separate defect class; a carrier is needed. MasterDetailForm.tsx:997-1004: the relationshipField && columns branch returns hydrateColumns(...) only; the derive branch at :1005-1024 sets both amountField and sortField (deriveDetail; SORT_FIELD_NAMES includes position). The spec declares no inline sort-field key (field.zod.ts:1450-1462: inlineEdit, inlineTitle, inlineColumns, inlineAmountField), and attachInlineSubforms carries none, so a child that declares inlineColumns can only get its sort field by derivation, which that branch skips. The showcase invoice line is exactly that child (identity-only inlineColumns plus position, whose own comment calls it the grid's drag-reorder sort field), so sort_field reaches LineItemsField undefined and a drag-reorder is not persisted; with no inlineAmountField the running total (total_field) is absent as well. Not this card's class (a derivation gap, not a currency sign) and rightly not fixed here. ESCALATED to the seat to file, with the dev's dedupe words (class a, reach: named producer).

e. Second note (GridField currency cells read the tenant currency only) — pre-existing by design (objectui#10355); folds into c's carrier. After this PR a fixed-EUR amount field under a USD tenant reads $ in the cells over € in the stack; before, $ over a yen sign. The stack is now the face that follows the ruling. objectui#10348 already reported the configured-column bag gap as a finding. The dev names no owner; c's carrier is the right home.

f. Deviations 1 to 5 in the report — accepted. (1) currencyFractionDigits / currencySymbol not on the fields entry: confirmed, and the width comes out the same (① item 5). (2) is c. (3) a lock timeout with nothing reported from it. (4) model-free commit trailers as AGENTS.md prescribes, which takes precedence. (5) the PR body's Why no line and the forced draft: cosmetic.

g. The gates the dev reports (plugin-form vitest 146 files, 1738 passed; type-check; narrowed eslint; the seven check scripts) agree with the head's check-runs, which are the verdicts here.

Implemented-by: claude/issue-11132-totals-currency
Reviewed-by: session_011p7ikEivgXefNDaE5S5Uec

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 20:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 1923d35 Sep 29, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11132-totals-currency branch September 29, 2026 21:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-form): the master-detail form's Subtotal / Tax / Total stack prints a hard-coded ¥ whatever the amount field's or the tenant's currency

2 participants