diff --git a/.changeset/26939-currency-tenant-default.md b/.changeset/26939-currency-tenant-default.md new file mode 100644 index 00000000000..08a28e00708 --- /dev/null +++ b/.changeset/26939-currency-tenant-default.md @@ -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. + + + +**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. diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index 99836d4d5a0..c25422d8ec1 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -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: @@ -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 diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index a5132ac1c1f..83713fecee3 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -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. diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index 2c1469779bf..1223899faf3 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -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 diff --git a/content/docs/kernel/runtime-services/email-service.mdx b/content/docs/kernel/runtime-services/email-service.mdx index 368e8358276..c03244d4dc3 100644 --- a/content/docs/kernel/runtime-services/email-service.mdx +++ b/content/docs/kernel/runtime-services/email-service.mdx @@ -31,4 +31,4 @@ services.email.sendTemplate(input: SendTemplateInput): Promise - `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. diff --git a/packages/formula/src/index.ts b/packages/formula/src/index.ts index 9a4645d8efa..b0982d5d1fb 100644 --- a/packages/formula/src/index.ts +++ b/packages/formula/src/index.ts @@ -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 diff --git a/packages/formula/src/template-currency-tenant-default.test.ts b/packages/formula/src/template-currency-tenant-default.test.ts new file mode 100644 index 00000000000..238faf4d050 --- /dev/null +++ b/packages/formula/src/template-currency-tenant-default.test.ts @@ -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, extra: { locale?: string; currency?: string } = {}) => + templateEngine.evaluate({ 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( + { 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`'); + }); +}); diff --git a/packages/formula/src/template-engine.ts b/packages/formula/src/template-engine.ts index c38c74eded6..8f1cc1e0b6a 100644 --- a/packages/formula/src/template-engine.ts +++ b/packages/formula/src/template-engine.ts @@ -37,6 +37,7 @@ type Formatter = ( arg: string | undefined, locale: string, timeZone?: string, + currency?: string, ) => string; function asNumber(v: unknown): number | undefined { @@ -68,20 +69,25 @@ const FORMATTERS: Record = { return new Intl.NumberFormat(locale, digits !== undefined && !Number.isNaN(digits) ? { minimumFractionDigits: digits, maximumFractionDigits: digits } : {}).format(n); }, - // currency | currency:EUR → no argument formats in USD; an argument is a - // literal ISO 4217 code. An argument that is not one (a path such as + // currency | currency:EUR → no argument formats in the tenant default + // currency (`localization.currency`, handed in by the caller); an argument + // is a literal ISO 4217 code. An argument that is not one (a path such as // `currency_code`, a lowercase or empty code) is refused when the template // is compiled or published (`judgeHoleFormatter`), and refused here too: it // throws rather than format the amount in a currency nobody wrote. The mail // dialect's `currency:@path` form is resolved by the mail renderer, which // hands this formatter the code it read; this formatter never reads a path. - currency: (v, arg, locale) => { + // With no argument and no tenant default it throws as well, whatever the + // value: there is no currency to format in, and none is guessed. + currency: (v, arg, locale, _timeZone, currency) => { if (arg !== undefined && !isCurrencyCode(arg)) { throw new RangeError(currencyArgumentRefusal(`currency:${arg}`, arg)); } + const refusal = arg === undefined ? currencyDefaultRefusal('`currency`', currency) : null; + if (refusal) throw new RangeError(refusal); const n = asNumber(v); if (n === undefined) return baseString(v); - const code = arg !== undefined ? arg.trim() : 'USD'; + const code = (arg ?? currency!).trim(); return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(n); }, // percent | percent:1 → 0.42 → "42%" (value is a 0..1 ratio) @@ -201,6 +207,36 @@ function isCurrencyCode(arg: string): boolean { /** The dialect a hole is judged in: the in-app template engine's (the default) or a mail template's. */ export type TemplateHoleDialect = 'in-app' | 'mail'; +/** + * Why a `currency` hole with no argument cannot be formatted, or `null` when + * it can: such a hole formats in the tenant default currency + * (`localization.currency`), so it is formatted only when the caller supplied + * one that is an ISO 4217 code in three capitals. Every renderer that refuses + * such a hole (the in-app engine, the mail renderer) shows this sentence; the + * mail dialect's remedy also names the `currency:@path` form. + * + * @param hole the hole as written, braces included, or the formatter's name when no hole text is at hand + * @param currency the tenant default currency the caller supplied, if any + * @param dialect `'mail'` names the `currency:@path` form in the remedy; the in-app dialect (the default) does not + */ +export function currencyDefaultRefusal( + hole: string, + currency: unknown, + dialect: TemplateHoleDialect = 'in-app', +): string | null { + if (typeof currency === 'string' && isCurrencyCode(currency)) return null; + const remedy = dialect === 'mail' + ? 'name the currency in the hole: a literal ISO 4217 code in three capital letters (`currency:EUR`), or ' + + '`@` followed by a path into the send\'s data whose value is such a code (`currency:@currency_code`).' + : 'name the currency in the hole with a literal ISO 4217 code in three capital letters (`currency:EUR`).'; + return ( + `the hole ${hole} has no currency argument, so it formats in the tenant default currency (the ` + + '`localization.currency` setting), and no tenant default currency reached this render: the ' + + 'organization has none set, or the caller did not supply it. The amount is not formatted in any ' + + `currency. Set the default currency in Setup → Localization, or ${remedy}` + ); +} + function currencyArgumentRefusal(hole: string, arg: string, dialect: TemplateHoleDialect = 'in-app'): string { if (dialect === 'mail') { return ( @@ -356,16 +392,20 @@ export function judgeHoleFormatter( * Exported so renderers that don't run the full CEL template engine — notably * the email pipeline (ADR-0053 Phase 2 slice 4) — format dates, money, etc. * identically to in-app templates, including reference-timezone `datetime`. + * + * `opts.currency` is the tenant default currency a `currency` with no + * argument formats in. Without one, that call throws a `RangeError` carrying + * {@link currencyDefaultRefusal}'s sentence: it never picks a currency. */ export function formatValue( name: string, value: unknown, arg: string | undefined, - opts: { locale?: string; timeZone?: string } = {}, + opts: { locale?: string; timeZone?: string; currency?: string } = {}, ): string | undefined { const fmt = FORMATTERS[name]; if (!fmt) return undefined; - return fmt(value, arg, opts.locale ?? 'en-US', opts.timeZone); + return fmt(value, arg, opts.locale ?? 'en-US', opts.timeZone, opts.currency); } function baseString(value: unknown): string { @@ -442,6 +482,15 @@ function parseHole(inner: string): ParsedHole | null { return { path, filter: { name, arg }, ...(refusal ? { refusal } : {}) }; } +/** The first hole of `source` that is `currency` with no argument, as written, braces included. */ +function firstTenantCurrencyHole(source: string): string | undefined { + for (const m of source.matchAll(HOLE_RE)) { + const parsed = parseHole(m[1]); + if (parsed?.filter?.name === 'currency' && parsed.filter.arg === undefined) return m[0]; + } + return undefined; +} + function compileTemplate(source: string): EvalResult { const open = (source.match(/\{\{/g) ?? []).length; const close = (source.match(/\}\}/g) ?? []).length; @@ -493,6 +542,16 @@ export const templateEngine: DialectEngine = { const check = compileTemplate(expr.source); if (!check.ok) return check as EvalResult; + // Tenant default currency for a `currency` hole with no argument: the + // context's reserved `currency` channel only, never a root. Without one + // such a hole refuses the whole sentence, before anything is rendered. + const currency = typeof ctx.currency === 'string' ? ctx.currency : undefined; + const currencyHole = firstTenantCurrencyHole(expr.source); + const currencyRefusal = currencyHole === undefined ? null : currencyDefaultRefusal(currencyHole, currency); + if (currencyRefusal) { + return { ok: false, error: { kind: 'runtime', message: `invalid template hole: ${currencyRefusal}` } }; + } + const scope = buildScope(ctx); // Formatter locale: the context's reserved `locale` channel only, never a // root — a root named `locale` is a value like any other (#26232). @@ -506,7 +565,7 @@ export const templateEngine: DialectEngine = { if (!parsed) return _match; // compile already validated; defensive const value = resolvePath(scope, parsed.path); if (parsed.filter) { - return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale, timeZone); + return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale, timeZone, currency); } return baseString(value); }); diff --git a/packages/formula/src/template-formatters.test.ts b/packages/formula/src/template-formatters.test.ts index bb4c744416a..1738f39db2b 100644 --- a/packages/formula/src/template-formatters.test.ts +++ b/packages/formula/src/template-formatters.test.ts @@ -15,8 +15,12 @@ describe('template formatters (ADR-0032 §3)', () => { expect(render('Hi {{ record.name }}', { name: 'Jane' })).toBe('Hi Jane'); }); - it('currency (default USD + explicit code)', () => { - expect(render('{{ record.amt | currency }}', { amt: 1234.5 })).toBe('$1,234.50'); + it('currency (the tenant default the context carries + explicit code)', () => { + const r = templateEngine.evaluate( + { dialect: 'template', source: '{{ record.amt | currency }}' }, + { record: { amt: 1234.5 }, locale: 'en-US', currency: 'USD' }, + ); + expect(r).toEqual({ ok: true, value: '$1,234.50' }); expect(render('{{ record.amt | currency:EUR }}', { amt: 1000 })).toContain('1,000'); }); diff --git a/packages/formula/src/template-hole-grammar.test.ts b/packages/formula/src/template-hole-grammar.test.ts index e338959ad87..695655f7aab 100644 --- a/packages/formula/src/template-hole-grammar.test.ts +++ b/packages/formula/src/template-hole-grammar.test.ts @@ -108,11 +108,17 @@ describe('the in-app template dialect', () => { expect(r.ok).toBe(false); }); - it('a literal code still formats in that currency, and no argument still formats in USD', () => { + it('a literal code still formats in that currency, and no argument formats in the tenant default', () => { const eur = evaluate('{{ record.amount | currency:EUR }}', { amount: 1234.5 }); expect(eur).toEqual({ ok: true, value: '€1,234.50' }); - const usd = evaluate('{{ record.amount | currency }}', { amount: 1234.5 }); - expect(usd).toEqual({ ok: true, value: '$1,234.50' }); + const cny = templateEngine.evaluate( + { dialect: 'template', source: '{{ record.amount | currency }}' }, + { record: { amount: 1234.5 }, locale: 'en-US', currency: 'CNY' }, + ); + expect(cny).toEqual({ ok: true, value: 'CN¥1,234.50' }); + // With no tenant default it is refused, never formatted in USD. + const none = evaluate('{{ record.amount | currency }}', { amount: 1234.5 }); + expect(none.ok).toBe(false); }); }); @@ -124,9 +130,10 @@ describe('the formatter itself never substitutes a currency', () => { expect(() => formatValue('currency', null, 'currency_code')).toThrow(RangeError); }); - it('formats a literal code, and the no-argument default, as before', () => { + it('formats a literal code as before, and no argument in the tenant default it is handed', () => { expect(formatValue('currency', 1234.5, 'EUR', { locale: 'en-US' })).toBe('€1,234.50'); - expect(formatValue('currency', 1234.5, undefined, { locale: 'en-US' })).toBe('$1,234.50'); + expect(formatValue('currency', 1234.5, undefined, { locale: 'en-US', currency: 'CNY' })).toBe('CN¥1,234.50'); + expect(() => formatValue('currency', 1234.5, undefined, { locale: 'en-US' })).toThrow(RangeError); }); }); diff --git a/packages/formula/src/types.ts b/packages/formula/src/types.ts index 10fdbf975a6..033d29dbace 100644 --- a/packages/formula/src/types.ts +++ b/packages/formula/src/types.ts @@ -118,6 +118,20 @@ export interface EvalContext { * translator. */ locale?: string; + /** + * The tenant default currency (an ISO 4217 code in three capitals, e.g. + * `CNY`): the `localization.currency` setting, read the way a currency field + * and a dashboard measure read it (`ExecutionContext.currency` on a request). + * A `template` hole `{{ amount | currency }}` with no argument formats in it. + * + * A reserved channel, like {@link locale}: never bound as a variable, and no + * root sets it. Unset (the tenant has no default, or the caller supplied + * none) or not a three-capital code, a no-argument `currency` hole makes the + * evaluation fail (`kind: 'runtime'`), naming the hole and the remedy: the + * amount is never formatted in a currency nobody chose. A hole that names + * its code (`currency:EUR`) does not read this. + */ + currency?: string; /** * Current authenticated subject (hook / action / view contexts). * diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 3575a0ba015..f4a85f68876 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -810,7 +810,12 @@ describe('AuthPlugin', () => { await boot({ settings, email, i18nDefault: 'en', registerRoutes }); expect(mockContext.getService).toHaveBeenCalledWith('email'); - expect(setEmailServiceSpy).toHaveBeenCalledWith(email); + // Wired wrapped (the tenant default currency on `sendTemplate`, see + // mail-tenant-currency.ts): every other call reaches the transport. + expect(setEmailServiceSpy).toHaveBeenCalledTimes(1); + const wired = setEmailServiceSpy.mock.calls[0]![0] as { send: (input: unknown) => unknown }; + wired.send({ to: 'a@x.com' }); + expect(email.send).toHaveBeenCalledWith({ to: 'a@x.com' }); expect(settings.get).toHaveBeenCalledWith('localization', 'locale', {}); expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN'); expect(setSmsLocaleSpy).toHaveBeenLastCalledWith('zh-CN'); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index c9cc29a3a3d..2e5015a5e52 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. // [#25083] `isCloudManaged`: the ONE accessor the cloud-managed doors below ask. -import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core'; +import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveLocalizationContext, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core'; import type { BetterAuthOptions } from 'better-auth'; import { AuthConfig, @@ -23,6 +23,7 @@ import { import { isHumanUserRow, OPEN_POSTURE_VERIFICATION_OFF_WARNING } from './audience-posture.js'; // [#14157] The dev-admin seed's own precondition — "does a login exist?". import { decideDevAdminSeedGate } from './dev-admin-seed-gate.js'; +import { withMailTenantCurrency } from './mail-tenant-currency.js'; import { // ADR-0048 — the Setup/Studio/Account apps moved to their own packages // (@objectstack/{setup,studio,account}); plugin-auth no longer registers them. @@ -1094,7 +1095,16 @@ export class AuthPlugin implements Plugin { let emailSvc: IEmailService | undefined; try { emailSvc = ctx.getService('email'); } catch { emailSvc = undefined; } if (emailSvc) { - this.authManager.setEmailService(emailSvc); + // A mail template's no-argument `currency` hole formats in the + // tenant default: added once here for the six auth mail call sites + // (see mail-tenant-currency.ts), read the way a request reads it. + this.authManager.setEmailService(withMailTenantCurrency(emailSvc, async (organizationId) => { + let settings: unknown; + try { settings = ctx.getService('settings'); } catch { settings = undefined; } + let ql: unknown; + try { ql = ctx.getService('data'); } catch { ql = undefined; } + return (await resolveLocalizationContext({ ql, settings, organizationId })).currency; + })); ctx.logger.info('Auth: email service wired (transactional mail enabled)'); } else { // No email service. The verification / password-reset callbacks now diff --git a/packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts b/packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts new file mode 100644 index 00000000000..d11d0a9c447 --- /dev/null +++ b/packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The auth mails reach `sendTemplate` through `withMailTenantCurrency`, which + * adds the organization's default currency to a call that names none, so a + * template's no-argument `{{ amount | currency }}` hole formats in it rather + * than being refused for a default the organization has. + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { IEmailService, SendTemplateInput } from '@objectstack/spec/contracts'; +import { withMailTenantCurrency } from './mail-tenant-currency.js'; + +function emailDouble() { + const sendTemplate = vi.fn(async (_input: SendTemplateInput) => ({ id: 'mail_1', status: 'sent' as const })); + const send = vi.fn(async () => ({ id: 'mail_2', status: 'sent' as const })); + return { sendTemplate, send, service: { send, sendTemplate } as unknown as IEmailService }; +} + +describe('withMailTenantCurrency', () => { + it('adds the named organization\'s default to a call that names none', async () => { + const email = emailDouble(); + const reads: Array = []; + const wired = withMailTenantCurrency(email.service, async (org) => { reads.push(org); return org === 'org_cn' ? 'CNY' : undefined; }); + await wired.sendTemplate({ template: 'auth.invitation', to: 'a@x.com', organizationId: 'org_cn' }); + expect(reads).toEqual(['org_cn']); + expect(email.sendTemplate).toHaveBeenCalledWith({ template: 'auth.invitation', to: 'a@x.com', organizationId: 'org_cn', currency: 'CNY' }); + }); + + it('identity mail, which names no organization, reads the deployment-wide default', async () => { + const email = emailDouble(); + const reads: Array = []; + const wired = withMailTenantCurrency(email.service, async (org) => { reads.push(org); return 'EUR'; }); + await wired.sendTemplate({ template: 'auth.verify_email', to: 'a@x.com', identityMail: true }); + expect(reads).toEqual([undefined]); + expect(email.sendTemplate.mock.calls[0]![0]).toMatchObject({ currency: 'EUR' }); + }); + + it('a call that names a currency keeps it, and the default is not read', async () => { + const email = emailDouble(); + const resolve = vi.fn(async () => 'CNY'); + await withMailTenantCurrency(email.service, resolve).sendTemplate({ template: 't', to: 'a@x.com', currency: 'JPY' }); + expect(resolve).not.toHaveBeenCalled(); + expect(email.sendTemplate.mock.calls[0]![0]).toMatchObject({ currency: 'JPY' }); + }); + + it.each([ + ['no default', async () => undefined], + ['a failed read', async () => { throw new Error('settings unavailable'); }], + ])('with %s it adds no `currency` key, and the render then refuses such a hole', async (_label, resolve) => { + const email = emailDouble(); + await withMailTenantCurrency(email.service, resolve).sendTemplate({ template: 't', to: 'a@x.com' }); + expect(email.sendTemplate.mock.calls[0]![0]).not.toHaveProperty('currency'); + }); + + it('every other member is the service\'s own', async () => { + const email = emailDouble(); + await withMailTenantCurrency(email.service, async () => 'CNY').send({ to: 'a@x.com' } as never); + expect(email.send).toHaveBeenCalledWith({ to: 'a@x.com' }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/mail-tenant-currency.ts b/packages/plugins/plugin-auth/src/mail-tenant-currency.ts new file mode 100644 index 00000000000..006bb2224a8 --- /dev/null +++ b/packages/plugins/plugin-auth/src/mail-tenant-currency.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The auth mails' tenant default currency. + * + * A mail template's `{{ amount | currency }}` hole with no argument formats in + * the organization's default currency (`localization.currency`), which the + * caller of `sendTemplate` hands over as `currency`; with none the render + * refuses the hole (`TEMPLATE_INVALID`). The auth mails + * (verification, password reset, invitations, magic link, notices) are sent + * from six call sites, so the default is added once, here, on the email + * service the plugin wires into the AuthManager, rather than at each site: a + * call that names no `currency` gets the default of the organization it names + * (`organizationId`), or the deployment-wide setting for identity mail, which + * names none. A call that names one keeps it, and a failed or empty read adds + * nothing. + */ + +import type { IEmailService, SendTemplateInput } from '@objectstack/spec/contracts'; + +/** Answers the default currency of an organization, or of the deployment when none is named. */ +export type MailTenantCurrencyResolver = (organizationId: string | undefined) => Promise; + +async function withCurrency(input: SendTemplateInput, resolve: MailTenantCurrencyResolver): Promise { + if (input.currency !== undefined) return input; + let currency: string | undefined; + try { + currency = await resolve(input.organizationId); + } catch { + currency = undefined; + } + return typeof currency === 'string' && currency !== '' ? { ...input, currency } : input; +} + +/** + * The email service, with the tenant default currency added to every + * `sendTemplate` call that carries none. Every other member is the service's + * own. + */ +export function withMailTenantCurrency(email: IEmailService, resolve: MailTenantCurrencyResolver): IEmailService { + return new Proxy(email, { + get(target, prop) { + if (prop === 'sendTemplate') { + return async (input: SendTemplateInput) => target.sendTemplate(await withCurrency(input, resolve)); + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index 824fcacdbc6..edffe5b4807 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -1452,6 +1452,9 @@ export class EmailService implements IEmailService { const renderOpts = { ...(locale ? { locale } : {}), ...(input.timezone ? { timeZone: input.timezone } : {}), + // The tenant default a `{{ amount | currency }}` hole formats in. The + // caller supplies it; absent, such a hole refuses below, never USD. + ...(input.currency ? { currency: input.currency } : {}), }; // Each face is rendered in ITS OWN encoding. Only `body_html` is markup, // so only it HTML-escapes its `{{x}}` holes. The subject (a mail header, diff --git a/packages/plugins/plugin-email/src/template-currency-tenant-default.render.test.ts b/packages/plugins/plugin-email/src/template-currency-tenant-default.render.test.ts new file mode 100644 index 00000000000..2fa10ef0eb3 --- /dev/null +++ b/packages/plugins/plugin-email/src/template-currency-tenant-default.render.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A mail template's `{{ amount | currency }}` hole with no argument formats in + * the tenant default currency the caller hands the send (`currency`, the + * organization's `localization.currency`). With none, the render refuses with + * `TEMPLATE_INVALID`, naming the template, the hole and the remedy, and + * nothing is sent: the amount is never formatted in USD or any other guess. + * + * Measured through the by-name doors, `EmailService.renderTemplate` and + * `sendTemplate`, over all three faces, and through the face renderers. + */ + +import { describe, expect, it } from 'vitest'; +import type { IEmailTransport, NormalizedEmailMessage, TransportSendResult } from '@objectstack/spec/contracts'; +import type { EmailTemplateRow, TemplateLoader } from './index.js'; +import { EmailService } from './email-service.js'; +import { renderPlainTextTemplate, renderTemplate } from './template-engine.js'; + +function makeService(rows: EmailTemplateRow[]) { + const sent: NormalizedEmailMessage[] = []; + const transport: IEmailTransport = { + async send(message: NormalizedEmailMessage): Promise { + sent.push(message); + return { messageId: `msg-${sent.length}` }; + }, + }; + const templateLoader: TemplateLoader = { + async load(name, locale) { + return rows.find((r) => r.name === name && (locale === undefined || r.locale === locale)) ?? null; + }, + }; + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader, + persistence: { + async insert(r) { return { id: r.id }; }, + async update() { /* noop */ }, + }, + }); + return { svc, sent }; +} + +const overdue = (locale: string): EmailTemplateRow => ({ + name: 'contract.overdue', + locale, + subject: 'Due {{ planned_amount | currency }}', + body_html: '

