fix(billing): link billing emails to the workspace subdomain (#22401)

## Problem

Billing and workspace-suspension emails hardcoded a
`BILLING_SETTINGS_URL` constant pointing at
`https://app.twenty.com/settings/billing`. A user in
`myworkspace.twenty.com` therefore received a CTA that bounced through
the central `app` domain instead of landing on their own workspace.
Those cross-subdomain redirects are unreliable, so it's better to link
straight to the workspace.

The invite, password-reset and email-verification emails already do this
correctly by building a workspace-specific URL server-side with
`WorkspaceDomainsService.buildWorkspaceURL(...)`; the billing/suspension
senders had the `workspace` entity in scope but never used it.

## Fix

Build the billing settings URL server-side and pass it into the
templates as a `link` prop, mirroring the existing pattern:

- **Templates** now take a `link` prop instead of the hardcoded
constant: `billing-trial-ending`, `billing-trial-converting`,
`billing-subscription-renewing`, `warn-suspended-workspace`.
- **`BillingReminderService`** and **`CleanerWorkspaceService`** build
`buildWorkspaceURL({ workspace, pathname:
getSettingsPath(SettingsPath.Billing) })` and thread it through.
- Wired `WorkspaceDomainsModule` into both NestJS modules; deleted the
now-unused `billing-settings-url.constant.ts`; updated the reminder unit
test.

This also fixes **self-hosted** deployments, which previously got the
same wrong hardcoded `app.twenty.com` link.

### Intentionally unchanged

- `clean-suspended-workspace` keeps its central-domain "start a new
workspace" CTA — that workspace is already deleted, so its subdomain no
longer resolves.
- `password-update-notify` (not a billing email) still uses
`getBaseUrl()`; the workspace entity isn't readily loaded there. Can be
a follow-up.

## Testing

Extended `billing-reminder.service.spec.ts` to assert the
workspace-specific `link` is threaded into the email. Note: local
`typecheck`/tests could not be run because the sandbox proxy repeatedly
dropped `yarn install` mid-fetch; the diff was reviewed line-by-line and
import paths verified against the actual `twenty-shared` exports and
module wiring. CI will provide the authoritative check.

https://claude.ai/code/session_01QsgNd4SWdcRkPFyrnCgj2b

---
_Generated by [Claude
Code](https://claude.ai/code/session_01QsgNd4SWdcRkPFyrnCgj2b)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22401?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-01 16:28:36 +02:00
committed by GitHub
parent 72bcc78e36
commit b8a3399230
10 changed files with 66 additions and 24 deletions
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingReminderCronCommand } from 'src/engine/core-modules/billing/reminders/crons/commands/billing-reminder.cron.command';
import { BillingReminderService } from 'src/engine/core-modules/billing/reminders/services/billing-reminder.service';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
@@ -15,6 +16,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
EmailModule,
UserModule,
UserVarsModule,
WorkspaceDomainsModule,
],
providers: [BillingReminderService, BillingReminderCronCommand],
exports: [BillingReminderService, BillingReminderCronCommand],
@@ -78,6 +78,11 @@ const buildService = ({
const emailService = { send: emailSend };
const i18nService = { getI18nInstance: () => ({ _: () => 'subject' }) };
const twentyConfigService = { get: (key: string) => CONFIG[key] };
const workspaceDomainsService = {
buildWorkspaceURL: jest.fn(
() => new URL('https://acme.twenty.com/settings/billing'),
),
};
const service = new BillingReminderService(
// oxlint-disable-next-line typescript/no-explicit-any
@@ -94,6 +99,8 @@ const buildService = ({
emailService as any,
// oxlint-disable-next-line typescript/no-explicit-any
i18nService as any,
// oxlint-disable-next-line typescript/no-explicit-any
workspaceDomainsService as any,
);
return { service, emailSend, userVarsSet };
@@ -121,7 +128,11 @@ describe('BillingReminderService', () => {
expect(BillingTrialEndingEmail).toHaveBeenCalledTimes(1);
expect(BillingTrialEndingEmail).toHaveBeenCalledWith(
expect.objectContaining({ trialEndsAt: trialEnd, dataRetentionDays: 14 }),
expect.objectContaining({
trialEndsAt: trialEnd,
dataRetentionDays: 14,
link: 'https://acme.twenty.com/settings/billing',
}),
);
expect(BillingTrialConvertingEmail).not.toHaveBeenCalled();
expect(emailSend).toHaveBeenCalledTimes(1);
@@ -11,7 +11,8 @@ import {
BillingTrialConvertingEmail,
BillingTrialEndingEmail,
} from 'twenty-emails';
import { isDefined } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Between, Repository } from 'typeorm';
@@ -22,6 +23,7 @@ import {
BILLING_RENEWAL_REMINDER_SENT_KEY,
BILLING_TRIAL_REMINDER_SENT_KEY,
} from 'src/engine/core-modules/billing/reminders/constants/billing-reminder-sent-keys.constant';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -52,6 +54,7 @@ export class BillingReminderService {
private readonly userVarsService: UserVarsService,
private readonly emailService: EmailService,
private readonly i18nService: I18nService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
) {}
async processReminders(): Promise<void> {
@@ -235,10 +238,18 @@ export class BillingReminderService {
const workspaceMembers =
await this.userService.loadWorkspaceMembers(workspace);
const billingSettingsUrl = this.workspaceDomainsService
.buildWorkspaceURL({
workspace,
pathname: getSettingsPath(SettingsPath.Billing),
})
.toString();
for (const workspaceMember of workspaceMembers) {
await this.sendReminderEmail({
workspaceMember,
workspaceDisplayName: workspace.displayName,
billingSettingsUrl,
reminder,
});
}
@@ -258,10 +269,12 @@ export class BillingReminderService {
private async sendReminderEmail({
workspaceMember,
workspaceDisplayName,
billingSettingsUrl,
reminder,
}: {
workspaceMember: WorkspaceMemberWorkspaceEntity;
workspaceDisplayName: string | undefined;
billingSettingsUrl: string;
reminder: BillingReminderEmail;
}): Promise<void> {
if (!isDefined(workspaceMember.userEmail)) {
@@ -276,6 +289,7 @@ export class BillingReminderService {
reminder,
userName,
workspaceDisplayName,
billingSettingsUrl,
locale,
});
@@ -297,11 +311,13 @@ export class BillingReminderService {
reminder,
userName,
workspaceDisplayName,
billingSettingsUrl,
locale,
}: {
reminder: BillingReminderEmail;
userName: string;
workspaceDisplayName: string | undefined;
billingSettingsUrl: string;
locale: WorkspaceMemberWorkspaceEntity['locale'];
}) {
switch (reminder.type) {
@@ -315,6 +331,7 @@ export class BillingReminderService {
dataRetentionDays: this.twentyConfigService.get(
'WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION',
),
link: billingSettingsUrl,
locale,
}),
};
@@ -326,6 +343,7 @@ export class BillingReminderService {
workspaceDisplayName,
trialEndsAt: reminder.trialEndsAt,
interval: reminder.interval,
link: billingSettingsUrl,
locale,
}),
};
@@ -336,6 +354,7 @@ export class BillingReminderService {
userName,
workspaceDisplayName,
renewsAt: reminder.renewsAt,
link: billingSettingsUrl,
locale,
}),
};