Skip to content

Commit 79ed6f7

Browse files
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 (#27249)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBPgpqhXpoVLpqQEw4Pge7jf)_ Fixes #26939 Ruling-ref: 6464202770 Clause-②: no (narrowing) 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 skills/** edit ## 维护者速读 - 改了什么:模板里不带参数的 `{{ amount | currency }}` 不再按美元格式化,改为按组织默认币种(`localization.currency`)格式化;邮件(含认证邮件)、流程文本槽、动作/批量动作文案三种模板方言全部生效。没有默认币种时,邮件发送/渲染以 TEMPLATE_INVALID 拒绝(不发送),流程节点失败,动作文案按原文显示。 - 为什么改:裁决 6464202770(#27119 Q2 = X)。原先默认美元是没人选过的默认值,非美元组织的金额被印成 `$`。 - 风险与回滚:BREAKING(语义变化,ADR-0087 登记 D3 条目 `template-currency-no-argument-tenant-default`)。没有设置默认币种的组织,带无参 currency 的邮件会进死信(permanent)。编写期不变,保存/发布照常接受。回滚即 revert。 - 先看:`packages/formula/src/template-engine.ts` 的 `currencyDefaultRefusal` 与 currency 格式化器;`packages/plugins/plugin-email/src/template-engine.ts` 的 render;`packages/services/service-automation/src/run-resume.ts` 的币种种入;`packages/services/service-messaging/src/messaging-service-plugin.ts` 的 `getTenantCurrency`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-email exec vitest run src/template-currency-tenant-default.render.test.ts`,预期全部通过(CNY 渲染为 `CN¥1,234.50`,无默认币种时 TEMPLATE_INVALID 且不发送)。 - 需要你做的:无,席位落地。#27217 已合入并已合并进本分支;设置读取故障被当作永久失败的残留问题已立卡 #27257。 ## What changes **One rule, every dialect.** A `currency` hole with no argument formats in the organization's default currency, the `localization.currency` setting that a currency field and a dashboard measure already display in. With no default, the render refuses the hole by name and names the remedy. Nothing formats in USD by default. **One sentence.** `currencyDefaultRefusal(hole, currency, dialect)` (`@objectstack/formula`, exported) returns `null` for a three-capital code and otherwise the refusal. The mail dialect's remedy also names `currency:@currency_code` (#26937, merged in here); the in-app dialect's names the literal form only, because it refuses `@path`. **How each host hands the default over.** Every server-side read goes through `resolveLocalizationContext` (`@objectstack/core`), the read a request resolves `ExecutionContext.currency` with; there is no second resolution. - `@objectstack/formula`: `EvalContext.currency`, a reserved channel like `locale`, never bound as a root. `evaluate` refuses the sentence (`kind: 'runtime'`) when a no-argument hole meets no default; `formatValue(…, { currency })` throws a `RangeError` with the same sentence. Compile and `validateExpression` are unchanged. - `@objectstack/plugin-email`: `RenderOptions.currency`; `render()` judges a no-argument hole before reading the amount and throws `TEMPLATE_INVALID`. `EmailService` passes `input.currency` through. - `@objectstack/spec` (contracts): `SendTemplateInput.currency`, `RenderTemplateInput.currency`, `AutomationContext.currency`, with docs; the `TEMPLATE_INVALID` docs on `sendTemplate` / `renderTemplate` name the case. - `@objectstack/service-messaging`: the email and inbox channels take `getTenantCurrency(organizationId)` and pass `currency` on the template arm only when the delivery's organization has one. The plugin wires it to `resolveLocalizationContext`. A refusal is graded permanent, like every `TEMPLATE_INVALID`. - `@objectstack/service-automation`: `AutomationEngine.setTenantCurrencyResolver`, wired by the plugin. `resolveRunContext` seeds `AutomationContext.currency` once at run setup, when the context carries none; a failed or empty read leaves it unset. A resumed segment whose persisted context carries none (a run parked before this change) is seeded the same way in `resolveDecisionSegmentContext`, on a copy, so the persisted `run.context` stays as it parked. `renderFlowTextSlot` renders the authored text before it blames a translation: when both throw (a no-argument hole with no default refuses both alike), the authored refusal is the answer and no translation warning is logged. A subflow and a map child inherit it through the context spread, and a suspended run persists it with the context. `renderTextSlot` takes the currency, and every text slot passes `context.currency`: notify `title` / `message` / `actionUrl`, http `url` and headers, screen texts, end message. - `@objectstack/plugin-auth`: the six auth mail call sites (verification, password reset, account and organization invitations, magic link, notices) reach `sendTemplate` through `withMailTenantCurrency` (`mail-tenant-currency.ts`), which the plugin wraps once around the email service it wires into the AuthManager. A call that names no `currency` gets the default of the organization it names (`organizationId`) or, for identity mail, the deployment-wide setting, read through `resolveLocalizationContext`; a call that names one keeps it, and a failed or empty read adds nothing. - `@object-ui/core` / `@object-ui/react` / `@object-ui/plugin-grid`: `ActionRunner.setTenantCurrency`, installed by `ActionProvider` and `useActionRunner` from `useLocalization().currency` (latest-ref, like the display locale); `fillTemplateHoles` and `bulkConfirmText` pass it to the engine. `BulkActionDialog` reads it from `useLocalization`. A sentence the engine refuses is shown as written, and `fillTemplateHoles` now `console.warn`s the refusal once per distinct sentence, naming the hole and the remedy. - Migration: semantic entry `18.template-currency-no-argument-tenant-default` (ADR-0087 D3: intent, not text, so no conversion). - Docs: email-templates (a paragraph; #26937's "no argument formats in US dollars" sentence now points at it), flows (a bullet), formulas (a sentence). **Census, left as they are.** These show the spelling `{{ record.amount | currency }}` and claim nothing about USD, so they stay: the spec describes in `flow-text-slot-template.ts`, `io-node-config.zod.ts` (notify, http, screen), the notify and screen node config descriptions, the semantic entry `18.flow-text-slot-single-brace-refused`, and the formula invalid-hole message. The lint fixtures and the ui `flowTextSlots` fixture stay valid because authoring is unchanged. No seeded template in the repo uses a no-argument `currency` hole. ## Tests - `packages/formula/src/template-currency-tenant-default.test.ts`: CNY in `en-US` (`CN¥1,234.50`) and `zh-CN` (`¥1,234.50`), EUR, JPY; a literal code ignores the default; a root named `currency` is a value, never the default; null renders empty; refusal when the default is unset, empty, `usd` or `dollars`, whatever the value, and for the whole sentence when one hole of several has no argument; compile and `validateExpression` stay ok; `formatValue`; `currencyDefaultRefusal` per dialect. - `packages/formula/src/template-formatters.test.ts`, `template-hole-grammar.test.ts`: re-pinned from USD to a supplied default. - `packages/plugins/plugin-email/src/template-currency-tenant-default.render.test.ts`: `renderTemplate` / `sendTemplate` over all three faces, double- and triple-braced; with no default or `usd`, `^TEMPLATE_INVALID: email template contract.overdue (locale=en-US): the hole {{ planned_amount | currency }}`, naming `localization.currency`, `currency:EUR` and `currency:@currency_code`, and nothing sent; the face renderers throw; a `currency:@path` hole and a template with no no-argument hole need no default. - `packages/services/service-messaging/src/template-tenant-currency.test.ts`: the email and inbox channels pass the delivery organization's default (exact input pinned), pass no `currency` key without one, and grade the refusal permanent. - `packages/services/service-automation/src/builtin/notify-currency-tenant-default.test.ts`: the resolver is read once per run with the tenant id and the title renders `Won CN¥1,234.50`; a context that already carries a currency keeps it; with no resolver, no default or a throwing read, the run fails naming the hole and nothing is emitted; `renderTextSlot` pins. - `packages/services/service-automation/src/builtin/notify-currency-tenant-default.test.ts` also: a run parked with no currency resumes on a second engine with a resolver, renders `Won CN¥1,234.50`, and the persisted context stays without `currency` (red with the resume seeding removed). `end-node-refusal-translation.test.ts`: a refusal both texts share fails the run naming the hole with no translation warning (red on the old `renderFlowTextSlot`), and with `currency: 'CNY'` the zh-CN translation renders in it. - `packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts`: the named organization's default is added; identity mail reads the deployment-wide one; a named currency is kept and nothing is read; no default or a failed read adds no key; other members delegate. `auth-plugin.test.ts`: the wired service is the wrapper and still reaches the transport. - ui: `ActionRunner.copyTemplateTenantCurrency-26939`, `ActionProvider.copyTemplateTenantCurrency-26939`, `bulkConfirmTenantCurrency-26939`: confirm and success copy in the tenant default; with none, the sentence is shown as written and the refusal is logged once. ## Acceptance notes - **Display surfaces with no default.** An action's confirm or success sentence with a no-argument hole and no tenant default is shown as written, the existing fallback for any sentence the engine refuses, and the refusal is logged. A flow `screen` text refuses the node like every other text slot. The contract review kept this split (comment 6469598248, open question answered A). - **Mail with no default dead-letters.** A messaging delivery whose organization has no default currency records the `TEMPLATE_INVALID` as permanent, so it is not retried. Setting the default and re-sending is the remedy. - **Residual: a settings read fault is graded permanent (#27257).** `resolveLocalizationContext` answers `currency: undefined` both when an organization has no default and when its `sys_setting` read fails (the failure is memoized for a short TTL), so within that window a templated delivery with a no-argument hole dead-letters as `TEMPLATE_INVALID`. Telling the two apart needs a core seam, filed as #27257. - **Pending 26660 changesets.** `.changeset/26660-*.md` (from #26937, unreleased) still say "no argument formats in US dollars". `check-empty-changeset` refuses an edit to a changeset the merge base carries, so this PR's changeset says it supersedes them in the same release instead. - **#27217 (#26948)** landed first and is merged in; its unknown-formatter refusal and this PR's no-default refusal sit side by side in `render()`. ## Verification Head `9727b5111`, which carries main at `9ff8154cf` (#27217 included) and the review's should-fixes S1, S3, S4 and S5. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `2ba0895c2`: 124 derived families, each run with its exit recorded; `--ran`: 124 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. Five spec-dist families first answered exit 3 because a spec source edit (the `9727b5111` fix below) had made `packages/spec/dist` stale; after `@objectstack/spec` build they reran exit 0. - Under `os-verify-lock` on `2ba0895c2`: the build of every changed package and dogfood's dependencies; `@objectstack/spec` `check:generated` exit 0; test and typecheck exit 0 for `@objectstack/plugin-auth`, `@objectstack/service-automation`, `@objectstack/plugin-email`, `@objectstack/formula`, `@object-ui/core`, `@object-ui/react` and `@object-ui/plugin-grid`; `@objectstack/dogfood` typecheck exit 0 and the full dogfood suite: 348 files passed, 3 skipped (2804 tests passed). - CI's Test Core shard 2 on `2ba0895c2` failed one spec repo pin, `currency-mode-family-closure.pin.test.ts` rule B: the new `RenderTemplateInput.currency` doc sat within five lines of the `timezone` doc's ADR-0053 citation. `9727b5111` moves that member beside `data`. Under `os-verify-lock` on `9727b5111`: `@objectstack/spec` build, `check:generated`, `test:repo`, `test` and typecheck, all exit 0. - Ablations: the S3 cold-resume test is red with the resume seeding removed, and the S5 test is red on the old `renderFlowTextSlot`; both are green restored. - ESLint on the changed files: 0 errors. ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. ESLint on the changed files: 0 errors; `pnpm lint` is CI's run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 block is in Chinese by the repo's dispatch rule.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6eaf404 commit 79ed6f7

48 files changed

Lines changed: 1609 additions & 59 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/formula': minor
3+
'@objectstack/plugin-email': minor
4+
'@objectstack/plugin-auth': minor
5+
'@objectstack/spec': minor
6+
'@objectstack/service-messaging': minor
7+
'@objectstack/service-automation': minor
8+
'@object-ui/core': minor
9+
'@object-ui/react': minor
10+
'@object-ui/plugin-grid': minor
11+
---
12+
13+
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)
14+
15+
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.
16+
Metadata-protocol: narrowed
17+
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.
18+
19+
<!-- adr-0087: registered template-currency-no-argument-tenant-default -->
20+
21+
**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.
22+
23+
- **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.
24+
- **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`.
25+
- **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.
26+
- **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.
27+
- **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.
28+
- **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.
29+
30+
**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 }}`.
31+
32+
**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.

