Skip to content

Commit 2ba0895

Browse files
committed
wip(plugin-auth): the auth mails carry the tenant default currency through the wired email service
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzizxYz7JyC5JLW3Gmv
1 parent 063f960 commit 2ba0895

5 files changed

Lines changed: 132 additions & 5 deletions

File tree

‎.changeset/26939-currency-tenant-default.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
'@objectstack/formula': minor
33
'@objectstack/plugin-email': minor
4+
'@objectstack/plugin-auth': minor
45
'@objectstack/spec': minor
56
'@objectstack/service-messaging': minor
67
'@objectstack/service-automation': minor
@@ -22,10 +23,10 @@ Skills-docs: updated here — `content/docs/automation/email-templates.mdx`, `co
2223
- **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.
2324
- **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`.
2425
- **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.
25-
- **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; `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.
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.
2627
- **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.
2728
- **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.
2829

2930
**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 }}`.
3031

31-
**Supersedes, in the same release.** The unreleased notes of the mail `currency:@path` change (`26660-currency-code-path-formula`, `26660-currency-code-path-plugin-email`, `26660-mail-hole-grammar-formula`) say a `currency` hole with no argument still formats in US dollars. 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.
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.

‎packages/plugins/plugin-auth/src/auth-plugin.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,12 @@ describe('AuthPlugin', () => {
810810
await boot({ settings, email, i18nDefault: 'en', registerRoutes });
811811

812812
expect(mockContext.getService).toHaveBeenCalledWith('email');
813-
expect(setEmailServiceSpy).toHaveBeenCalledWith(email);
813+
// Wired wrapped (the tenant default currency on `sendTemplate`, see
814+
// mail-tenant-currency.ts): every other call reaches the transport.
815+
expect(setEmailServiceSpy).toHaveBeenCalledTimes(1);
816+
const wired = setEmailServiceSpy.mock.calls[0]![0] as { send: (input: unknown) => unknown };
817+
wired.send({ to: 'a@x.com' });
818+
expect(email.send).toHaveBeenCalledWith({ to: 'a@x.com' });
814819
expect(settings.get).toHaveBeenCalledWith('localization', 'locale', {});
815820
expect(setEmailLocaleSpy).toHaveBeenLastCalledWith('zh-CN');
816821
expect(setSmsLocaleSpy).toHaveBeenLastCalledWith('zh-CN');

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
// [#25083] `isCloudManaged`: the ONE accessor the cloud-managed doors below ask.
4-
import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core';
4+
import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveLocalizationContext, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core';
55
import type { BetterAuthOptions } from 'better-auth';
66
import {
77
AuthConfig,
@@ -23,6 +23,7 @@ import {
2323
import { isHumanUserRow, OPEN_POSTURE_VERIFICATION_OFF_WARNING } from './audience-posture.js';
2424
// [#14157] The dev-admin seed's own precondition — "does a login exist?".
2525
import { decideDevAdminSeedGate } from './dev-admin-seed-gate.js';
26+
import { withMailTenantCurrency } from './mail-tenant-currency.js';
2627
import {
2728
// ADR-0048 — the Setup/Studio/Account apps moved to their own packages
2829
// (@objectstack/{setup,studio,account}); plugin-auth no longer registers them.
@@ -1094,7 +1095,16 @@ export class AuthPlugin implements Plugin {
10941095
let emailSvc: IEmailService | undefined;
10951096
try { emailSvc = ctx.getService<IEmailService>('email'); } catch { emailSvc = undefined; }
10961097
if (emailSvc) {
1097-
this.authManager.setEmailService(emailSvc);
1098+
// A mail template's no-argument `currency` hole formats in the
1099+
// tenant default: added once here for the six auth mail call sites
1100+
// (see mail-tenant-currency.ts), read the way a request reads it.
1101+
this.authManager.setEmailService(withMailTenantCurrency(emailSvc, async (organizationId) => {
1102+
let settings: unknown;
1103+
try { settings = ctx.getService('settings'); } catch { settings = undefined; }
1104+
let ql: unknown;
1105+
try { ql = ctx.getService<IDataEngine>('data'); } catch { ql = undefined; }
1106+
return (await resolveLocalizationContext({ ql, settings, organizationId })).currency;
1107+
}));
10981108
ctx.logger.info('Auth: email service wired (transactional mail enabled)');
10991109
} else {
11001110
// No email service. The verification / password-reset callbacks now
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The auth mails reach `sendTemplate` through `withMailTenantCurrency`, which
5+
* adds the organization's default currency to a call that names none, so a
6+
* template's no-argument `{{ amount | currency }}` hole formats in it rather
7+
* than being refused for a default the organization has.
8+
*/
9+
10+
import { describe, expect, it, vi } from 'vitest';
11+
import type { IEmailService, SendTemplateInput } from '@objectstack/spec/contracts';
12+
import { withMailTenantCurrency } from './mail-tenant-currency.js';
13+
14+
function emailDouble() {
15+
const sendTemplate = vi.fn(async (_input: SendTemplateInput) => ({ id: 'mail_1', status: 'sent' as const }));
16+
const send = vi.fn(async () => ({ id: 'mail_2', status: 'sent' as const }));
17+
return { sendTemplate, send, service: { send, sendTemplate } as unknown as IEmailService };
18+
}
19+
20+
describe('withMailTenantCurrency', () => {
21+
it('adds the named organization\'s default to a call that names none', async () => {
22+
const email = emailDouble();
23+
const reads: Array<string | undefined> = [];
24+
const wired = withMailTenantCurrency(email.service, async (org) => { reads.push(org); return org === 'org_cn' ? 'CNY' : undefined; });
25+
await wired.sendTemplate({ template: 'auth.invitation', to: 'a@x.com', organizationId: 'org_cn' });
26+
expect(reads).toEqual(['org_cn']);
27+
expect(email.sendTemplate).toHaveBeenCalledWith({ template: 'auth.invitation', to: 'a@x.com', organizationId: 'org_cn', currency: 'CNY' });
28+
});
29+
30+
it('identity mail, which names no organization, reads the deployment-wide default', async () => {
31+
const email = emailDouble();
32+
const reads: Array<string | undefined> = [];
33+
const wired = withMailTenantCurrency(email.service, async (org) => { reads.push(org); return 'EUR'; });
34+
await wired.sendTemplate({ template: 'auth.verify_email', to: 'a@x.com', identityMail: true });
35+
expect(reads).toEqual([undefined]);
36+
expect(email.sendTemplate.mock.calls[0]![0]).toMatchObject({ currency: 'EUR' });
37+
});
38+
39+
it('a call that names a currency keeps it, and the default is not read', async () => {
40+
const email = emailDouble();
41+
const resolve = vi.fn(async () => 'CNY');
42+
await withMailTenantCurrency(email.service, resolve).sendTemplate({ template: 't', to: 'a@x.com', currency: 'JPY' });
43+
expect(resolve).not.toHaveBeenCalled();
44+
expect(email.sendTemplate.mock.calls[0]![0]).toMatchObject({ currency: 'JPY' });
45+
});
46+
47+
it.each([
48+
['no default', async () => undefined],
49+
['a failed read', async () => { throw new Error('settings unavailable'); }],
50+
])('with %s it adds no `currency` key, and the render then refuses such a hole', async (_label, resolve) => {
51+
const email = emailDouble();
52+
await withMailTenantCurrency(email.service, resolve).sendTemplate({ template: 't', to: 'a@x.com' });
53+
expect(email.sendTemplate.mock.calls[0]![0]).not.toHaveProperty('currency');
54+
});
55+
56+
it('every other member is the service\'s own', async () => {
57+
const email = emailDouble();
58+
await withMailTenantCurrency(email.service, async () => 'CNY').send({ to: 'a@x.com' } as never);
59+
expect(email.send).toHaveBeenCalledWith({ to: 'a@x.com' });
60+
});
61+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The auth mails' tenant default currency.
5+
*
6+
* A mail template's `{{ amount | currency }}` hole with no argument formats in
7+
* the organization's default currency (`localization.currency`), which the
8+
* caller of `sendTemplate` hands over as `currency`; with none the render
9+
* refuses the hole (`TEMPLATE_INVALID`). The auth mails
10+
* (verification, password reset, invitations, magic link, notices) are sent
11+
* from six call sites, so the default is added once, here, on the email
12+
* service the plugin wires into the AuthManager, rather than at each site: a
13+
* call that names no `currency` gets the default of the organization it names
14+
* (`organizationId`), or the deployment-wide setting for identity mail, which
15+
* names none. A call that names one keeps it, and a failed or empty read adds
16+
* nothing.
17+
*/
18+
19+
import type { IEmailService, SendTemplateInput } from '@objectstack/spec/contracts';
20+
21+
/** Answers the default currency of an organization, or of the deployment when none is named. */
22+
export type MailTenantCurrencyResolver = (organizationId: string | undefined) => Promise<string | undefined>;
23+
24+
async function withCurrency(input: SendTemplateInput, resolve: MailTenantCurrencyResolver): Promise<SendTemplateInput> {
25+
if (input.currency !== undefined) return input;
26+
let currency: string | undefined;
27+
try {
28+
currency = await resolve(input.organizationId);
29+
} catch {
30+
currency = undefined;
31+
}
32+
return typeof currency === 'string' && currency !== '' ? { ...input, currency } : input;
33+
}
34+
35+
/**
36+
* The email service, with the tenant default currency added to every
37+
* `sendTemplate` call that carries none. Every other member is the service's
38+
* own.
39+
*/
40+
export function withMailTenantCurrency(email: IEmailService, resolve: MailTenantCurrencyResolver): IEmailService {
41+
return new Proxy(email, {
42+
get(target, prop) {
43+
if (prop === 'sendTemplate') {
44+
return async (input: SendTemplateInput) => target.sendTemplate(await withCurrency(input, resolve));
45+
}
46+
const value = Reflect.get(target, prop, target);
47+
return typeof value === 'function' ? value.bind(target) : value;
48+
},
49+
});
50+
}

0 commit comments

Comments
 (0)