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:
@@ -1 +0,0 @@
|
||||
export const BILLING_SETTINGS_URL = 'https://app.twenty.com/settings/billing';
|
||||
@@ -3,7 +3,6 @@ import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { CallToAction } from 'src/components/CallToAction';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { BILLING_SETTINGS_URL } from 'src/constants/billing-settings-url.constant';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
@@ -11,6 +10,7 @@ type BillingSubscriptionRenewingEmailProps = {
|
||||
userName: string;
|
||||
workspaceDisplayName: string | undefined;
|
||||
renewsAt: Date;
|
||||
link: string;
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export const BillingSubscriptionRenewingEmail = ({
|
||||
userName,
|
||||
workspaceDisplayName,
|
||||
renewsAt,
|
||||
link,
|
||||
locale,
|
||||
}: BillingSubscriptionRenewingEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
@@ -54,10 +55,7 @@ export const BillingSubscriptionRenewingEmail = ({
|
||||
<Trans id="No action is needed if you'd like to continue. If you'd rather not renew, you can cancel anytime before then." />
|
||||
</MainText>
|
||||
<br />
|
||||
<CallToAction
|
||||
href={BILLING_SETTINGS_URL}
|
||||
value={i18n._('Manage subscription')}
|
||||
/>
|
||||
<CallToAction href={link} value={i18n._('Manage subscription')} />
|
||||
<br />
|
||||
<br />
|
||||
</BaseEmail>
|
||||
@@ -68,6 +66,7 @@ BillingSubscriptionRenewingEmail.PreviewProps = {
|
||||
userName: 'John Doe',
|
||||
workspaceDisplayName: 'Acme Inc.',
|
||||
renewsAt: new Date('2027-07-02'),
|
||||
link: 'https://acme.twenty.com/settings/billing',
|
||||
locale: 'en',
|
||||
} as BillingSubscriptionRenewingEmailProps;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { CallToAction } from 'src/components/CallToAction';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { BILLING_SETTINGS_URL } from 'src/constants/billing-settings-url.constant';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
@@ -12,6 +11,7 @@ type BillingTrialConvertingEmailProps = {
|
||||
workspaceDisplayName: string | undefined;
|
||||
trialEndsAt: Date;
|
||||
interval: 'month' | 'year';
|
||||
link: string;
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export const BillingTrialConvertingEmail = ({
|
||||
workspaceDisplayName,
|
||||
trialEndsAt,
|
||||
interval,
|
||||
link,
|
||||
locale,
|
||||
}: BillingTrialConvertingEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
@@ -64,10 +65,7 @@ export const BillingTrialConvertingEmail = ({
|
||||
<Trans id="If Twenty is working for you, you're all set — there's nothing to do. If it's not the right fit, you can cancel in one click before then and you won't be charged." />
|
||||
</MainText>
|
||||
<br />
|
||||
<CallToAction
|
||||
href={BILLING_SETTINGS_URL}
|
||||
value={i18n._('Manage subscription')}
|
||||
/>
|
||||
<CallToAction href={link} value={i18n._('Manage subscription')} />
|
||||
<br />
|
||||
<br />
|
||||
</BaseEmail>
|
||||
@@ -79,6 +77,7 @@ BillingTrialConvertingEmail.PreviewProps = {
|
||||
workspaceDisplayName: 'Acme Inc.',
|
||||
trialEndsAt: new Date('2026-07-02'),
|
||||
interval: 'month',
|
||||
link: 'https://acme.twenty.com/settings/billing',
|
||||
locale: 'en',
|
||||
} as BillingTrialConvertingEmailProps;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { CallToAction } from 'src/components/CallToAction';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { BILLING_SETTINGS_URL } from 'src/constants/billing-settings-url.constant';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
@@ -12,6 +11,7 @@ type BillingTrialEndingEmailProps = {
|
||||
workspaceDisplayName: string | undefined;
|
||||
trialEndsAt: Date;
|
||||
dataRetentionDays: number;
|
||||
link: string;
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ export const BillingTrialEndingEmail = ({
|
||||
workspaceDisplayName,
|
||||
trialEndsAt,
|
||||
dataRetentionDays,
|
||||
link,
|
||||
locale,
|
||||
}: BillingTrialEndingEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
@@ -59,10 +60,7 @@ export const BillingTrialEndingEmail = ({
|
||||
/>
|
||||
</MainText>
|
||||
<br />
|
||||
<CallToAction
|
||||
href={BILLING_SETTINGS_URL}
|
||||
value={i18n._('Add a payment method')}
|
||||
/>
|
||||
<CallToAction href={link} value={i18n._('Add a payment method')} />
|
||||
<br />
|
||||
<br />
|
||||
</BaseEmail>
|
||||
@@ -74,6 +72,7 @@ BillingTrialEndingEmail.PreviewProps = {
|
||||
workspaceDisplayName: 'Acme Inc.',
|
||||
trialEndsAt: new Date('2026-07-02'),
|
||||
dataRetentionDays: 14,
|
||||
link: 'https://acme.twenty.com/settings/billing',
|
||||
locale: 'en',
|
||||
} as BillingTrialEndingEmailProps;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { CallToAction } from 'src/components/CallToAction';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { BILLING_SETTINGS_URL } from 'src/constants/billing-settings-url.constant';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
@@ -12,6 +11,7 @@ type WarnSuspendedWorkspaceEmailProps = {
|
||||
inactiveDaysBeforeDelete: number;
|
||||
userName: string;
|
||||
workspaceDisplayName: string | undefined;
|
||||
link: string;
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export const WarnSuspendedWorkspaceEmail = ({
|
||||
inactiveDaysBeforeDelete,
|
||||
userName,
|
||||
workspaceDisplayName,
|
||||
link,
|
||||
locale,
|
||||
}: WarnSuspendedWorkspaceEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
@@ -54,10 +55,7 @@ export const WarnSuspendedWorkspaceEmail = ({
|
||||
<Trans id="After that, the workspace and all of its data will be permanently deleted — and we won't be able to bring it back." />
|
||||
</MainText>
|
||||
<br />
|
||||
<CallToAction
|
||||
href={BILLING_SETTINGS_URL}
|
||||
value={i18n._('Reactivate workspace')}
|
||||
/>
|
||||
<CallToAction href={link} value={i18n._('Reactivate workspace')} />
|
||||
<br />
|
||||
<br />
|
||||
</BaseEmail>
|
||||
@@ -69,6 +67,7 @@ WarnSuspendedWorkspaceEmail.PreviewProps = {
|
||||
inactiveDaysBeforeDelete: 14,
|
||||
userName: 'John Doe',
|
||||
workspaceDisplayName: 'Acme Inc.',
|
||||
link: 'https://acme.twenty.com/settings/billing',
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
|
||||
+2
@@ -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],
|
||||
|
||||
+12
-1
@@ -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);
|
||||
|
||||
+20
-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,
|
||||
}),
|
||||
};
|
||||
|
||||
+14
-1
@@ -8,13 +8,15 @@ import {
|
||||
CleanSuspendedWorkspaceEmail,
|
||||
WarnSuspendedWorkspaceEmail,
|
||||
} 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 { In, Repository } from 'typeorm';
|
||||
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
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 { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
@@ -67,6 +69,7 @@ export class CleanerWorkspaceService {
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly metricsService: MetricsService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
) {
|
||||
this.inactiveDaysBeforeSoftDelete = this.twentyConfigService.get(
|
||||
'WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION',
|
||||
@@ -115,6 +118,7 @@ export class CleanerWorkspaceService {
|
||||
async sendWarningEmail(
|
||||
workspaceMember: WorkspaceMemberWorkspaceEntity,
|
||||
workspaceDisplayName: string | undefined,
|
||||
billingSettingsUrl: string,
|
||||
daysSinceInactive: number,
|
||||
) {
|
||||
const emailData = {
|
||||
@@ -122,6 +126,7 @@ export class CleanerWorkspaceService {
|
||||
inactiveDaysBeforeDelete: this.inactiveDaysBeforeSoftDelete,
|
||||
userName: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
|
||||
workspaceDisplayName: `${workspaceDisplayName}`,
|
||||
link: billingSettingsUrl,
|
||||
locale: workspaceMember.locale,
|
||||
};
|
||||
const emailTemplate = WarnSuspendedWorkspaceEmail(emailData);
|
||||
@@ -177,11 +182,19 @@ export class CleanerWorkspaceService {
|
||||
.join(', ')}']`,
|
||||
);
|
||||
|
||||
const billingSettingsUrl = this.workspaceDomainsService
|
||||
.buildWorkspaceURL({
|
||||
workspace,
|
||||
pathname: getSettingsPath(SettingsPath.Billing),
|
||||
})
|
||||
.toString();
|
||||
|
||||
if (!dryRun) {
|
||||
for (const workspaceMember of workspaceMembers) {
|
||||
await this.sendWarningEmail(
|
||||
workspaceMember,
|
||||
workspace.displayName,
|
||||
billingSettingsUrl,
|
||||
daysSinceInactive,
|
||||
);
|
||||
|
||||
|
||||
+2
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
@@ -31,6 +32,7 @@ import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace-
|
||||
EmailModule,
|
||||
BillingModule,
|
||||
MetricsModule,
|
||||
WorkspaceDomainsModule,
|
||||
],
|
||||
providers: [
|
||||
DestroyWorkspaceCommand,
|
||||
|
||||
Reference in New Issue
Block a user