‎content/docs/automation/email-templates.mdx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ second format — and pass the finished value as its own variable.
111111
argument is either a literal ISO 4217 code in three capital letters
112112
(`currency:EUR`) or, in a mail template only, `@` followed by a path into the
113113
send's data whose value is such a code (`currency:@currency_code`). `currency`
114-
with no argument formats in US dollars.
114+
with no argument formats in the tenant default currency (below).
115115

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

142+
**`currency` with no argument formats in the tenant default currency.**
143+
`{{ amount | currency }}` formats in the organization's default currency, the
144+
`localization.currency` setting (Setup → Localization) that currency fields and
145+
dashboards read too. The notification channels hand it to every templated
146+
send; code that calls `sendTemplate` or `renderTemplate` itself passes it as
147+
`currency`. When no default currency reaches the render, the render fails with
148+
`TEMPLATE_INVALID`, naming the template, its locale and the hole, and nothing is
149+
sent: the amount is never formatted in US dollars or any other guess. Set the
150+
organization's default currency, or name the currency in the hole: a literal
151+
code (`currency:EUR`) or a marked path to it (`currency:@currency_code`).
152+
142153
**Where a hole may sit in `bodyHtml`.** The renderer's escaping is right for
143154
element text and for a quoted attribute value that holds plain text, so those
144155
are the supported placements for `{{path}}`. A hole anywhere else — inside a

