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
32 changes: 32 additions & 0 deletions .changeset/26939-currency-tenant-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@objectstack/formula': minor
'@objectstack/plugin-email': minor
'@objectstack/plugin-auth': minor
'@objectstack/spec': minor
'@objectstack/service-messaging': minor
'@objectstack/service-automation': minor
'@object-ui/core': minor
'@object-ui/react': minor
'@object-ui/plugin-grid': minor
---

feat(formula, plugin-email, messaging, automation, ui)!: a template `currency` hole with no argument formats in the organization's default currency, and with none the render refuses it; nothing formats in US dollars by default any more (#26939)

Clause-②: no (narrowing) — a mail send or render (`sendTemplate`, `renderTemplate`, and the messaging email and inbox channels on them), a flow text slot (notify, http, screen, end) and a templated formula evaluation refuse a `{{ amount | currency }}` hole with no argument when no default currency reaches the render, which they formatted in US dollars before. Authoring is unchanged: the hole is still accepted at save and publish. Nothing is widened.
Metadata-protocol: narrowed
Skills-docs: updated here — `content/docs/automation/email-templates.mdx`, `content/docs/automation/flows.mdx` and `content/docs/data-modeling/formulas.mdx` state the tenant default and the refusal; no skill under `skills/**` taught the dollar default (the formula skill names the formatter grammar only), so no skill line changes.

<!-- adr-0087: registered template-currency-no-argument-tenant-default -->

**BREAKING** semantic change of the `currency` formatter's no-argument form (`@objectstack/formula`), served by every template dialect: mail templates, flow text slots, and action / bulk-action confirm and success copy.