{{ planned_amount | currency }} / {{{ planned_amount | currency }}}

', + body_text: 'Due {{ planned_amount | currency }}', + active: true, +}); + +const data = { planned_amount: 1234.5 }; + +describe('no argument: the tenant default currency the send carries', () => { + it.each([ + ['en-US', 'CN¥1,234.50'], + ['zh-CN', '¥1,234.50'], + ])('renders every face in CNY for the %s row', async (locale, amount) => { + const { svc } = makeService([overdue('en-US'), overdue('zh-CN')]); + const out = await svc.renderTemplate({ template: 'contract.overdue', locale, data, currency: 'CNY' }); + expect(out).toEqual({ subject: `Due ${amount}`, html: `

${amount} / ${amount}

`, text: `Due ${amount}` }); + }); + + it('sendTemplate delivers the amount in the tenant default', async () => { + const { svc, sent } = makeService([overdue('en-US')]); + await svc.sendTemplate({ template: 'contract.overdue', to: 'a@x.com', data, currency: 'EUR' }); + expect(sent).toHaveLength(1); + expect(sent[0]!.subject).toBe('Due €1,234.50'); + }); + + it('a hole that names its code does not read the tenant default', async () => { + const { svc } = makeService([{ ...overdue('en-US'), subject: 'Due {{ planned_amount | currency:EUR }}' }]); + const out = await svc.renderTemplate({ template: 'contract.overdue', data, currency: 'CNY' }); + expect(out.subject).toBe('Due €1,234.50'); + }); +}); + +describe('no argument and no tenant default: refused, nothing sent', () => { + it.each([ + ['not supplied', undefined], + ['lowercase', 'usd'], + ])('renderTemplate refuses when the tenant default is %s, naming the template, the hole and the remedy', async (_label, currency) => { + const { svc } = makeService([overdue('en-US')]); + const render = svc.renderTemplate({ template: 'contract.overdue', data, ...(currency ? { currency } : {}) }); + await expect(render).rejects.toThrow( + /^TEMPLATE_INVALID: email template contract\.overdue \(locale=en-US\): the hole \{\{ planned_amount \| currency \}\}/, + ); + const message = await render.catch((e: Error) => e.message); + expect(message).toContain('localization.currency'); + expect(message).toContain('currency:EUR'); + expect(message).toContain('currency:@currency_code'); + expect(message).not.toContain('$'); + }); + + it('sendTemplate refuses the same template and sends nothing', async () => { + const { svc, sent } = makeService([overdue('en-US')]); + await expect(svc.sendTemplate({ template: 'contract.overdue', to: 'a@x.com', data })).rejects.toThrow(/^TEMPLATE_INVALID: /); + expect(sent).toEqual([]); + }); + + it('refuses whatever the value: a send with no amount is refused too', async () => { + const { svc } = makeService([overdue('en-US')]); + await expect(svc.renderTemplate({ template: 'contract.overdue', data: {} })).rejects.toThrow(/^TEMPLATE_INVALID: /); + }); + + it('each face renderer refuses it, double- or triple-braced', () => { + expect(() => renderTemplate('

{{ amt | currency }}

', { amt: 1 })).toThrow(/^TEMPLATE_INVALID: the hole \{\{ amt \| currency \}\}/); + expect(() => renderTemplate('

{{{ amt | currency }}}

', { amt: 1 })).toThrow(/^TEMPLATE_INVALID: /); + expect(() => renderPlainTextTemplate('Due {{ amt | currency }}', { amt: 1 })).toThrow(/^TEMPLATE_INVALID: /); + }); + + it('a marked path names its own code and needs no tenant default', async () => { + const { svc } = makeService([{ ...overdue('en-US'), subject: 'Due {{ planned_amount | currency:@currency_code }}', body_html: '

x

', body_text: 'x' }]); + const out = await svc.renderTemplate({ template: 'contract.overdue', data: { ...data, currency_code: 'JPY' } }); + expect(out.subject).toBe('Due ¥1,235'); + }); + + it('a template with no no-argument currency hole needs no tenant default', async () => { + const { svc } = makeService([{ ...overdue('en-US'), subject: 'Due {{ planned_amount | currency:EUR }}', body_html: '

{{ planned_amount | number:2 }}

', body_text: 'x' }]); + const out = await svc.renderTemplate({ template: 'contract.overdue', data }); + expect(out.subject).toBe('Due €1,234.50'); + }); +}); diff --git a/packages/plugins/plugin-email/src/template-engine.test.ts b/packages/plugins/plugin-email/src/template-engine.test.ts index 627997f1815..21962129440 100644 --- a/packages/plugins/plugin-email/src/template-engine.test.ts +++ b/packages/plugins/plugin-email/src/template-engine.test.ts @@ -76,7 +76,7 @@ describe('template-engine', () => { // ADR-0053 Phase 2: formatter holes reuse the shared formula whitelist. describe('formatter holes', () => { it('applies currency / number formatters', () => { - expect(renderTemplate('{{ amt | currency }}', { amt: 1234.5 })).toBe('$1,234.50'); + expect(renderTemplate('{{ amt | currency }}', { amt: 1234.5 }, { currency: 'USD' })).toBe('$1,234.50'); expect(renderTemplate('{{ n | number:2 }}', { n: 1000 })).toBe('1,000.00'); }); diff --git a/packages/plugins/plugin-email/src/template-engine.ts b/packages/plugins/plugin-email/src/template-engine.ts index 1c917a9740f..c19906c0d4f 100644 --- a/packages/plugins/plugin-email/src/template-engine.ts +++ b/packages/plugins/plugin-email/src/template-engine.ts @@ -42,7 +42,10 @@ * own path; a value that is not a code in three capital letters — missing, * `null`, lowercase (`eur`), anything else — makes the render throw * `TEMPLATE_INVALID` too, naming the hole, the path and the value - * (`currencyCodeValueRefusal`). Those are the ways a render throws: it prints + * (`currencyCodeValueRefusal`). A `currency` hole with no argument formats in + * the tenant default currency, {@link RenderOptions.currency}; with none it + * throws `TEMPLATE_INVALID` as well (`currencyDefaultRefusal`), since it then + * names no currency at all. Those are the ways a render throws: it prints * neither a substituted currency nor formatter syntax to a recipient. * * Deliberately tiny (no loops / conditionals / partials) — the design @@ -52,12 +55,18 @@ * runtime; we resist that until a real use case demands it. */ -import { currencyCodeValueRefusal, formatValue, mailHolePattern, parseMailHole } from '@objectstack/formula'; +import { currencyCodeValueRefusal, currencyDefaultRefusal, formatValue, mailHolePattern, parseMailHole } from '@objectstack/formula'; /** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */ export interface RenderOptions { locale?: string; timeZone?: string; + /** + * The tenant default currency (ISO 4217, the `localization.currency` + * setting) a `{{ amount | currency }}` hole with no argument formats in. + * Unset, such a hole makes the render throw `TEMPLATE_INVALID`. + */ + currency?: string; } function lookup(data: Record, path: string): unknown { @@ -131,6 +140,12 @@ function render( const raw = lookup(data, parsed.path); let str: string; if (parsed.formatter) { + // A `currency` hole with no argument formats in the tenant default, + // judged before the amount is read: with none, nothing is formatted. + if (parsed.formatter === 'currency' && parsed.arg === undefined) { + const refusal = currencyDefaultRefusal(match, opts.currency, 'mail'); + if (refusal) throw new Error(`TEMPLATE_INVALID: ${refusal}`); + } // `currency:@path`: the code is the send's data at that path, judged // before the amount is read, as a literal argument is. let arg = parsed.arg; @@ -145,6 +160,7 @@ function render( const formatted = formatValue(parsed.formatter, raw, arg, { locale: opts.locale, timeZone: opts.timeZone, + currency: opts.currency, }); // The grammar refuses a formatter the whitelist does not name, so // `formatValue` knows this one; if the two ever disagree the render diff --git a/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts b/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts index 2757e3c4ba0..6c53ed3ec67 100644 --- a/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts +++ b/packages/plugins/plugin-email/src/template-hole-grammar.render.test.ts @@ -107,13 +107,13 @@ describe('a currency argument that is not a literal ISO 4217 code', () => { expect(sent).toEqual([]); }); - it('a literal code renders in that currency in every face, and no argument still renders USD', async () => { + it('a literal code renders in that currency in every face, and no argument renders the tenant default', async () => { const { svc } = makeService([row('contract.overdue', { subject: 'Due {{ planned_amount | currency:EUR }}', body_html: '

{{ planned_amount | currency:EUR }} / {{ planned_amount | currency }}

', body_text: 'Due {{ planned_amount | currency:EUR }}', })]); - const out = await svc.renderTemplate({ template: 'contract.overdue', data }); - expect(out).toEqual({ subject: 'Due €1,234.50', html: '

€1,234.50 / $1,234.50

', text: 'Due €1,234.50' }); + const out = await svc.renderTemplate({ template: 'contract.overdue', data, currency: 'CNY' }); + expect(out).toEqual({ subject: 'Due €1,234.50', html: '

€1,234.50 / CN¥1,234.50

', text: 'Due €1,234.50' }); }); }); diff --git a/packages/services/service-automation/src/builtin/http-nodes.ts b/packages/services/service-automation/src/builtin/http-nodes.ts index 04793820d48..3b72e5b6533 100644 --- a/packages/services/service-automation/src/builtin/http-nodes.ts +++ b/packages/services/service-automation/src/builtin/http-nodes.ts @@ -73,11 +73,12 @@ import { FLOW_CREDENTIAL_CLEARED, HTTP_SIGNING_SECRET_KEY } from '../flow-creden function renderHeaders( headers: Readonly> | undefined, variables: VariableMap, + currency: string | undefined, ): Record | undefined { if (headers === undefined) return undefined; const out: Record = {}; for (const [name, value] of Object.entries(headers)) { - const text = renderTextSlot(value, variables); + const text = renderTextSlot(value, variables, currency); if (text !== undefined) out[name] = text; } return out; @@ -237,9 +238,9 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): // The request, rendered and evaluated ONCE, before either mode: the // durable outbox enqueues what the inline call would send. - const url = renderTextSlot(cfg.url, variables); + const url = renderTextSlot(cfg.url, variables, context.currency); if (!url) return refuseNode('http: url is required'); - const headers = renderHeaders(cfg.headers, variables); + const headers = renderHeaders(cfg.headers, variables, context.currency); const body = cfg.body === undefined ? undefined : resolveValueSlot(engine, cfg.body, variables, 'body', context); const durable = cfg.durable === true; diff --git a/packages/services/service-automation/src/builtin/notify-currency-tenant-default.test.ts b/packages/services/service-automation/src/builtin/notify-currency-tenant-default.test.ts new file mode 100644 index 00000000000..bb0ecc47e95 --- /dev/null +++ b/packages/services/service-automation/src/builtin/notify-currency-tenant-default.test.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A flow text slot's `{{ amount | currency }}` hole with no argument formats + * in the run's organization's default currency (`localization.currency`). + * Run setup seeds it once (`AutomationContext.currency`) through the host's + * localization read (`setTenantCurrencyResolver`, which the automation plugin + * bridges to `@objectstack/core`'s `resolveLocalizationContext`). With none, + * the node is refused with a message naming the hole, and nothing is sent: the + * amount is never formatted in USD or any other guess. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { InMemorySuspendedRunStore } from '../suspended-run-store.js'; +import { installBuiltinNodes } from './index.js'; +import { registerNotifyNode } from './notify-node.js'; +import type { MessagingServiceSurface } from './notify-node.js'; +import { FlowTextTemplateError, renderTextSlot } from './template.js'; + +function createTestLogger() { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + child: () => createTestLogger(), + } as any; +} + +function fakeMessaging() { + const emitted: Array[0]> = []; + const service: MessagingServiceSurface = { + async emit(n) { + emitted.push(n); + return { notificationId: 'evt_1', delivered: n.audience.length, failed: 0 }; + }, + }; + return { service, emitted }; +} + +const FLOW = { + name: 'deal_won_notice', + label: 'Deal Won Notice', + type: 'autolaunched' as const, + variables: [{ name: 'amount', type: 'number' as const, isInput: true }], + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { + id: 'notify', + type: 'notify' as const, + label: 'Notify', + config: { recipients: ['user_1'], title: 'Won {{ amount | currency }}', message: 'Booked {{ amount | currency:EUR }}' }, + }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'notify' }, + { id: 'e2', source: 'notify', target: 'end' }, + ], +}; + +describe('notify — a no-argument currency hole formats in the run organization\'s default', () => { + let engine: AutomationEngine; + let messaging: ReturnType; + + beforeEach(() => { + messaging = fakeMessaging(); + engine = new AutomationEngine(createTestLogger()); + registerNotifyNode(engine, { + logger: createTestLogger(), + getService: (name: string) => (name === 'messaging' ? messaging.service : undefined), + } as any); + engine.registerFlow('deal_won_notice', FLOW); + }); + + it('run setup reads the run organization\'s default once, and the title formats in it', async () => { + const reads: Array = []; + engine.setTenantCurrencyResolver((tenantId) => { + reads.push(tenantId); + return tenantId === 'org_cn' ? 'CNY' : undefined; + }); + const result = await engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_cn' } as any); + expect(result.success, JSON.stringify(result)).toBe(true); + expect(reads).toEqual(['org_cn']); + expect(messaging.emitted[0]?.payload).toMatchObject({ title: 'Won CN¥1,234.50', body: 'Booked €1,234.50' }); + }); + + it('a context that already carries a currency keeps it, and the resolver is not asked', async () => { + let asked = 0; + engine.setTenantCurrencyResolver(() => { asked += 1; return 'CNY'; }); + const result = await engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_cn', currency: 'JPY' } as any); + expect(result.success, JSON.stringify(result)).toBe(true); + expect(asked).toBe(0); + expect(messaging.emitted[0]?.payload).toMatchObject({ title: 'Won ¥1,235' }); + }); + + it.each([ + ['no resolver is wired', undefined], + ['the organization has no default', () => undefined], + ['the read fails', () => { throw new Error('settings unavailable'); }], + ])('refuses the node by name when %s, and sends nothing', async (_label, resolver) => { + if (resolver) engine.setTenantCurrencyResolver(resolver as never); + const result = await engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_none' } as any); + expect(result.success).toBe(false); + expect(String(result.error)).toContain('{{ amount | currency }}'); + expect(String(result.error)).toContain('localization.currency'); + expect(String(result.error)).not.toContain('$'); + expect(messaging.emitted).toHaveLength(0); + }); +}); + +describe('a run parked before run setup seeded a currency resumes in the organization\'s default', () => { + const PARKED = { + name: 'deal_review', + label: 'Deal Review', + type: 'screen' as const, + variables: [{ name: 'amount', type: 'number' as const, isInput: true }], + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { id: 'ask', type: 'screen' as const, label: 'Ask', config: { waitForInput: true, title: 'Confirm', fields: [{ name: 'ok', label: 'OK', type: 'checkbox' }] } }, + { id: 'notify', type: 'notify' as const, label: 'Notify', config: { recipients: ['user_1'], title: 'Won {{ amount | currency }}', message: 'x' } }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ask' }, + { id: 'e2', source: 'ask', target: 'notify' }, + { id: 'e3', source: 'notify', target: 'end' }, + ], + }; + + function engineOver(store: InMemorySuspendedRunStore, messaging: ReturnType) { + const engine = new AutomationEngine(createTestLogger(), store); + installBuiltinNodes(engine, { + logger: createTestLogger(), + getService: (name: string) => (name === 'messaging' ? messaging.service : undefined), + } as never); + engine.registerFlow('deal_review', PARKED); + return engine; + } + + it('a cold resume seeds the default the persisted context lacks, and leaves the persisted context as it parked', async () => { + const store = new InMemorySuspendedRunStore(); + const before = engineOver(store, fakeMessaging()); + // Parked by an engine with no resolver: the persisted context carries no currency. + const paused = await before.execute('deal_review', { params: { amount: 1234.5 }, tenantId: 'org_cn' } as any); + expect(paused.status).toBe('paused'); + const parked = await store.load(paused.runId!); + expect(parked?.context).not.toHaveProperty('currency'); + + const messaging = fakeMessaging(); + const after = engineOver(store, messaging); + const reads: Array = []; + after.setTenantCurrencyResolver((tenantId) => { reads.push(tenantId); return 'CNY'; }); + const resumed = await after.resume(paused.runId!, { variables: { ok: true } } as never); + + expect(resumed.success, JSON.stringify(resumed)).toBe(true); + expect(reads).toEqual(['org_cn']); + expect(messaging.emitted[0]?.payload).toMatchObject({ title: 'Won CN¥1,234.50' }); + expect(parked?.context).not.toHaveProperty('currency'); + }); +}); + +describe('renderTextSlot — the tenant default on the engine\'s reserved channel', () => { + const vars = new Map([['amount', 1234.5], ['currency', 'EUR']]); + + it('formats a no-argument hole in the currency it is handed', () => { + expect(renderTextSlot('Due {{ amount | currency }}', vars, 'CNY')).toBe('Due CN¥1,234.50'); + }); + + it('a flow variable named `currency` is a value, never the tenant default', () => { + expect(() => renderTextSlot('Due {{ amount | currency }} ({{ currency }})', vars)).toThrow(FlowTextTemplateError); + }); + + it('throws the guard refusal without one, naming the hole', () => { + expect(() => renderTextSlot('Due {{ amount | currency }}', vars)).toThrow(/\{\{ amount \| currency \}\}/); + }); +}); diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index e7907826b21..4d72c8a208a 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -950,8 +950,8 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // `end` text share — the formula template engine's `{{ }}` holes // (ADR-0032 D3). An object a hole reaches (`{{ $error }}`) renders as // JSON, never `[object Object]` (#3450). - const title = renderTextSlot(cfg.title?.source, variables) ?? ''; - const body = renderTextSlot(cfg.message?.source, variables) ?? ''; + const title = renderTextSlot(cfg.title?.source, variables, context.currency) ?? ''; + const body = renderTextSlot(cfg.message?.source, variables, context.currency) ?? ''; // #9205 — the localizable content path. `template` is read RAW (a // static metadata cross-reference, like `topic`/`channels`); // `templateData` is a VALUE slot per key (#19939), so flow state @@ -969,7 +969,7 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) // renderer, like `title`, with `{{ }}` holes. A link that renders no // text is no link (`undefined`), so the synthesized one below still // applies — as an empty one did. - const actionUrl = renderTextSlot(cfg.actionUrl, variables); + const actionUrl = renderTextSlot(cfg.actionUrl, variables, context.currency); // [#19939] `payload` is a literal, read as written: no flow // computed one, and the contract refused a `{…}` token or an // envelope in it. diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 1cecbf95085..729a93228fa 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -410,7 +410,7 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext ? context.flowName : undefined; const screenText = (key: FlowScreenCopyKey, authored: unknown, shownAs: string): string | undefined => { - if (flowName === undefined) return renderTextSlot(authored, variables); + if (flowName === undefined) return renderTextSlot(authored, variables, context.currency); return engine.renderFlowTextSlot( { key: flowScreenCopyKey(flowName, node.id, key), diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index c7cc1cd2fec..2ed777e4c0a 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -35,7 +35,10 @@ export type VariableMap = Map; * compile every text slot first, so this is met only by a flow that reached the * executor without them. A guard refusal (#3863): the metadata is wrong, * re-running the flow unchanged can never succeed, and a `fault` edge must not - * swallow it into a notification sent with its holes unfilled. + * swallow it into a notification sent with its holes unfilled. The same error + * carries a `{{ amount | currency }}` hole with no argument met by a run whose + * organization has no default currency ({@link renderTextSlot}): nothing is + * sent until the organization sets one or the hole names its code. */ export class FlowTextTemplateError extends Error { /** The template text as authored. */ @@ -107,6 +110,15 @@ export function textTemplateScope(variables: VariableMap): Record { + it('a no-argument `currency` hole with no tenant default fails the run naming the hole, with no translation warning', async () => { + const lines: LogLine[] = []; + const zh = { flows: { dedupe: { refusals: { finish: { message: '已拒绝:{{ record.amount | currency }}' } } } } }; + const { engine } = engineWith(i18nWith({ 'zh-CN': zh }), lines); + const flow = refusingFlow(); + (flow.nodes[1] as { config: { message: string } }).config.message = 'Refused: {{ record.amount | currency }}'; + engine.registerFlow('dedupe', flow as never); + + const result = await engine.execute('dedupe', { + ...doorContext('zh-CN'), record: { ...ACME, amount: 1234.5 }, + } as unknown as AutomationContext); + + expect(result.success).toBe(false); + expect(String(result.error)).toContain('{{ record.amount | currency }}'); + expect(String(result.error)).toContain('localization.currency'); + expect(lines.filter((l) => l.level === 'warn' && l.message.includes('translation'))).toEqual([]); + }); + + it('CONTROL — with the tenant default on the run, the zh-CN translation renders in it', async () => { + const zh = { flows: { dedupe: { refusals: { finish: { message: '已拒绝:{{ record.amount | currency }}' } } } } }; + const { engine } = engineWith(i18nWith({ 'zh-CN': zh })); + const flow = refusingFlow(); + (flow.nodes[1] as { config: { message: string } }).config.message = 'Refused: {{ record.amount | currency }}'; + engine.registerFlow('dedupe', flow as never); + + const result = await engine.execute('dedupe', { + ...doorContext('zh-CN'), record: { ...ACME, amount: 1234.5 }, currency: 'CNY', + } as unknown as AutomationContext); + + expect(result.refusalMessage).toBe(`已拒绝:${new Intl.NumberFormat('en-US', { style: 'currency', currency: 'CNY' }).format(1234.5)}`); + }); +}); + describe('#22450 — a door-started run carries its locale into a resumed leg', () => { /** `start → ask (screen) → finish (refused)`: the refusal renders on the RESUMED leg. */ function reviewFlow() { diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 1946c20fab9..096cdc59f24 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -803,6 +803,21 @@ export type FlowUserGrantsResolver = ( tenantId: string | undefined, ) => Promise | FlowUserGrants | undefined; +/** + * Resolves the tenant default currency (ISO 4217, the `localization.currency` + * setting) of a run's organization, so a flow text slot's + * `{{ amount | currency }}` hole with no argument formats in it. Injected by + * the host — the automation plugin bridges it to `@objectstack/core`'s + * `resolveLocalizationContext`, the read a request resolves + * `ExecutionContext.currency` with. Returns `undefined` (or throws, tolerated) + * when the organization has none; run setup then leaves + * {@link AutomationContext.currency} unset, and such a hole refuses the node by + * name. When unwired, the same: nothing is guessed. + */ +export type FlowTenantCurrencyResolver = ( + tenantId: string | undefined, +) => Promise | string | undefined; + /** * Re-reads the specified single-hop lookup relations of a record-change flow's * triggering record so `{record..}` templates can traverse them @@ -2785,6 +2800,9 @@ export class AutomationEngine implements IAutomationService { /** Bridge to a host data read that expands declared lookup relations on the * run record (#3475), if wired. See {@link FlowRecordExpander}. */ private recordExpander: FlowRecordExpander | null = null; + /** Bridge to the host's localization read so a run carries its organization's + * default currency, if wired. See {@link FlowTenantCurrencyResolver}. */ + private tenantCurrencyResolver: FlowTenantCurrencyResolver | null = null; private executionLogs: ExecutionLogEntry[] = []; private readonly maxLogSize: number; /** Level for the per-run summary line (#4354). See {@link RunSummaryLogLevel}. */ @@ -4043,6 +4061,19 @@ export class AutomationEngine implements IAutomationService { this.userGrantsResolver = resolver; } + /** + * Wire the engine to the host's localization read so run setup seeds + * {@link AutomationContext.currency} — the organization's default currency, + * the code a text slot's `{{ amount | currency }}` hole with no argument + * formats in. The automation plugin bridges it to `@objectstack/core`'s + * `resolveLocalizationContext`. Passing `null` detaches it: a run then + * carries only a currency its trigger supplied, and such a hole refuses + * without one. + */ + setTenantCurrencyResolver(resolver: FlowTenantCurrencyResolver | null): void { + this.tenantCurrencyResolver = resolver; + } + /** * Wire the record lookup-expander (#3475). The automation plugin bridges it * to a `findOne(..., { expand, context })` on the same data engine the CRUD @@ -5499,6 +5530,12 @@ export class AutomationEngine implements IAutomationService { * explicit (the build-time lint `flow-runas-unscoped` rejects the * statically-decidable shapes earlier — but NOT the record-change shape, which * is only knowable at run time). + * + * Also seeds {@link AutomationContext.currency} — the organization's + * default currency — through {@link setTenantCurrencyResolver} when the + * context carries none, so a text slot's `{{ amount | currency }}` hole + * with no argument formats in it; with none it stays unset and such a hole + * refuses by name. */ private resolveRunContext(flow: FlowParsed, context?: AutomationContext, runId?: string): Promise { // The body lives in ./run-resume.ts (#25222). @@ -5506,6 +5543,7 @@ export class AutomationEngine implements IAutomationService { seedRunGrants: (...a) => this.seedRunGrants(...a), logger: this.logger, expandDeclaredLookups: (...a) => this.expandDeclaredLookups(...a), + tenantCurrencyResolver: this.tenantCurrencyResolver, }; return resolveRunContext.call(host, flow, context, runId); } @@ -5598,6 +5636,11 @@ export class AutomationEngine implements IAutomationService { * permission sets, so its data operations fall back to the baseline member * set — narrower, never the trigger user's grants (see the warning in * {@link seedRunGrants}). + * + * A segment whose persisted context carries no + * {@link AutomationContext.currency} — a run parked before run setup + * seeded one — is seeded here the way {@link resolveRunContext} seeds a + * new run, on a copy: the persisted `run.context` is left as it parked. */ private resolveDecisionSegmentContext( run: SuspendedRun, @@ -5606,6 +5649,7 @@ export class AutomationEngine implements IAutomationService { // The body lives in ./run-resume.ts (#25222). const host: ThisParameterType = { seedRunGrants: (...a) => this.seedRunGrants(...a), + tenantCurrencyResolver: this.tenantCurrencyResolver, }; return resolveDecisionSegmentContext.call(host, run, signal); } diff --git a/packages/services/service-automation/src/node-text-rendering.ts b/packages/services/service-automation/src/node-text-rendering.ts index 54856c71523..aa29e575234 100644 --- a/packages/services/service-automation/src/node-text-rendering.ts +++ b/packages/services/service-automation/src/node-text-rendering.ts @@ -113,7 +113,7 @@ export function renderRefusalMessage( ); } -/** The body of {@link AutomationEngine.renderFlowTextSlot}, moved unedited; that method's docblock is its contract. */ +/** The body of {@link AutomationEngine.renderFlowTextSlot}; that method's docblock is its contract. */ export function renderFlowTextSlot( this: Pick, slot: FlowTextSlotTranslation, @@ -121,19 +121,28 @@ export function renderFlowTextSlot( context: AutomationContext, ): string | undefined { const translated = this.translatedFlowTemplate(slot, context.locale); + let translationRefusal: FlowTextTemplateError | undefined; if (translated !== undefined) { try { - return renderTextSlot(translated.template, variables); + return renderTextSlot(translated.template, variables, context.currency); } catch (err: unknown) { if (!(err instanceof FlowTextTemplateError)) throw err; - this.logger.warn( - `[automation] ${slot.subject}: the '${translated.locale}' translation at '${slot.key}' does not ` + - `compile (${err.message}), so ${slot.shownAs} shows its authored text instead. Fix the ` + - `translation's holes to match the authored text.`, - ); + translationRefusal = err; } } - return renderTextSlot(slot.authored, variables); + // The authored text renders before the translation is blamed: when it + // throws too (a `{{ amount | currency }}` hole that meets no tenant + // default refuses both alike), its own refusal is the answer, and no line + // tells anyone to fix a translation that is not at fault. + const authored = renderTextSlot(slot.authored, variables, context.currency); + if (translated !== undefined && translationRefusal !== undefined) { + this.logger.warn( + `[automation] ${slot.subject}: the '${translated.locale}' translation at '${slot.key}' does not ` + + `compile (${translationRefusal.message}), so ${slot.shownAs} shows its authored text instead. Fix the ` + + `translation's holes to match the authored text.`, + ); + } + return authored; } /** The body of {@link AutomationEngine.translatedFlowTemplate}, moved unedited; that method's docblock is its contract. */ diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index f8d90b9c0a2..87d3bd769c3 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; -import { resolveUserAuthzGrants } from '@objectstack/core'; +import { resolveLocalizationContext, resolveUserAuthzGrants } from '@objectstack/core'; import type { IJobService } from '@objectstack/spec/contracts'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; import type { @@ -1304,6 +1304,18 @@ export class AutomationServicePlugin implements Plugin { '(a user-mode run carries the triggering user\'s positions and permission sets)', ); + // The organization's default currency (`localization.currency`) + // for a text slot's `{{ amount | currency }}` hole with no + // argument: the read a request resolves `ExecutionContext.currency` + // with, over the same engine and the `settings` service, probed at + // run setup so a late-registered settings service is seen. + this.engine.setTenantCurrencyResolver(async (tenantId) => { + let settings: unknown; + try { settings = ctx.getService('settings'); } catch { settings = undefined; } + const localization = await resolveLocalizationContext({ ql: engineQl, settings, organizationId: tenantId }); + return localization.currency; + }); + // #3475 — bridge the lookup expander for record-change flow // templates. Re-reads the relations a flow declares in its start // node's `config.expand` via the run's own identity diff --git a/packages/services/service-automation/src/run-resume.ts b/packages/services/service-automation/src/run-resume.ts index dc436ba77b6..a963e52437d 100644 --- a/packages/services/service-automation/src/run-resume.ts +++ b/packages/services/service-automation/src/run-resume.ts @@ -66,6 +66,7 @@ export interface RunResumeHost { readonly logger: AutomationEngine['logger']; readonly expandDeclaredLookups: AutomationEngine['expandDeclaredLookups']; readonly userGrantsResolver: AutomationEngine['userGrantsResolver']; + readonly tenantCurrencyResolver: AutomationEngine['tenantCurrencyResolver']; readonly recordExpander: AutomationEngine['recordExpander']; readonly refuseGatedResume: AutomationEngine['refuseGatedResume']; readonly resumeInternal: AutomationEngine['resumeInternal']; @@ -105,8 +106,8 @@ export interface RunResumeHost { readonly failAncestors: AutomationEngine['failAncestors']; } -/** The body of {@link AutomationEngine.resolveRunContext}, moved unedited; that method's docblock is its contract. */ -export async function resolveRunContext(this: Pick, flow: FlowParsed, context?: AutomationContext, runId?: string): Promise { +/** The body of {@link AutomationEngine.resolveRunContext}; that method's docblock is its contract. */ +export async function resolveRunContext(this: Pick, flow: FlowParsed, context?: AutomationContext, runId?: string): Promise { // `flowRunId` is stamped alongside `runAs` because it shares that field's // lifetime and its single construction point: set once here, copied into // every data node's ObjectQL context by `resolveRunDataContext`, and @@ -159,6 +160,21 @@ export async function resolveRunContext(this: Pick, + run: SuspendedRun, + signal: ResumeSignal, +): Promise { + return withTenantCurrency.call(this, await deciderSegmentContext.call(this, run, signal)); +} + +/** + * A resumed segment's context with the organization's default currency, when + * the persisted one carries none: a run parked before run setup seeded + * `currency` would otherwise refuse a `{{ amount | currency }}` hole whose + * remedy, the setting, is already in place. A copy, never the persisted + * context; a failed or empty read leaves it as it was. + */ +async function withTenantCurrency( + this: Pick, + context: AutomationContext, +): Promise { + if (context?.currency !== undefined || !this.tenantCurrencyResolver) return context; + try { + const currency = await this.tenantCurrencyResolver(context?.tenantId); + if (typeof currency === 'string' && currency !== '') return { ...(context ?? {}), currency } as AutomationContext; + } catch { + // Tolerated, as at run setup: the hole's own refusal is the loud answer. + } + return context; +} + +async function deciderSegmentContext( this: Pick, run: SuspendedRun, signal: ResumeSignal, diff --git a/packages/services/service-messaging/src/email-channel.ts b/packages/services/service-messaging/src/email-channel.ts index 92922cc5a04..0769f53a753 100644 --- a/packages/services/service-messaging/src/email-channel.ts +++ b/packages/services/service-messaging/src/email-channel.ts @@ -109,6 +109,16 @@ export interface EmailChannelOptions { * exist at async delivery time and is not a rung. */ getDefaultTemplateLocale?(): string | undefined; + /** + * The tenant default currency (ISO 4217, the `localization.currency` + * setting) of the delivery's organization, read the way a currency field + * reads it (`resolveLocalizationContext` in `@objectstack/core`). Handed to + * the template render as its `currency`, the code a `{{ amount | currency }}` + * hole with no argument formats in. `undefined` (no default set, or not + * wired) leaves the render without one, so such a hole refuses the delivery + * with `TEMPLATE_INVALID` instead of formatting in a guessed currency. + */ + getTenantCurrency?(organizationId: string | undefined): Promise; } /** @@ -342,11 +352,13 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel const templateLocale = recipientLocale; const data = (payload.templateData ?? undefined) as Record | undefined; try { + const currency = await opts.getTenantCurrency?.(n.organizationId); const result = (await email.sendTemplate({ template: templateName, to: address, ...(data !== undefined ? { data } : {}), ...(templateLocale ? { locale: templateLocale } : {}), + ...(currency ? { currency } : {}), // Commit b706af987 — this channel HOLDS the organization (the // tenant stamp the outbox snapshots per delivery), so // it threads it for the sys_email.organization_id diff --git a/packages/services/service-messaging/src/inbox-channel.ts b/packages/services/service-messaging/src/inbox-channel.ts index 28c2c48c518..50107504259 100644 --- a/packages/services/service-messaging/src/inbox-channel.ts +++ b/packages/services/service-messaging/src/inbox-channel.ts @@ -53,6 +53,16 @@ export interface InboxChannelOptions { * in `recipient-locale.ts` exactly as the email channel composes it. */ getDefaultTemplateLocale?(): string | undefined; + /** + * The tenant default currency (ISO 4217, the `localization.currency` + * setting) of the delivery's organization, read the way a currency field + * reads it (`resolveLocalizationContext` in `@objectstack/core`). Handed to + * the template render as its `currency`, the code a `{{ amount | currency }}` + * hole with no argument formats in. `undefined` (no default set, or not + * wired) leaves the render without one, so such a hole refuses the delivery + * with `TEMPLATE_INVALID` instead of formatting in a guessed currency. + */ + getTenantCurrency?(organizationId: string | undefined): Promise; /** User identity object the recipient's locale is read from (default `sys_user`). */ userObject?: string; } @@ -185,10 +195,12 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel ); const templateData = (payload.templateData ?? undefined) as Record | undefined; try { + const currency = await opts.getTenantCurrency?.(n.organizationId); const rendered = await email.renderTemplate({ template: templateName, ...(templateData !== undefined ? { data: templateData } : {}), ...(templateLocale ? { locale: templateLocale } : {}), + ...(currency ? { currency } : {}), }); title = rendered.subject; bodyMd = rendered.text; diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 26a769f7e9c..7a0043bb8e6 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { Plugin, PluginContext } from '@objectstack/core'; +import { resolveLocalizationContext } from '@objectstack/core'; import type { IDataEngine } from '@objectstack/spec/contracts'; import { MessagingService } from './messaging-service.js'; import type { MessagingChannel } from './channel.js'; @@ -262,6 +263,24 @@ export class MessagingServicePlugin implements Plugin { } }; + // The organization's default currency (`localization.currency`), the + // code a template's `{{ amount | currency }}` hole with no argument + // formats in. Read through `resolveLocalizationContext`, the reader a + // request resolves `ExecutionContext.currency` with, so a mail and the + // record's currency field show the same currency. Probed at delivery + // time like the locale above; a failed read leaves it unset, and the + // render then refuses such a hole by name rather than guess one. + const getTenantCurrency = async (organizationId: string | undefined): Promise => { + let settings: unknown; + try { settings = ctx.getService('settings'); } catch { settings = undefined; } + try { + const localization = await resolveLocalizationContext({ ql: getData(), settings, organizationId }); + return localization.currency; + } catch { + return undefined; + } + }; + // [#24978] The `i18n` service whose catalog a keyed notification text // is rendered from — probed at delivery time, never captured at boot, // for the reasons `getDefaultTemplateLocale` above is. @@ -290,7 +309,7 @@ export class MessagingServicePlugin implements Plugin { }); if (this.options.registerInbox) { - service.registerChannel(createInboxChannel({ getData, getEmail, getDefaultTemplateLocale })); + service.registerChannel(createInboxChannel({ getData, getEmail, getDefaultTemplateLocale, getTenantCurrency })); } ctx.registerService('messaging', service); @@ -438,7 +457,7 @@ export class MessagingServicePlugin implements Plugin { ctx.hook('kernel:ready', async () => { service.registerChannelProvider('email', lazyChannelMount({ resolveTransport: getEmail, - create: () => createEmailChannel({ getEmail, getData, store: templateStore, getDefaultTemplateLocale }), + create: () => createEmailChannel({ getEmail, getData, store: templateStore, getDefaultTemplateLocale, getTenantCurrency }), announce: '[messaging] email channel registered (renders sys_notification_template; notify `template` refs resolve sys_email_template by (name, locale) — resolved per recipient: sys_user.locale, else the deployment default)', logger: ctx.logger, })); diff --git a/packages/services/service-messaging/src/template-tenant-currency.test.ts b/packages/services/service-messaging/src/template-tenant-currency.test.ts new file mode 100644 index 00000000000..24c137b43c3 --- /dev/null +++ b/packages/services/service-messaging/src/template-tenant-currency.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The notification channels hand a templated mail render its organization's + * default currency (`localization.currency`), the code a + * `{{ amount | currency }}` hole with no argument formats in. The read is the + * delivery's own organization's; with none, no `currency` key is passed and + * the render refuses such a hole (`TEMPLATE_INVALID`) instead of guessing. + */ + +import { describe, expect, it } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { createEmailChannel } from './email-channel.js'; +import { createInboxChannel } from './inbox-channel.js'; +import { NotificationTemplateStore } from './template-renderer.js'; +import type { Delivery } from './channel.js'; + +function silentCtx() { + return { logger: { info: () => {}, warn: () => {}, error: () => {} } }; +} + +function templateDelivery(channel: 'email' | 'inbox', organizationId?: string): Delivery { + return { + channel, + recipient: 'user_1', + notification: { + notificationId: 'evt_1', + topic: 'deal.won', + title: 'deal.won', + body: '', + severity: 'info', + recipients: ['user_1'], + payload: { template: 'crm.large_deal_won', templateData: { amount: 1234.5 } }, + ...(organizationId ? { organizationId } : {}), + }, + }; +} + +function fakeData() { + return { + async findOne(object: string, query: any) { + assertEngineFindOnePredicate(object, query); + return object === 'sys_user' && query?.where?.id === 'user_1' ? { email: 'ada@example.com' } : null; + }, + async find() { return []; }, + async insert(_o: string, r: any) { return { id: 'x', ...r }; }, + async update(_o: string, d: any, options: any) { assertEngineUpdateDispatch(d, options); return {}; }, + async delete(_o: string, options: any) { assertEngineDeleteDispatch(options); return {}; }, + } as any; +} + +/** The tenant default per organization, recording every read. */ +function tenantCurrencies(byOrg: Record) { + const reads: Array = []; + return { + reads, + getTenantCurrency: async (organizationId: string | undefined) => { + reads.push(organizationId); + return organizationId ? byOrg[organizationId] : undefined; + }, + }; +} + +describe('email channel: the template arm carries the organization\'s default currency', () => { + function emailDouble() { + const templated: any[] = []; + return { + templated, + service: { + async send() { return { id: 'email_row_1' }; }, + async sendTemplate(input: any) { templated.push(input); return { id: 'email_row_9', status: 'sent' }; }, + }, + }; + } + + it('reads the delivery organization\'s default and hands it to sendTemplate as `currency`', async () => { + const email = emailDouble(); + const data = fakeData(); + const tenant = tenantCurrencies({ org_apex: 'CNY', org_other: 'EUR' }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + getTenantCurrency: tenant.getTenantCurrency, + }); + const r = await ch.send(silentCtx(), templateDelivery('email', 'org_apex')); + expect(r.ok).toBe(true); + expect(tenant.reads).toEqual(['org_apex']); + expect(email.templated).toEqual([{ + template: 'crm.large_deal_won', + to: 'ada@example.com', + data: { amount: 1234.5 }, + currency: 'CNY', + organizationId: 'org_apex', + }]); + }); + + it('an organization with no default passes no `currency` key: the render then refuses such a hole', async () => { + const email = emailDouble(); + const data = fakeData(); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + getTenantCurrency: tenantCurrencies({}).getTenantCurrency, + }); + await ch.send(silentCtx(), templateDelivery('email', 'org_apex')); + expect(email.templated).toHaveLength(1); + expect(email.templated[0]).not.toHaveProperty('currency'); + }); + + it('a TEMPLATE_INVALID refusal of such a hole reaches the delivery row, graded permanent', async () => { + const data = fakeData(); + const refusal = 'TEMPLATE_INVALID: email template crm.large_deal_won (locale=en-US): the hole {{ amount | currency }} has no currency argument'; + const ch = createEmailChannel({ + getEmail: () => ({ + async send() { return { id: 'x' }; }, + async sendTemplate() { throw new Error(refusal); }, + }), + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + const r = await ch.send(silentCtx(), templateDelivery('email', 'org_apex')); + expect(r).toEqual({ ok: false, error: refusal }); + expect(ch.classifyError?.(refusal)).toBe('permanent'); + }); +}); + +describe('inbox channel: the template arm carries the organization\'s default currency', () => { + function rendererDouble() { + const calls: any[] = []; + return { + calls, + email: { + async send() { return {}; }, + async renderTemplate(input: any) { + calls.push(input); + return { subject: 'Deal won', html: '

x

', text: 'x' }; + }, + }, + }; + } + + it('reads the delivery organization\'s default and hands it to renderTemplate as `currency`', async () => { + const r = rendererDouble(); + const tenant = tenantCurrencies({ org_apex: 'JPY' }); + const ch = createInboxChannel({ getData: () => fakeData(), getEmail: () => r.email, getTenantCurrency: tenant.getTenantCurrency }); + const result = await ch.send(silentCtx(), templateDelivery('inbox', 'org_apex')); + expect(result.ok).toBe(true); + expect(tenant.reads).toEqual(['org_apex']); + expect(r.calls).toEqual([{ template: 'crm.large_deal_won', data: { amount: 1234.5 }, currency: 'JPY' }]); + }); + + it('an organization with no default passes no `currency` key', async () => { + const r = rendererDouble(); + const ch = createInboxChannel({ getData: () => fakeData(), getEmail: () => r.email, getTenantCurrency: tenantCurrencies({}).getTenantCurrency }); + await ch.send(silentCtx(), templateDelivery('inbox')); + expect(r.calls).toHaveLength(1); + expect(r.calls[0]).not.toHaveProperty('currency'); + }); +}); diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 05cd45ba9b6..6812531814c 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -158,6 +158,23 @@ export interface AutomationContext { * Presentation, not authorization: no security middleware keys on it. */ locale?: string; + /** + * The tenant default currency of the run's organization (ISO 4217, e.g. + * `CNY`): the `localization.currency` setting, read the way a currency + * field reads it. Seeded by the engine at run setup when the context does + * not already carry one (the automation plugin bridges the read to + * `@objectstack/core`'s `resolveLocalizationContext`), and kept for the + * run's life like {@link locale}: persisted with a suspended run, inherited + * by a `subflow` / `map` child. + * + * Its reader is the flow's text renderer: a `{{ amount | currency }}` hole + * with no argument formats in it. Absent (the organization has no default, + * or the engine is not bridged), such a hole refuses the node by name + * instead of formatting in a guessed currency. + * + * Presentation, not authorization: no security middleware keys on it. + */ + currency?: string; /** * Effective execution identity for the run's DATA operations, established by * the engine from {@link FlowParsed.runAs} at run setup (ADR-0049 / #1888): diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 8120a39c38c..946824843f1 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -203,6 +203,16 @@ export interface SendTemplateInput { * behavior). Calendar-day `date` holes are unaffected (tz-naive). */ timezone?: string; + /** + * The tenant default currency (ISO 4217, e.g. `CNY`) a `currency` hole with + * no argument formats in — `{{ amount | currency }}`. The caller supplies the + * organization's `localization.currency` setting, read the way a currency + * field reads it (typically the resolved `ExecutionContext.currency`). Unset + * → such a hole refuses the send with `TEMPLATE_INVALID` and nothing is + * sent: the amount is never formatted in a currency nobody chose. A hole + * that names its code (`currency:EUR`) does not read this. + */ + currency?: string; // `org` ("Tenant id for org-overlay resolution (when supported)") was REMOVED // 2026-08-25 (#11832, ADR-0049 enforce-or-remove). No implementation ever read // it — template resolution keys on `(name, locale)` only — so the key was @@ -261,6 +271,12 @@ export interface RenderTemplateInput { template: string; /** Render context — placeholders in subject/body are resolved against this object. */ data?: Record; + /** + * The tenant default currency for a `currency` hole with no argument. Same + * semantics as {@link SendTemplateInput.currency}: unset, such a hole + * refuses the render with `TEMPLATE_INVALID`. + */ + currency?: string; /** * Preferred BCP-47 locale. Resolution is the SAME ladder as * {@link SendTemplateInput.locale} — exact match, then `'en-US'`, then @@ -324,7 +340,11 @@ export interface IEmailService { * neither a literal ISO 4217 code nor a marked `@path`). It is refused * at render, as `TEMPLATE_INVALID: email template (locale=): * `, so nothing is delivered. Permanent: retrying cannot - * succeed until the template is re-published without that hole. + * succeed until the template is re-published without that hole. The same + * code refuses a `currency` hole with no argument when the call carries no + * tenant default currency ({@link SendTemplateInput.currency}); that one + * clears once the organization sets its default currency, or the hole + * names its code. */ sendTemplate(input: SendTemplateInput): Promise; @@ -350,7 +370,9 @@ export interface IEmailService { * neither a literal ISO 4217 code nor a marked `@path`). It is refused * at render, as `TEMPLATE_INVALID: email template (locale=): * `, so nothing is rendered. Permanent: retrying cannot - * succeed until the template is re-published without that hole. + * succeed until the template is re-published without that hole. The same + * code refuses a `currency` hole with no argument when the call carries no + * tenant default currency ({@link RenderTemplateInput.currency}). */ renderTemplate(input: RenderTemplateInput): Promise; diff --git a/packages/spec/src/migrations/entries/semantic/18.template-currency-no-argument-tenant-default.ts b/packages/spec/src/migrations/entries/semantic/18.template-currency-no-argument-tenant-default.ts new file mode 100644 index 00000000000..c1e72fdcc76 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.template-currency-no-argument-tenant-default.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A template `currency` hole with no argument stopped formatting in US +// dollars: it formats in the organization's default currency +// (`localization.currency`), and with none the render refuses the hole. No +// text changes, so there is nothing to rewrite; the question a conversion +// cannot answer is which currency the author meant. +export const entry: SemanticMigration = { + id: 'template-currency-no-argument-tenant-default', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: 'a template hole {{ amount | currency }} with no argument — email_template subject / bodyHtml / ' + + 'bodyText, flow text slots (notify, http, screen, end), action and bulk-action confirmText / successMessage', + replacement: 'nothing, where the amount is in the organization\'s default currency (Setup → Localization, the ' + + '`localization.currency` setting). Where the amount is in one fixed currency, name it: ' + + '`{{ 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 }}`', + reason: + 'A behaviour change with no text to rewrite. The hole used to format in US dollars whatever currency the ' + + 'amount was in, a default nobody chose. It now formats in the organization\'s default currency, the code a ' + + 'currency field and a dashboard measure already display in, and when the organization has none the render ' + + 'refuses the hole by name: a mail or a notification is not sent (TEMPLATE_INVALID), a flow node fails, and ' + + 'an action\'s confirm or success sentence is shown as written. Not losslessly convertible because the ' + + 'question is intent, not text: writing `currency:USD` into every such hole would keep the old dollars and ' + + 'defeat the change for a non-dollar organization, and only the deployment knows which amounts were meant ' + + 'to be dollars. Authoring is unchanged: the hole is accepted at save and publish as before.', + acceptanceCriteria: + 'With the organization\'s default currency set to CNY, a mail template whose subject is ' + + '`Due {{ amount | currency }}` renders `Due CN¥1,234.50` for an `en-US` row and amount 1234.5; with no ' + + 'default currency set, the same render fails with TEMPLATE_INVALID naming the hole and nothing is sent; ' + + '`{{ amount | currency:USD }}` renders `$1,234.50` either way.', +}; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index ca576807530..9ca77140320 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4906,6 +4906,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/services/service-messaging/src/template-tenant-currency.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/services/service-messaging/src/template-tenant-currency.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/services/service-messaging/src/template-tenant-currency.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-queue/src/db-queue-adapter-13993-idempotency-window-materialisation.test.ts", "verb": "delete", diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index ecfc5fa6703..f1a66d2ed9e 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -1333,6 +1333,7 @@ export class ActionRunner { private resultDialogHandler: ResultDialogHandler | null; private translate: ((key: string, options: { defaultValue: string }) => string) | null; private displayLocale: (() => string | undefined) | null; + private tenantCurrency: (() => string | undefined) | null; /** * Built-in dispatch, one entry per runnable action type. @@ -1373,6 +1374,7 @@ export class ActionRunner { this.resultDialogHandler = null; this.translate = null; this.displayLocale = null; + this.tenantCurrency = null; } /** @@ -1406,6 +1408,19 @@ export class ActionRunner { this.displayLocale = displayLocale; } + /** + * Set where the runner reads the tenant default currency (ISO 4217, the + * `localization.currency` setting) — the host's own, injected so this + * package resolves none. A `{{ amount | currency }}` hole with no argument + * in `confirmText` and `successMessage` formats in it, through the engine's + * reserved currency channel. With none installed, or none set for the + * tenant, such a sentence is not filled: it is shown as written, never with + * the amount in a guessed currency. + */ + setTenantCurrency(tenantCurrency: () => string | undefined): void { + this.tenantCurrency = tenantCurrency; + } + /** * Set a custom confirmation handler (e.g., Shadcn AlertDialog). */ @@ -1900,6 +1915,7 @@ export class ActionRunner { successCopy, { result: payload, record: this.dispatchRecord(action) }, this.displayLocale?.(), + this.tenantCurrency?.(), ) : successCopy; } @@ -1939,6 +1955,7 @@ export class ActionRunner { text, { record: this.dispatchRecord(action), params: staticParams }, this.displayLocale?.(), + this.tenantCurrency?.(), ); } @@ -3074,6 +3091,7 @@ export class ActionRunner { scoped.resultDialogHandler = this.resultDialogHandler; scoped.translate = this.translate; scoped.displayLocale = this.displayLocale; + scoped.tenantCurrency = this.tenantCurrency; return scoped; } diff --git a/ui/packages/core/src/actions/__tests__/ActionRunner.copyTemplateTenantCurrency-26939.test.ts b/ui/packages/core/src/actions/__tests__/ActionRunner.copyTemplateTenantCurrency-26939.test.ts new file mode 100644 index 00000000000..fae5f35bd61 --- /dev/null +++ b/ui/packages/core/src/actions/__tests__/ActionRunner.copyTemplateTenantCurrency-26939.test.ts @@ -0,0 +1,107 @@ +/** + * 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. + */ + +/** + * objectstack#26939 — a copy template's `{{ amount | currency }}` hole with no + * argument formats in the tenant default currency (`localization.currency`), + * which the host installs on the runner (`setTenantCurrency`) and the filler + * passes on the engine's reserved currency channel, apart from the roots. + * With none, the engine refuses the sentence and it is shown as written: the + * amount is never formatted in USD. + * + * Expected strings come from `Intl` itself, so a newer ICU cannot drift them. + */ +import { describe, it, expect, vi } from 'vitest'; +import { ActionRunner, type ActionDef } from '../ActionRunner'; +import { fillTemplateHoles } from '../actionCopyTemplate'; + +const AMOUNT = 1234.5; +const money = (tag: string, currency: string) => new Intl.NumberFormat(tag, { style: 'currency', currency }).format(AMOUNT); + +async function confirmTextFor(runner: ActionRunner, def: ActionDef): Promise { + const confirm = vi.fn().mockResolvedValue(false); + runner.setConfirmHandler(confirm); + await runner.execute(def); + expect(confirm).toHaveBeenCalledTimes(1); + return confirm.mock.calls[0][0]; +} + +describe('fillTemplateHoles: a no-argument currency hole reads the tenant default it is passed', () => { + it('formats in the tenant default, in the display locale', () => { + expect(fillTemplateHoles('{{ amount | currency }}', { amount: AMOUNT }, 'de-DE', 'CNY')).toBe(money('de-DE', 'CNY')); + }); + + it('a root named `currency` is a value, never the tenant default', () => { + const source = '{{ amount | currency }} ({{ currency }})'; + expect(fillTemplateHoles(source, { amount: AMOUNT, currency: 'EUR' })).toBe(source); + }); + + it('with no tenant default the sentence is returned as written, never in USD', () => { + const source = 'Set {{ amount | currency }}?'; + expect(fillTemplateHoles(source, { amount: AMOUNT }, 'en-US')).toBe(source); + }); + + it('a refused sentence logs the refusal, naming the hole and the remedy, once', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const source = 'Logged {{ amount | currency }}?'; + expect(fillTemplateHoles(source, { amount: AMOUNT }, 'en-US')).toBe(source); + expect(fillTemplateHoles(source, { amount: AMOUNT }, 'en-US')).toBe(source); + expect(warn).toHaveBeenCalledTimes(1); + const line = String(warn.mock.calls[0][0]); + expect(line).toContain(source); + expect(line).toContain('{{ amount | currency }}'); + expect(line).toContain('localization.currency'); + expect(line).toContain('currency:EUR'); + } finally { + warn.mockRestore(); + } + }); + + it('a hole that names its code needs no tenant default', () => { + expect(fillTemplateHoles('{{ amount | currency:EUR }}', { amount: AMOUNT }, 'en-US')).toBe(money('en-US', 'EUR')); + }); +}); + +describe('the runner fills its copy templates in the tenant default currency it was given', () => { + const CONFIRM: ActionDef = { name: 'reprice', onClick: () => {}, confirmText: 'Set {{ record.amount | currency }}?' }; + const runner = () => new ActionRunner({ objectName: 'crm_deal', record: { amount: AMOUNT } }); + + it('confirmText: the installed tenant default is the currency', async () => { + const r = runner(); + r.setTenantCurrency(() => 'CNY'); + expect(await confirmTextFor(r, CONFIRM)).toBe(`Set ${money('en-US', 'CNY')}?`); + }); + + it('read per fill, so a settings change reaches a runner built before it', async () => { + const r = runner(); + let current: string | undefined = 'CNY'; + r.setTenantCurrency(() => current); + expect(await confirmTextFor(r, CONFIRM)).toBe(`Set ${money('en-US', 'CNY')}?`); + current = 'JPY'; + expect(await confirmTextFor(r, CONFIRM)).toBe(`Set ${money('en-US', 'JPY')}?`); + }); + + it('with none installed the sentence is shown as written', async () => { + expect(await confirmTextFor(runner(), CONFIRM)).toBe('Set {{ record.amount | currency }}?'); + }); + + it('successMessage: formats `result` in the tenant default', async () => { + const r = runner(); + const toast = vi.fn(); + r.setToastHandler(toast); + r.setTenantCurrency(() => 'EUR'); + r.registerHandler('script', async () => ({ success: true, data: { total: AMOUNT } })); + await r.execute({ + type: 'script', + name: 'reprice', + successMessage: { dialect: 'template', source: 'Total {{ result.total | currency }}' }, + }); + expect(toast.mock.calls[0][0]).toBe(`Total ${money('en-US', 'EUR')}`); + }); +}); diff --git a/ui/packages/core/src/actions/actionCopyTemplate.ts b/ui/packages/core/src/actions/actionCopyTemplate.ts index edab0671e0e..c8ba4044479 100644 --- a/ui/packages/core/src/actions/actionCopyTemplate.ts +++ b/ui/packages/core/src/actions/actionCopyTemplate.ts @@ -84,12 +84,41 @@ export function hasTemplateHoles(text: string): boolean { * named `locale` (a param of that name) is a value a hole prints, not the * locale of the whole sentence (objectstack#26232). Omitted, the engine * formats in `en-US`. + * + * `currency` is the tenant default currency (`localization.currency`, the + * code `useLocalization()` holds), passed the same way and on its own reserved + * channel: a `{{ amount | currency }}` hole with no argument formats in it. + * Omitted, the engine refuses such a sentence and it is returned as written, + * like any sentence the engine refuses — never with a guessed currency. + * + * A refused sentence is shown as written, and the engine's refusal — which + * names the hole and the remedy (for a missing default: set + * `localization.currency`, or write `currency:CODE`) — goes to + * `console.warn`, once per distinct refusal, so it reaches whoever reads the + * console rather than nobody. */ export function fillTemplateHoles( source: string, roots: Record, locale?: string, + currency?: string, ): string { - const filled = templateEngine.evaluate({ dialect: 'template', source }, { extra: roots, locale }); - return filled.ok && typeof filled.value === 'string' ? filled.value : source; + const filled = templateEngine.evaluate( + { dialect: 'template', source }, + { extra: roots, locale, ...(currency ? { currency } : {}) }, + ); + if (filled.ok && typeof filled.value === 'string') return filled.value; + if (!filled.ok) warnRefusedCopy(source, filled.error.message); + return source; +} + +/** Refusals already logged, so a dialog that re-renders does not repeat one. */ +const warnedRefusals = new Set(); + +function warnRefusedCopy(source: string, refusal: string): void { + const key = `${source}\u0000${refusal}`; + if (warnedRefusals.has(key)) return; + if (warnedRefusals.size >= 200) warnedRefusals.clear(); + warnedRefusals.add(key); + console.warn(`[action copy] the sentence "${source}" is shown as written: ${refusal}`); } diff --git a/ui/packages/plugin-grid/src/__tests__/bulkConfirmTenantCurrency-26939.test.tsx b/ui/packages/plugin-grid/src/__tests__/bulkConfirmTenantCurrency-26939.test.tsx new file mode 100644 index 00000000000..38920024f3f --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/bulkConfirmTenantCurrency-26939.test.tsx @@ -0,0 +1,104 @@ +/** + * 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. + */ + +/** + * objectstack#26939 — a bulk `confirmText` hole `{{ amount | currency }}` with + * no argument formats in the tenant default currency (`localization.currency`, + * the code `useLocalization()` holds), the one every currency field reads. + * With none, the sentence is shown as written: never in USD. + * + * Two halves: the helper's `currency` argument, and the real + * `BulkActionDialog` under a real `LocalizationProvider`. + * + * Expected strings come from `Intl` itself, so a newer ICU cannot drift them. + */ +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import type { BulkActionDef, BulkActionParam } from '@object-ui/types'; + +import { BulkActionDialog } from '../components/BulkActionDialog'; +import { bulkConfirmText } from '../components/bulkConfirmText'; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +afterEach(() => cleanup()); + +const AMOUNT_VALUE = 1234.5; +const money = (tag: string, currency: string) => + new Intl.NumberFormat(tag, { style: 'currency', currency }).format(AMOUNT_VALUE); +const AMOUNT: BulkActionParam = { name: 'amount', label: 'Amount', type: 'number' }; +const SOURCE = 'Set the amount to {{ amount | currency }} on {{ count }} records?'; + +describe('bulkConfirmText formats a no-argument currency hole in the tenant default it is handed', () => { + const fill = (currency?: string) => + bulkConfirmText(SOURCE, { count: 2, params: [AMOUNT], values: { amount: AMOUNT_VALUE } }, 'en-US', currency); + + it('formats in the tenant default', () => { + expect(fill('CNY')).toBe(`Set the amount to ${money('en-US', 'CNY')} on 2 records?`); + }); + + it('with none, the sentence is shown as written', () => { + expect(fill()).toBe(SOURCE); + }); +}); + +const REPRICE: BulkActionDef = { + name: 'reprice', + label: 'Reprice', + operation: 'update', + params: [{ ...AMOUNT, default: AMOUNT_VALUE }], + confirmText: { dialect: 'template', source: SOURCE }, +}; + +function openDialog(tenantCurrency: string | undefined) { + render( + + + ({})), delete: vi.fn(async () => ({})) } as any} + open + onClose={() => {}} + /> + + , + ); +} + +/** The confirm step's sentence, read as the dialog's description `textContent`. */ +async function confirmSentence(): Promise { + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + const dialog = await screen.findByRole('dialog'); + const description = await waitFor(() => { + const el = document.getElementById(dialog.getAttribute('aria-describedby') ?? ''); + expect(el?.textContent).toMatch(/^Set the amount to /); + return el; + }); + return description?.textContent ?? null; +} + +describe('BulkActionDialog formats its confirm sentence in the tenant default currency', () => { + it('formats a no-argument currency hole in the tenant default', async () => { + openDialog('JPY'); + expect(await confirmSentence()).toBe(`Set the amount to ${money('en-US', 'JPY')} on 2 records?`); + }); + + it('shows the sentence as written when the tenant has no default', async () => { + openDialog(undefined); + expect(await confirmSentence()).toBe(SOURCE); + }); +}); diff --git a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx index 6c9fb47d733..0c0c86ec2d4 100644 --- a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx +++ b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx @@ -21,7 +21,7 @@ import { } from '@object-ui/components'; import { AlertTriangle, CheckCircle2, Loader2, XCircle } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/react'; -import { useDisplayLocale } from '@object-ui/i18n'; +import { useDisplayLocale, useLocalization } from '@object-ui/i18n'; import { getLazyFieldWidget } from '@object-ui/fields'; // The two shared allow-tables of widgets that are fed the dialog's own // in-progress values as their record — one definition each for this dialog, the @@ -165,6 +165,7 @@ export const BulkActionDialog: React.FC = ({ // renderer and the console's action runners read: the confirm sentence's // formatters render in it (objectstack#26307). ⛔ No second resolution. const displayLocale = useDisplayLocale(); + const { currency: tenantCurrency } = useLocalization(); const params = def?.params ?? []; // Effective multi-value semantics for an `update` param: an explicit @@ -415,7 +416,7 @@ export const BulkActionDialog: React.FC = ({ {step === 'confirm' && ( - {bulkConfirmText(def.confirmText, { count: rows.length, params, values, lookupLabels }, displayLocale) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })} + {bulkConfirmText(def.confirmText, { count: rows.length, params, values, lookupLabels }, displayLocale, tenantCurrency) ?? t('grid.bulk.confirmDefault', { count: rows.length, defaultValue: `This will apply to ${rows.length} record(s).` })} )} diff --git a/ui/packages/plugin-grid/src/components/bulkConfirmText.ts b/ui/packages/plugin-grid/src/components/bulkConfirmText.ts index 6754286aecf..92bdee614b8 100644 --- a/ui/packages/plugin-grid/src/components/bulkConfirmText.ts +++ b/ui/packages/plugin-grid/src/components/bulkConfirmText.ts @@ -132,13 +132,19 @@ export function bulkConfirmTemplateScope(scope: BulkConfirmScope): Record = ({ const translate = useActionRunnerTranslator(); // Its copy templates' formatters in the viewer's display locale (#26232). const displayLocale = useActionRunnerDisplayLocale(); + // Their no-argument `currency` holes in the tenant default currency. + const tenantCurrency = useActionRunnerTenantCurrency(); const runner = useMemo(() => { // Normalize the evaluator scope so predicates can use *either* flat @@ -120,6 +122,7 @@ export const ActionProvider: React.FC = ({ const r = new ActionRunner(normalizedContext); r.setTranslator(translate); r.setDisplayLocale(displayLocale); + r.setTenantCurrency(tenantCurrency); if (onConfirm) r.setConfirmHandler(onConfirm); if (onToast) r.setToastHandler(onToast); if (onModal) r.setModalHandler(onModal); diff --git a/ui/packages/react/src/context/__tests__/ActionProvider.copyTemplateTenantCurrency-26939.test.tsx b/ui/packages/react/src/context/__tests__/ActionProvider.copyTemplateTenantCurrency-26939.test.tsx new file mode 100644 index 00000000000..c0d0e77bcc5 --- /dev/null +++ b/ui/packages/react/src/context/__tests__/ActionProvider.copyTemplateTenantCurrency-26939.test.tsx @@ -0,0 +1,86 @@ +/** + * 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. + */ + +/** + * objectstack#26939 — both React owners of an `ActionRunner` install the + * tenant default currency on it (`useActionRunnerTenantCurrency`, which reads + * `useLocalization().currency`), so a copy template's `{{ x | currency }}` + * hole with no argument formats in it. With none set, the sentence is shown + * as written: never in USD. + * + * Measured by running a real action through a real provider under a real + * `I18nProvider` and `LocalizationProvider`. Expected strings come from `Intl` + * itself, so a newer ICU cannot drift them. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import type { ActionResult, ToastHandler } from '@object-ui/core'; +import { ActionProvider, useAction } from '../ActionContext'; +import { useActionRunner } from '../../hooks/useActionRunner'; + +afterEach(() => cleanup()); + +const AMOUNT = 1234.5; +const money = (currency: string) => new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(AMOUNT); +const CONFIG = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; +const SUCCEEDS = async (): Promise => ({ success: true, data: { total: AMOUNT } }); +const SUCCESS_COPY = { dialect: 'template' as const, source: 'Total {{ result.total | currency }}' }; + +function localization(currency: string | undefined) { + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +} + +describe(' — a no-argument currency hole formats in the tenant default', () => { + function providerHarness(currency: string | undefined, onToast: ToastHandler) { + const Outer = localization(currency); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + return renderHook(() => useAction(), { wrapper }); + } + + it('successMessage formats in the tenant default currency', async () => { + const onToast = vi.fn(); + const { result } = providerHarness('CNY', onToast); + await act(async () => { + await result.current.execute({ type: 'script', name: 'reprice', successMessage: SUCCESS_COPY }); + }); + expect(onToast).toHaveBeenCalledWith(`Total ${money('CNY')}`, expect.objectContaining({ type: 'success' })); + }); + + it('with no tenant default the sentence is shown as written', async () => { + const onToast = vi.fn(); + const { result } = providerHarness(undefined, onToast); + await act(async () => { + await result.current.execute({ type: 'script', name: 'reprice', successMessage: SUCCESS_COPY }); + }); + expect(onToast).toHaveBeenCalledWith(SUCCESS_COPY.source, expect.objectContaining({ type: 'success' })); + }); +}); + +describe('useActionRunner — a no-argument currency hole formats in the tenant default', () => { + it('successMessage formats in the tenant default currency', async () => { + const onToast = vi.fn(); + const { result } = renderHook(() => useActionRunner({ context: {}, onToast }), { wrapper: localization('EUR') }); + result.current.runner.registerHandler('script', SUCCEEDS); + await act(async () => { + await result.current.execute({ type: 'script', name: 'reprice', successMessage: SUCCESS_COPY }); + }); + expect(onToast).toHaveBeenCalledWith(`Total ${money('EUR')}`, expect.objectContaining({ type: 'success' })); + }); +}); diff --git a/ui/packages/react/src/context/actionRunnerTranslator.ts b/ui/packages/react/src/context/actionRunnerTranslator.ts index cfd6a30efa3..fbcaf8a8fca 100644 --- a/ui/packages/react/src/context/actionRunnerTranslator.ts +++ b/ui/packages/react/src/context/actionRunnerTranslator.ts @@ -7,7 +7,7 @@ */ import { useEffect, useRef, useState } from 'react'; -import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; +import { useDisplayLocale, useLocalization, useObjectTranslation } from '@object-ui/i18n'; /** What `ActionRunner.setTranslator` takes. */ type ActionRunnerTranslate = (key: string, options: { defaultValue: string }) => string; @@ -67,3 +67,31 @@ export function useActionRunnerDisplayLocale(): ActionRunnerDisplayLocale { const [read] = useState(() => () => latest.current); return read; } + +/** + * What `ActionRunner.setTenantCurrency` takes: the tenant default currency, + * read when a copy template is filled. + */ +type ActionRunnerTenantCurrency = () => string | undefined; + +/** + * The tenant default currency both React owners of an `ActionRunner` install + * beside the display locale, so a `{{ amount | currency }}` hole with no + * argument in an action's `confirmText` and `successMessage` formats in it. It + * is `useLocalization().currency` — the `localization.currency` setting every + * currency field and measure renderer reads; ⛔ no second copy here. Undefined + * when the tenant has none, and such a hole then does not render. + * + * Read when CALLED, by the same latest-ref pattern as + * {@link useActionRunnerTranslator}, so a settings change reaches a runner + * that was built before it. + */ +export function useActionRunnerTenantCurrency(): ActionRunnerTenantCurrency { + const { currency } = useLocalization(); + const latest = useRef(currency); + useEffect(() => { + latest.current = currency; + }); + const [read] = useState(() => () => latest.current); + return read; +} diff --git a/ui/packages/react/src/hooks/useActionRunner.ts b/ui/packages/react/src/hooks/useActionRunner.ts index 7840e99cca8..7f2dba1e1e5 100644 --- a/ui/packages/react/src/hooks/useActionRunner.ts +++ b/ui/packages/react/src/hooks/useActionRunner.ts @@ -19,7 +19,7 @@ import { type ParamCollectionHandler, type ResultDialogHandler, } from '@object-ui/core'; -import { useActionRunnerDisplayLocale, useActionRunnerTranslator } from '../context/actionRunnerTranslator.js'; +import { useActionRunnerDisplayLocale, useActionRunnerTenantCurrency, useActionRunnerTranslator } from '../context/actionRunnerTranslator.js'; export interface UseActionRunnerOptions { context?: ActionContext; @@ -73,11 +73,14 @@ export function useActionRunner( const translate = useActionRunnerTranslator(); // Its copy templates' formatters in the viewer's display locale (#26232). const displayLocale = useActionRunnerDisplayLocale(); + // Their no-argument `currency` holes in the tenant default currency. + const tenantCurrency = useActionRunnerTenantCurrency(); const runner = useMemo(() => { const r = new ActionRunner(context); r.setTranslator(translate); r.setDisplayLocale(displayLocale); + r.setTenantCurrency(tenantCurrency); if (onConfirm) r.setConfirmHandler(onConfirm); if (onToast) r.setToastHandler(onToast); if (onModal) r.setModalHandler(onModal);