‎content/docs/automation/flows.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,12 @@ other character is literal.
484484
formatter makes the rest explicit: `upper`, `lower`, `trim`, `number[:digits]`,
485485
`currency[:CODE]`, `percent[:digits]`, `date[:short|medium|long|iso]`,
486486
`datetime[:…]`, `truncate[:n]`, `default:'fallback'`, `json`.
487+
- `currency` with no code formats in the run's organization's default currency
488+
(the `localization.currency` setting, which currency fields and dashboards
489+
read too), read once when the run starts. When the organization has none, the
490+
node fails with a refusal that names the hole, and no fault edge routes it:
491+
nothing is sent with the amount in a guessed currency. Name the code
492+
(`currency:EUR`) where the amount's currency is fixed.
487493
- A hole holds **no logic** — `{{ a + b }}` or `{{ round(x) }}` is refused at
488494
`objectstack validate` and `registerFlow`. Compute the value first, in an
489495
`assignment` node's CEL value envelope, and write the variable as a hole.

‎content/docs/data-modeling/formulas.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ tmpl`Deal {{record.name}} — {{record.amount | currency}} closes {{record.close
8282
Formatters: `currency[:CODE]`, `number[:decimals]`, `percent[:decimals]`,
8383
`date[:short|long|iso]`, `datetime[:…]`, `upper`, `lower`, `trim`, `truncate:N`,
8484
`default:'…'`, `json`. Single-brace `{x}` is **not** a valid template hole.
85+
`currency` with no code formats in the organization's default currency (the
86+
`localization.currency` setting, the one currency fields and dashboards read);
87+
when the organization has none, the render refuses the hole by name instead of
88+
guessing a currency. Write `currency:EUR` where the amount's currency is fixed.
8589

8690
At **input** time you may write a bare string for shorthand — the spec
8791
transforms it into the right envelope based on the field type. The compiled

‎content/docs/kernel/runtime-services/email-service.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@ services.email.sendTemplate(input: SendTemplateInput): Promise<SendEmailResult>
3131
- `TEMPLATE_NOT_FOUND`
3232
- `TEMPLATE_INACTIVE`
3333
- `MISSING_VARIABLES`
34-
- `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.
34+
- `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.

‎packages/formula/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export {
7171
CURRENCY_ARGUMENT_RULE,
7272
currencyCodePath,
7373
currencyCodeValueRefusal,
74+
// A `currency` hole with no argument formats in the tenant default currency the
75+
// caller supplies; with none, every renderer refuses it with this sentence.
76+
currencyDefaultRefusal,
7477
} from './template-engine';
7578
export type { TemplateHoleDialect, TemplateHoleGrammarRefusal, TemplateHoleGrammarRule } from './template-engine';
7679
// The mail template hole grammar — one copy, read by `@objectstack/plugin-email`'s
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A `currency` hole with no argument formats in the tenant default currency
5+
* (`localization.currency`), handed in on the context's reserved `currency`
6+
* channel — the code a currency field and a dashboard measure read. With no
7+
* tenant default the hole is refused at render, by name: nothing formats in
8+
* USD, or in any currency nobody chose.
9+
*
10+
* Authoring is unchanged: the no-argument form compiles and validates, because
11+
* whether a tenant default exists is a fact of the render, not of the text.
12+
*/
13+
import { describe, expect, it } from 'vitest';
14+
15+
import { currencyDefaultRefusal } from './index';
16+
import { formatValue, templateEngine } from './template-engine';
17+
import { validateExpression } from './validate';
18+
19+
const evaluate = (source: string, record: Record<string, unknown>, extra: { locale?: string; currency?: string } = {}) =>
20+
templateEngine.evaluate<string>({ dialect: 'template', source }, { record, locale: 'en-US', ...extra });
21+
22+
describe('no argument: the tenant default currency', () => {
23+
it.each([
24+
['CNY', 'en-US', 'CN¥1,234.50'],
25+
['CNY', 'zh-CN', '¥1,234.50'],
26+
['EUR', 'en-US', '€1,234.50'],
27+
['JPY', 'en-US', '¥1,235'],
28+
])('formats in %s for a %s reader', (currency, locale, expected) => {
29+
expect(evaluate('{{ record.amount | currency }}', { amount: 1234.5 }, { locale, currency })).toEqual({
30+
ok: true,
31+
value: expected,
32+
});
33+
});
34+
35+
it('a hole that names its code does not read the tenant default', () => {
36+
expect(evaluate('{{ record.amount | currency:EUR }}', { amount: 1234.5 }, { currency: 'CNY' })).toEqual({
37+
ok: true,
38+
value: '€1,234.50',
39+
});
40+
});
41+
42+
it('a root named `currency` is a value, never the tenant default', () => {
43+
const r = templateEngine.evaluate<string>(
44+
{ dialect: 'template', source: '{{ amount | currency }} {{ currency }}' },
45+
{ extra: { amount: 1234.5, currency: 'EUR' }, locale: 'en-US' },
46+
);
47+
expect(r.ok).toBe(false);
48+
});
49+
50+
it('formats an empty value as empty, like every formatter', () => {
51+
expect(evaluate('[{{ record.amount | currency }}]', { amount: null }, { currency: 'CNY' })).toEqual({
52+
ok: true,
53+
value: '[]',
54+
});
55+
});
56+
});
57+
58+
describe('no argument and no tenant default: refused at render, by name', () => {
59+
it.each([
60+
['unset', undefined],
61+
['empty', ''],
62+
['lowercase', 'usd'],
63+
['not a code', 'dollars'],
64+
])('refuses the sentence when the tenant default is %s', (_label, currency) => {
65+
const r = evaluate('Due {{ record.amount | currency }} today', { amount: 1234.5 }, currency === undefined ? {} : { currency });
66+
expect(r.ok).toBe(false);
67+
if (r.ok) return;
68+
expect(r.error.kind).toBe('runtime');
69+
expect(r.error.message).toContain('{{ record.amount | currency }}');
70+
expect(r.error.message).toContain('localization.currency');
71+
expect(r.error.message).toContain('currency:EUR');
72+
expect(r.error.message).not.toContain('$');
73+
});
74+
75+
it('refuses whatever the value: the refusal does not depend on the data', () => {
76+
const r = evaluate('{{ record.amount | currency }}', { amount: null });
77+
expect(r.ok).toBe(false);
78+
});
79+
80+
it('refuses the whole sentence, even when another hole names its code', () => {
81+
const r = evaluate('{{ record.a | currency:EUR }} / {{ record.b | currency }}', { a: 1, b: 2 });
82+
expect(r.ok).toBe(false);
83+
if (r.ok) return;
84+
expect(r.error.message).toContain('{{ record.b | currency }}');
85+
});
86+
87+
it('a sentence with no no-argument currency hole does not need a tenant default', () => {
88+
expect(evaluate('{{ record.amount | currency:EUR }} {{ record.amount | number:1 }}', { amount: 1234.5 })).toEqual({
89+
ok: true,
90+
value: '€1,234.50 1,234.5',
91+
});
92+
});
93+
});
94+
95+
describe('authoring is unchanged', () => {
96+
it('the no-argument form compiles and validates: a tenant default is a fact of the render', () => {
97+
expect(templateEngine.compile('Due {{ record.amount | currency }}').ok).toBe(true);
98+
const v = validateExpression('template', { dialect: 'template', source: 'Due {{ record.amount | currency }}' });
99+
expect(v.ok).toBe(true);
100+
});
101+
});
102+
103+
describe('formatValue (the mail renderer reads it)', () => {
104+
it('formats no argument in the tenant default it is handed', () => {
105+
expect(formatValue('currency', 1234.5, undefined, { locale: 'zh-CN', currency: 'CNY' })).toBe('¥1,234.50');
106+
});
107+
108+
it('throws without one, whatever the value, and never formats in USD', () => {
109+
expect(() => formatValue('currency', 1234.5, undefined, { locale: 'en-US' })).toThrow(RangeError);
110+
expect(() => formatValue('currency', 1234.5, undefined, { locale: 'en-US' })).toThrow(/localization\.currency/);
111+
expect(() => formatValue('currency', null, undefined)).toThrow(RangeError);
112+
});
113+
});
114+
115+
describe('currencyDefaultRefusal', () => {
116+
it('is null for a tenant default in three capitals, and the sentence otherwise', () => {
117+
expect(currencyDefaultRefusal('{{ x | currency }}', 'CNY')).toBeNull();
118+
const refusal = currencyDefaultRefusal('{{ x | currency }}', undefined);
119+
expect(refusal).toContain('{{ x | currency }}');
120+
expect(refusal).toContain('Setup → Localization');
121+
expect(refusal).not.toContain('@');
122+
});
123+
124+
it('names the marked-path form in the mail dialect only', () => {
125+
expect(currencyDefaultRefusal('{{ x | currency }}', 'EUR', 'mail')).toBeNull();
126+
expect(currencyDefaultRefusal('{{ x | currency }}', undefined, 'mail')).toContain('`currency:@currency_code`');
127+
});
128+
});

0 commit comments

Comments
 (0)