- **Before.** `{{ amount | currency }}` formatted the amount in US dollars (`$1,234.50`) whatever currency the amount was in and whatever the organization's default currency was.
- **Now.** It formats in the organization's default currency, the `localization.currency` setting (Setup → Localization) that a currency field and a dashboard measure already display in: `CN¥1,234.50` for an `en-US` reader and `¥1,234.50` for a `zh-CN` reader when the default is CNY. Every server-side read goes through `resolveLocalizationContext` (`@objectstack/core`); the UI reads `useLocalization().currency`.
- **With no default currency.** The render refuses the hole by name and names the remedy: set the default, or write `currency:CODE` (a mail template's refusal also names `currency:@path`). A mail send or render fails with `TEMPLATE_INVALID` and nothing is sent; a messaging delivery records it as a permanent failure. A flow node with such a hole in a text slot fails with the refusal, which no fault edge routes. An action's confirm or success sentence is shown as written, the existing fallback for a sentence the template engine refuses.
- **How each host hands the default over.** `SendTemplateInput.currency` / `RenderTemplateInput.currency` (`@objectstack/spec/contracts`), filled by the messaging email and inbox channels from the delivery's organization, and by the auth mails (verification, password reset, invitations, magic link, notices) from the organization the mail names or, for identity mail, the deployment-wide setting (`@objectstack/plugin-auth`, once, on the email service it wires); `AutomationContext.currency`, seeded once at run setup from the run's tenant (`AutomationEngine.setTenantCurrencyResolver`, which the automation plugin wires), persisted with a suspended run and inherited by a subflow; `EvalContext.currency` in `@objectstack/formula`, a reserved channel like `locale`, never bound as a root; `ActionRunner.setTenantCurrency` in `@object-ui/core`, installed by the React action providers.
- **Unchanged.** `{{ amount | currency:EUR }}` formats in the code it names, with or without a default. A variable, field or root named `currency` is a value, never the default. Compile, `validateExpression` and the publish gates accept the no-argument form as before. A template with no no-argument currency hole needs no default.
- **Who sees it.** An organization with a default currency other than USD sees its own code where it saw dollars. An organization with no default currency that sends a mail, runs a flow or shows an action sentence with a no-argument `currency` hole now gets the refusal instead of dollars.

**The remedy.** None where the amount is in the organization's default currency: set it in Setup → Localization. Where the amount is in one fixed currency, name it in the hole: `{{ amount | currency:USD }}`. Where a mail template shows each record's amount in that record's own currency, read the code from the send's data: `{{ amount | currency:@currency_code }}`.

**Supersedes, in the same release.** The unreleased notes of the mail `currency:@path` and hole-grammar changes (`26660-currency-code-path-formula`, `26660-currency-code-path-plugin-email`, `26660-mail-hole-grammar-formula`, `26660-mail-hole-grammar`) describe a `currency` hole formatting in US dollars, and the first three say a hole with no argument still does. That was true when they were written; this entry moves it, and in this release a no-argument hole formats in the organization's default currency as described above.
13 changes: 12 additions & 1 deletion content/docs/automation/email-templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ second format — and pass the finished value as its own variable.
argument is either a literal ISO 4217 code in three capital letters
(`currency:EUR`) or, in a mail template only, `@` followed by a path into the
send's data whose value is such a code (`currency:@currency_code`). `currency`
with no argument formats in US dollars.
with no argument formats in the tenant default currency (below).

The marked path is how one template shows each record's amount in that record's
own currency:
Expand Down Expand Up @@ -128,6 +128,17 @@ The `@` form belongs to mail templates. In-app template text (flow
notifications, action and bulk-action copy) takes a literal code only and
refuses `currency:@path` by name.

**`currency` with no argument formats in the tenant default currency.**
`{{ amount | currency }}` formats in the organization's default currency, the
`localization.currency` setting (Setup → Localization) that currency fields and
dashboards read too. The notification channels hand it to every templated
send; code that calls `sendTemplate` or `renderTemplate` itself passes it as
`currency`. When no default currency reaches the render, the render fails with
`TEMPLATE_INVALID`, naming the template, its locale and the hole, and nothing is
sent: the amount is never formatted in US dollars or any other guess. Set the
organization's default currency, or name the currency in the hole: a literal
code (`currency:EUR`) or a marked path to it (`currency:@currency_code`).

**Where a hole may sit in `bodyHtml`.** The renderer's escaping is right for
element text and for a quoted attribute value that holds plain text, so those
are the supported placements for `{{path}}`. A hole anywhere else — inside a
Expand Down
6 changes: 6 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,12 @@ other character is literal.
formatter makes the rest explicit: `upper`, `lower`, `trim`, `number[:digits]`,
`currency[:CODE]`, `percent[:digits]`, `date[:short|medium|long|iso]`,
`datetime[:…]`, `truncate[:n]`, `default:'fallback'`, `json`.
- `currency` with no code formats in the run's organization's default currency
(the `localization.currency` setting, which currency fields and dashboards
read too), read once when the run starts. When the organization has none, the
node fails with a refusal that names the hole, and no fault edge routes it:
nothing is sent with the amount in a guessed currency. Name the code
(`currency:EUR`) where the amount's currency is fixed.
- A hole holds **no logic** — `{{ a + b }}` or `{{ round(x) }}` is refused at
`objectstack validate` and `registerFlow`. Compute the value first, in an
`assignment` node's CEL value envelope, and write the variable as a hole.
Expand Down
4 changes: 4 additions & 0 deletions content/docs/data-modeling/formulas.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ tmpl`Deal {{record.name}} — {{record.amount | currency}} closes {{record.close
Formatters: `currency[:CODE]`, `number[:decimals]`, `percent[:decimals]`,
`date[:short|long|iso]`, `datetime[:…]`, `upper`, `lower`, `trim`, `truncate:N`,
`default:'…'`, `json`. Single-brace `{x}` is **not** a valid template hole.
`currency` with no code formats in the organization's default currency (the
`localization.currency` setting, the one currency fields and dashboards read);
when the organization has none, the render refuses the hole by name instead of
guessing a currency. Write `currency:EUR` where the amount's currency is fixed.

At **input** time you may write a bare string for shorthand — the spec
transforms it into the right envelope based on the field type. The compiled
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/runtime-services/email-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ services.email.sendTemplate(input: SendTemplateInput): Promise<SendEmailResult>
- `TEMPLATE_NOT_FOUND`
- `TEMPLATE_INACTIVE`
- `MISSING_VARIABLES`
- `TEMPLATE_INVALID`: the stored template has a hole the authoring rules refuse (a second formatter, or a `currency` argument that is not a literal ISO 4217 code). It is permanent: retrying fails until the template is re-published without that hole. `renderTemplate` raises it too.
- `TEMPLATE_INVALID`: the stored template has a hole the authoring rules refuse (a second formatter, or a `currency` argument that is not a literal ISO 4217 code). It is permanent: retrying fails until the template is re-published without that hole. `renderTemplate` raises it too. It is also raised when the template has a `currency` hole with no argument (`{{ amount | currency }}`) and the call carries no `currency`: such a hole formats in the organization's default currency (`localization.currency`), which the notification channels pass for you and a direct caller passes as `currency`. Nothing is sent until the default is set or the hole names its code.
3 changes: 3 additions & 0 deletions packages/formula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export {
CURRENCY_ARGUMENT_RULE,
currencyCodePath,
currencyCodeValueRefusal,
// A `currency` hole with no argument formats in the tenant default currency the
// caller supplies; with none, every renderer refuses it with this sentence.
currencyDefaultRefusal,
} from './template-engine';
export type { TemplateHoleDialect, TemplateHoleGrammarRefusal, TemplateHoleGrammarRule } from './template-engine';
// The mail template hole grammar — one copy, read by `@objectstack/plugin-email`'s
Expand Down
128 changes: 128 additions & 0 deletions packages/formula/src/template-currency-tenant-default.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A `currency` hole with no argument formats in the tenant default currency
* (`localization.currency`), handed in on the context's reserved `currency`
* channel — the code a currency field and a dashboard measure read. With no
* tenant default the hole is refused at render, by name: nothing formats in
* USD, or in any currency nobody chose.
*
* Authoring is unchanged: the no-argument form compiles and validates, because
* whether a tenant default exists is a fact of the render, not of the text.
*/
import { describe, expect, it } from 'vitest';

import { currencyDefaultRefusal } from './index';
import { formatValue, templateEngine } from './template-engine';
import { validateExpression } from './validate';

const evaluate = (source: string, record: Record<string, unknown>, extra: { locale?: string; currency?: string } = {}) =>
templateEngine.evaluate<string>({ dialect: 'template', source }, { record, locale: 'en-US', ...extra });

describe('no argument: the tenant default currency', () => {
it.each([
['CNY', 'en-US', 'CN¥1,234.50'],
['CNY', 'zh-CN', '¥1,234.50'],
['EUR', 'en-US', '€1,234.50'],
['JPY', 'en-US', '¥1,235'],
])('formats in %s for a %s reader', (currency, locale, expected) => {
expect(evaluate('{{ record.amount | currency }}', { amount: 1234.5 }, { locale, currency })).toEqual({
ok: true,
value: expected,
});
});

it('a hole that names its code does not read the tenant default', () => {
expect(evaluate('{{ record.amount | currency:EUR }}', { amount: 1234.5 }, { currency: 'CNY' })).toEqual({
ok: true,
value: '€1,234.50',
});
});

it('a root named `currency` is a value, never the tenant default', () => {
const r = templateEngine.evaluate<string>(
{ dialect: 'template', source: '{{ amount | currency }} {{ currency }}' },
{ extra: { amount: 1234.5, currency: 'EUR' }, locale: 'en-US' },
);
expect(r.ok).toBe(false);
});

it('formats an empty value as empty, like every formatter', () => {
expect(evaluate('[{{ record.amount | currency }}]', { amount: null }, { currency: 'CNY' })).toEqual({
ok: true,
value: '[]',
});
});
});

describe('no argument and no tenant default: refused at render, by name', () => {
it.each([
['unset', undefined],
['empty', ''],
['lowercase', 'usd'],
['not a code', 'dollars'],
])('refuses the sentence when the tenant default is %s', (_label, currency) => {
const r = evaluate('Due {{ record.amount | currency }} today', { amount: 1234.5 }, currency === undefined ? {} : { currency });
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.kind).toBe('runtime');
expect(r.error.message).toContain('{{ record.amount | currency }}');
expect(r.error.message).toContain('localization.currency');
expect(r.error.message).toContain('currency:EUR');
expect(r.error.message).not.toContain('$');
});

it('refuses whatever the value: the refusal does not depend on the data', () => {
const r = evaluate('{{ record.amount | currency }}', { amount: null });
expect(r.ok).toBe(false);
});

it('refuses the whole sentence, even when another hole names its code', () => {
const r = evaluate('{{ record.a | currency:EUR }} / {{ record.b | currency }}', { a: 1, b: 2 });
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error.message).toContain('{{ record.b | currency }}');
});

it('a sentence with no no-argument currency hole does not need a tenant default', () => {
expect(evaluate('{{ record.amount | currency:EUR }} {{ record.amount | number:1 }}', { amount: 1234.5 })).toEqual({
ok: true,
value: '€1,234.50 1,234.5',
});
});
});

describe('authoring is unchanged', () => {
it('the no-argument form compiles and validates: a tenant default is a fact of the render', () => {
expect(templateEngine.compile('Due {{ record.amount | currency }}').ok).toBe(true);
const v = validateExpression('template', { dialect: 'template', source: 'Due {{ record.amount | currency }}' });
expect(v.ok).toBe(true);
});
});

describe('formatValue (the mail renderer reads it)', () => {
it('formats no argument in the tenant default it is handed', () => {
expect(formatValue('currency', 1234.5, undefined, { locale: 'zh-CN', currency: 'CNY' })).toBe('¥1,234.50');
});

it('throws without one, whatever the value, and never formats in USD', () => {
expect(() => formatValue('currency', 1234.5, undefined, { locale: 'en-US' })).toThrow(RangeError);
expect(() => formatValue('currency', 1234.5, undefined, { locale: 'en-US' })).toThrow(/localization\.currency/);
expect(() => formatValue('currency', null, undefined)).toThrow(RangeError);
});
});

describe('currencyDefaultRefusal', () => {
it('is null for a tenant default in three capitals, and the sentence otherwise', () => {
expect(currencyDefaultRefusal('{{ x | currency }}', 'CNY')).toBeNull();
const refusal = currencyDefaultRefusal('{{ x | currency }}', undefined);
expect(refusal).toContain('{{ x | currency }}');
expect(refusal).toContain('Setup → Localization');
expect(refusal).not.toContain('@');
});

it('names the marked-path form in the mail dialect only', () => {
expect(currencyDefaultRefusal('{{ x | currency }}', 'EUR', 'mail')).toBeNull();
expect(currencyDefaultRefusal('{{ x | currency }}', undefined, 'mail')).toContain('`currency:@currency_code`');
});
});
Loading
Loading