diff --git a/packages/twenty-emails/src/constants/billing-settings-url.constant.ts b/packages/twenty-emails/src/constants/billing-settings-url.constant.ts
new file mode 100644
index 0000000000..08092a8be0
--- /dev/null
+++ b/packages/twenty-emails/src/constants/billing-settings-url.constant.ts
@@ -0,0 +1 @@
+export const BILLING_SETTINGS_URL = 'https://app.twenty.com/settings/billing';
diff --git a/packages/twenty-emails/src/emails/billing-subscription-renewing.email.tsx b/packages/twenty-emails/src/emails/billing-subscription-renewing.email.tsx
new file mode 100644
index 0000000000..b1946f20a6
--- /dev/null
+++ b/packages/twenty-emails/src/emails/billing-subscription-renewing.email.tsx
@@ -0,0 +1,74 @@
+import { Trans } from '@lingui/react';
+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';
+
+type BillingSubscriptionRenewingEmailProps = {
+ userName: string;
+ workspaceDisplayName: string | undefined;
+ renewsAt: Date;
+ locale: keyof typeof APP_LOCALES;
+};
+
+// Sent 7 days before a yearly subscription renews. Goal: never let a large annual
+// charge be a surprise. This is both fair to the customer and expected by auto-renewal
+// laws in several regions. Monthly subscriptions intentionally get no renewal reminders.
+export const BillingSubscriptionRenewingEmail = ({
+ userName,
+ workspaceDisplayName,
+ renewsAt,
+ locale,
+}: BillingSubscriptionRenewingEmailProps) => {
+ const i18n = createI18nInstance(locale);
+ const formattedDate = i18n.date(renewsAt, {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ });
+
+ return (
+
+
+
+ {userName?.length > 1 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ );
+};
+
+BillingSubscriptionRenewingEmail.PreviewProps = {
+ userName: 'John Doe',
+ workspaceDisplayName: 'Acme Inc.',
+ renewsAt: new Date('2027-07-02'),
+ locale: 'en',
+} as BillingSubscriptionRenewingEmailProps;
+
+export default BillingSubscriptionRenewingEmail;
diff --git a/packages/twenty-emails/src/emails/billing-trial-converting.email.tsx b/packages/twenty-emails/src/emails/billing-trial-converting.email.tsx
new file mode 100644
index 0000000000..894f7e2fca
--- /dev/null
+++ b/packages/twenty-emails/src/emails/billing-trial-converting.email.tsx
@@ -0,0 +1,85 @@
+import { Trans } from '@lingui/react';
+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';
+
+type BillingTrialConvertingEmailProps = {
+ userName: string;
+ workspaceDisplayName: string | undefined;
+ trialEndsAt: Date;
+ interval: 'month' | 'year';
+ locale: keyof typeof APP_LOCALES;
+};
+
+// Sent 7 days before a trial WITH a credit card ends, i.e. before the first charge.
+// Goal: be transparent and fair — no surprise charge. The user can cancel in one click
+// before the date if Twenty is not the right fit. This is intentionally not a dark pattern.
+export const BillingTrialConvertingEmail = ({
+ userName,
+ workspaceDisplayName,
+ trialEndsAt,
+ interval,
+ locale,
+}: BillingTrialConvertingEmailProps) => {
+ const i18n = createI18nInstance(locale);
+ const formattedDate = i18n.date(trialEndsAt, {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ });
+
+ return (
+
+
+
+ {userName?.length > 1 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {interval === 'year' ? (
+ }}
+ />
+ ) : (
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+BillingTrialConvertingEmail.PreviewProps = {
+ userName: 'John Doe',
+ workspaceDisplayName: 'Acme Inc.',
+ trialEndsAt: new Date('2026-07-02'),
+ interval: 'month',
+ locale: 'en',
+} as BillingTrialConvertingEmailProps;
+
+export default BillingTrialConvertingEmail;
diff --git a/packages/twenty-emails/src/emails/billing-trial-ending.email.tsx b/packages/twenty-emails/src/emails/billing-trial-ending.email.tsx
new file mode 100644
index 0000000000..d6f980f5da
--- /dev/null
+++ b/packages/twenty-emails/src/emails/billing-trial-ending.email.tsx
@@ -0,0 +1,80 @@
+import { Trans } from '@lingui/react';
+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';
+
+type BillingTrialEndingEmailProps = {
+ userName: string;
+ workspaceDisplayName: string | undefined;
+ trialEndsAt: Date;
+ dataRetentionDays: number;
+ locale: keyof typeof APP_LOCALES;
+};
+
+// Sent the day before a trial WITHOUT a credit card ends. Goal: get the user to
+// add a payment method so they keep their workspace and data. No card on file means
+// there is no surprise charge risk, only a data-loss risk, so the framing is helpful.
+export const BillingTrialEndingEmail = ({
+ userName,
+ workspaceDisplayName,
+ trialEndsAt,
+ dataRetentionDays,
+ locale,
+}: BillingTrialEndingEmailProps) => {
+ const i18n = createI18nInstance(locale);
+ const formattedDate = i18n.date(trialEndsAt, {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ });
+
+ return (
+
+
+
+ {userName?.length > 1 ? (
+
+ ) : (
+
+ )}
+
+
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+BillingTrialEndingEmail.PreviewProps = {
+ userName: 'John Doe',
+ workspaceDisplayName: 'Acme Inc.',
+ trialEndsAt: new Date('2026-07-02'),
+ dataRetentionDays: 14,
+ locale: 'en',
+} as BillingTrialEndingEmailProps;
+
+export default BillingTrialEndingEmail;
diff --git a/packages/twenty-emails/src/emails/clean-suspended-workspace.email.tsx b/packages/twenty-emails/src/emails/clean-suspended-workspace.email.tsx
index 05d850702e..3b86723689 100644
--- a/packages/twenty-emails/src/emails/clean-suspended-workspace.email.tsx
+++ b/packages/twenty-emails/src/emails/clean-suspended-workspace.email.tsx
@@ -23,31 +23,31 @@ export const CleanSuspendedWorkspaceEmail = ({
return (
-
+
{userName?.length > 1 ? (
-
+
) : (
)}
}}
/>
-
+
-
+
diff --git a/packages/twenty-emails/src/emails/warn-suspended-workspace.email.tsx b/packages/twenty-emails/src/emails/warn-suspended-workspace.email.tsx
index 17ff8ae692..7bfb618674 100644
--- a/packages/twenty-emails/src/emails/warn-suspended-workspace.email.tsx
+++ b/packages/twenty-emails/src/emails/warn-suspended-workspace.email.tsx
@@ -3,6 +3,7 @@ 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';
@@ -28,37 +29,34 @@ export const WarnSuspendedWorkspaceEmail = ({
return (
-
+
{userName?.length > 1 ? (
-
+
) : (
)}
}}
/>
-
+
diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts
index f6ce3e8a78..404beef6e8 100644
--- a/packages/twenty-emails/src/index.ts
+++ b/packages/twenty-emails/src/index.ts
@@ -1,4 +1,7 @@
export type { JSONContent } from '@tiptap/core';
+export * from './emails/billing-subscription-renewing.email';
+export * from './emails/billing-trial-converting.email';
+export * from './emails/billing-trial-ending.email';
export * from './emails/clean-suspended-workspace.email';
export * from './emails/password-reset-link.email';
export * from './emails/password-update-notify.email';
diff --git a/packages/twenty-server/src/database/commands/cron-register-all.command.ts b/packages/twenty-server/src/database/commands/cron-register-all.command.ts
index ca1b1fc18b..e85424e796 100644
--- a/packages/twenty-server/src/database/commands/cron-register-all.command.ts
+++ b/packages/twenty-server/src/database/commands/cron-register-all.command.ts
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { StaleRegistrationCleanupCronCommand } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/commands/stale-registration-cleanup.cron.command';
import { ApplicationVersionCheckCronCommand } from 'src/engine/core-modules/application/application-upgrade/crons/commands/application-version-check.cron.command';
+import { BillingReminderCronCommand } from 'src/engine/core-modules/billing/reminders/crons/commands/billing-reminder.cron.command';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
import { EventLogCleanupCronCommand } from 'src/engine/core-modules/event-logs/cleanup/commands/event-log-cleanup.cron.command';
import { RotateSigningKeysCronCommand } from 'src/engine/core-modules/jwt/crons/commands/rotate-signing-keys.cron.command';
@@ -64,6 +65,7 @@ export class CronRegisterAllCommand extends CommandRunner {
private readonly marketplaceCatalogSyncCronCommand: MarketplaceCatalogSyncCronCommand,
private readonly applicationVersionCheckCronCommand: ApplicationVersionCheckCronCommand,
private readonly staleRegistrationCleanupCronCommand: StaleRegistrationCleanupCronCommand,
+ private readonly billingReminderCronCommand: BillingReminderCronCommand,
private readonly twentyConfigService: TwentyConfigService,
) {
super();
@@ -80,6 +82,8 @@ export class CronRegisterAllCommand extends CommandRunner {
'MARKETPLACE_CATALOG_SYNC_CRON_ENABLED',
);
+ const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
+
const allCommands = [
{
name: 'MessagingMessagesImport',
@@ -179,6 +183,11 @@ export class CronRegisterAllCommand extends CommandRunner {
name: 'StaleRegistrationCleanup',
command: this.staleRegistrationCleanupCronCommand,
},
+ {
+ name: 'BillingReminder',
+ command: this.billingReminderCronCommand,
+ isEnabled: isBillingEnabled,
+ },
];
let successCount = 0;
diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts
index dcc12ab671..060bd295fa 100644
--- a/packages/twenty-server/src/database/commands/database-command.module.ts
+++ b/packages/twenty-server/src/database/commands/database-command.module.ts
@@ -22,6 +22,7 @@ import { StaleRegistrationCleanupModule } from 'src/engine/core-modules/applicat
import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module';
+import { BillingReminderModule } from 'src/engine/core-modules/billing/reminders/billing-reminder.module';
import { EnterpriseKeyValidationCronCommand } from 'src/engine/core-modules/enterprise/cron/command/enterprise-key-validation.cron.command';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { EventLogCleanupModule } from 'src/engine/core-modules/event-logs/cleanup/event-log-cleanup.module';
@@ -76,6 +77,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
WorkspaceCleanerModule,
WorkspaceMigrationModule,
TrashCleanupModule,
+ BillingReminderModule,
CodeInterpreterSessionCleanupModule,
PublicDomainModule,
EventLogCleanupModule,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/reminders/billing-reminder.module.ts b/packages/twenty-server/src/engine/core-modules/billing/reminders/billing-reminder.module.ts
new file mode 100644
index 0000000000..8b268c24cf
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/reminders/billing-reminder.module.ts
@@ -0,0 +1,22 @@
+import { Module } from '@nestjs/common';
+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 { 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';
+import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([BillingSubscriptionEntity, WorkspaceEntity]),
+ EmailModule,
+ UserModule,
+ UserVarsModule,
+ ],
+ providers: [BillingReminderService, BillingReminderCronCommand],
+ exports: [BillingReminderService, BillingReminderCronCommand],
+})
+export class BillingReminderModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/reminders/constants/billing-reminder-sent-keys.constant.ts b/packages/twenty-server/src/engine/core-modules/billing/reminders/constants/billing-reminder-sent-keys.constant.ts
new file mode 100644
index 0000000000..a037e5ee06
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/reminders/constants/billing-reminder-sent-keys.constant.ts
@@ -0,0 +1,7 @@
+// Workspace-level user vars used to make the billing reminder cron idempotent.
+// The stored value is the ISO boundary date (trialEnd / currentPeriodEnd) we last
+// sent a reminder for, so a yearly renewal reminder fires again next period while the
+// daily cron never sends twice for the same boundary.
+export const BILLING_TRIAL_REMINDER_SENT_KEY = 'BILLING_TRIAL_REMINDER_SENT';
+export const BILLING_RENEWAL_REMINDER_SENT_KEY =
+ 'BILLING_RENEWAL_REMINDER_SENT';
diff --git a/packages/twenty-server/src/engine/core-modules/billing/reminders/constants/billing-reminder.cron-pattern.constant.ts b/packages/twenty-server/src/engine/core-modules/billing/reminders/constants/billing-reminder.cron-pattern.constant.ts
new file mode 100644
index 0000000000..68322e2442
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/reminders/constants/billing-reminder.cron-pattern.constant.ts
@@ -0,0 +1 @@
+export const BILLING_REMINDER_CRON_PATTERN = '0 8 * * *'; // Every day at 08:00 UTC
diff --git a/packages/twenty-server/src/engine/core-modules/billing/reminders/crons/billing-reminder.cron.job.ts b/packages/twenty-server/src/engine/core-modules/billing/reminders/crons/billing-reminder.cron.job.ts
new file mode 100644
index 0000000000..572d2fa79e
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/reminders/crons/billing-reminder.cron.job.ts
@@ -0,0 +1,19 @@
+import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
+import { BILLING_REMINDER_CRON_PATTERN } from 'src/engine/core-modules/billing/reminders/constants/billing-reminder.cron-pattern.constant';
+import { BillingReminderService } from 'src/engine/core-modules/billing/reminders/services/billing-reminder.service';
+import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
+import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
+
+@Processor(MessageQueue.cronQueue)
+export class BillingReminderCronJob {
+ constructor(
+ private readonly billingReminderService: BillingReminderService,
+ ) {}
+
+ @Process(BillingReminderCronJob.name)
+ @SentryCronMonitor(BillingReminderCronJob.name, BILLING_REMINDER_CRON_PATTERN)
+ async handle(): Promise {
+ await this.billingReminderService.processReminders();
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/reminders/crons/commands/billing-reminder.cron.command.ts b/packages/twenty-server/src/engine/core-modules/billing/reminders/crons/commands/billing-reminder.cron.command.ts
new file mode 100644
index 0000000000..8aee4b1307
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/reminders/crons/commands/billing-reminder.cron.command.ts
@@ -0,0 +1,31 @@
+import { Command, CommandRunner } from 'nest-commander';
+
+import { BILLING_REMINDER_CRON_PATTERN } from 'src/engine/core-modules/billing/reminders/constants/billing-reminder.cron-pattern.constant';
+import { BillingReminderCronJob } from 'src/engine/core-modules/billing/reminders/crons/billing-reminder.cron.job';
+import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
+import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
+
+@Command({
+ name: 'cron:billing:reminder',
+ description:
+ 'Starts a cron job to send trial-ending and subscription-renewal reminder emails',
+})
+export class BillingReminderCronCommand extends CommandRunner {
+ constructor(
+ @InjectMessageQueue(MessageQueue.cronQueue)
+ private readonly messageQueueService: MessageQueueService,
+ ) {
+ super();
+ }
+
+ async run(): Promise {
+ await this.messageQueueService.addCron({
+ jobName: BillingReminderCronJob.name,
+ data: undefined,
+ options: {
+ repeat: { pattern: BILLING_REMINDER_CRON_PATTERN },
+ },
+ });
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/billing/reminders/services/__tests__/billing-reminder.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/reminders/services/__tests__/billing-reminder.service.spec.ts
new file mode 100644
index 0000000000..dfead99c9d
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/reminders/services/__tests__/billing-reminder.service.spec.ts
@@ -0,0 +1,251 @@
+import { addDays } from 'date-fns';
+import {
+ BillingSubscriptionRenewingEmail,
+ BillingTrialConvertingEmail,
+ BillingTrialEndingEmail,
+} from 'twenty-emails';
+
+import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
+import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
+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 { BillingReminderService } from 'src/engine/core-modules/billing/reminders/services/billing-reminder.service';
+
+jest.mock('@react-email/render', () => ({
+ render: jest.fn().mockResolvedValue(''),
+}));
+jest.mock('twenty-emails', () => ({
+ BillingTrialEndingEmail: jest.fn(),
+ BillingTrialConvertingEmail: jest.fn(),
+ BillingSubscriptionRenewingEmail: jest.fn(),
+}));
+
+const CONFIG: Record = {
+ IS_BILLING_ENABLED: true,
+ BILLING_TRIAL_WITHOUT_CREDIT_CARD_REMINDER_DAYS_BEFORE: 1,
+ BILLING_TRIAL_WITH_CREDIT_CARD_REMINDER_DAYS_BEFORE: 7,
+ BILLING_SUBSCRIPTION_RENEWAL_REMINDER_DAYS_BEFORE: 7,
+ BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS: 30,
+ BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS: 7,
+ WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION: 14,
+ EMAIL_FROM_NAME: 'Twenty',
+ EMAIL_FROM_ADDRESS: 'noreply@twenty.com',
+};
+
+const buildService = ({
+ trialingSubscriptions = [],
+ renewingSubscriptions = [],
+ alreadySentBoundary,
+}: {
+ // oxlint-disable-next-line typescript/no-explicit-any
+ trialingSubscriptions?: any[];
+ // oxlint-disable-next-line typescript/no-explicit-any
+ renewingSubscriptions?: any[];
+ alreadySentBoundary?: string;
+}) => {
+ const emailSend = jest.fn().mockResolvedValue(undefined);
+ const userVarsSet = jest.fn().mockResolvedValue(undefined);
+
+ const billingSubscriptionRepository = {
+ find: jest
+ .fn()
+ .mockImplementation(({ where }) =>
+ where.status === SubscriptionStatus.Trialing
+ ? trialingSubscriptions
+ : renewingSubscriptions,
+ ),
+ };
+ const workspaceRepository = {
+ findOne: jest
+ .fn()
+ .mockResolvedValue({ id: 'workspace-1', displayName: 'Acme Inc.' }),
+ };
+ const userService = {
+ loadWorkspaceMembers: jest.fn().mockResolvedValue([
+ {
+ name: { firstName: 'John', lastName: 'Doe' },
+ locale: 'en',
+ userEmail: 'john@acme.com',
+ },
+ ]),
+ };
+ const userVarsService = {
+ get: jest.fn().mockResolvedValue(alreadySentBoundary),
+ set: userVarsSet,
+ };
+ const emailService = { send: emailSend };
+ const i18nService = { getI18nInstance: () => ({ _: () => 'subject' }) };
+ const twentyConfigService = { get: (key: string) => CONFIG[key] };
+
+ const service = new BillingReminderService(
+ // oxlint-disable-next-line typescript/no-explicit-any
+ twentyConfigService as any,
+ // oxlint-disable-next-line typescript/no-explicit-any
+ billingSubscriptionRepository as any,
+ // oxlint-disable-next-line typescript/no-explicit-any
+ workspaceRepository as any,
+ // oxlint-disable-next-line typescript/no-explicit-any
+ userService as any,
+ // oxlint-disable-next-line typescript/no-explicit-any
+ userVarsService as any,
+ // oxlint-disable-next-line typescript/no-explicit-any
+ emailService as any,
+ // oxlint-disable-next-line typescript/no-explicit-any
+ i18nService as any,
+ );
+
+ return { service, emailSend, userVarsSet };
+};
+
+describe('BillingReminderService', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('sends the add-a-card email the day before a no-credit-card trial ends', async () => {
+ const trialEnd = addDays(new Date(), 1);
+ const { service, emailSend } = buildService({
+ trialingSubscriptions: [
+ {
+ workspaceId: 'workspace-1',
+ status: SubscriptionStatus.Trialing,
+ interval: SubscriptionInterval.Month,
+ trialStart: addDays(new Date(), -6),
+ trialEnd,
+ billingCustomer: { hasPaymentMethod: false },
+ },
+ ],
+ });
+
+ await service.processReminders();
+
+ expect(BillingTrialEndingEmail).toHaveBeenCalledTimes(1);
+ expect(BillingTrialEndingEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ trialEndsAt: trialEnd, dataRetentionDays: 14 }),
+ );
+ expect(BillingTrialConvertingEmail).not.toHaveBeenCalled();
+ expect(emailSend).toHaveBeenCalledTimes(1);
+ });
+
+ it('sends the upcoming-charge email 7 days before a credit-card trial converts', async () => {
+ const trialEnd = addDays(new Date(), 7);
+ const { service, userVarsSet } = buildService({
+ trialingSubscriptions: [
+ {
+ workspaceId: 'workspace-1',
+ status: SubscriptionStatus.Trialing,
+ interval: SubscriptionInterval.Month,
+ trialStart: addDays(new Date(), -23),
+ trialEnd,
+ billingCustomer: { hasPaymentMethod: true },
+ },
+ ],
+ });
+
+ await service.processReminders();
+
+ expect(BillingTrialConvertingEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ trialEndsAt: trialEnd, interval: 'month' }),
+ );
+ expect(BillingTrialEndingEmail).not.toHaveBeenCalled();
+ expect(userVarsSet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: BILLING_TRIAL_REMINDER_SENT_KEY,
+ value: trialEnd.toISOString(),
+ }),
+ );
+ });
+
+ it('classifies a card-on-file trial as converting even when hasPaymentMethod and trialStart are not yet synced', async () => {
+ const trialEnd = addDays(new Date(), 5);
+ const { service } = buildService({
+ trialingSubscriptions: [
+ {
+ workspaceId: 'workspace-1',
+ status: SubscriptionStatus.Trialing,
+ interval: SubscriptionInterval.Month,
+ trialStart: null,
+ trialEnd,
+ createdAt: addDays(new Date(), -25),
+ billingCustomer: { hasPaymentMethod: null },
+ },
+ ],
+ });
+
+ await service.processReminders();
+
+ expect(BillingTrialConvertingEmail).toHaveBeenCalled();
+ expect(BillingTrialEndingEmail).not.toHaveBeenCalled();
+ });
+
+ it('does not send twice for the same boundary (idempotent)', async () => {
+ const trialEnd = addDays(new Date(), 7);
+ const { service, emailSend, userVarsSet } = buildService({
+ trialingSubscriptions: [
+ {
+ workspaceId: 'workspace-1',
+ status: SubscriptionStatus.Trialing,
+ interval: SubscriptionInterval.Month,
+ trialStart: addDays(new Date(), -23),
+ trialEnd,
+ billingCustomer: { hasPaymentMethod: true },
+ },
+ ],
+ alreadySentBoundary: trialEnd.toISOString(),
+ });
+
+ await service.processReminders();
+
+ expect(BillingTrialConvertingEmail).not.toHaveBeenCalled();
+ expect(emailSend).not.toHaveBeenCalled();
+ expect(userVarsSet).not.toHaveBeenCalled();
+ });
+
+ it('sends a renewal reminder 7 days before a yearly subscription renews', async () => {
+ const renewsAt = addDays(new Date(), 7);
+ const { service, userVarsSet } = buildService({
+ renewingSubscriptions: [
+ {
+ workspaceId: 'workspace-1',
+ status: SubscriptionStatus.Active,
+ interval: SubscriptionInterval.Year,
+ cancelAtPeriodEnd: false,
+ currentPeriodEnd: renewsAt,
+ },
+ ],
+ });
+
+ await service.processReminders();
+
+ expect(BillingSubscriptionRenewingEmail).toHaveBeenCalledWith(
+ expect.objectContaining({ renewsAt }),
+ );
+ expect(userVarsSet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: BILLING_RENEWAL_REMINDER_SENT_KEY,
+ value: renewsAt.toISOString(),
+ }),
+ );
+ });
+
+ it('does nothing when billing is disabled', async () => {
+ const { service, emailSend } = buildService({
+ trialingSubscriptions: [
+ {
+ workspaceId: 'workspace-1',
+ status: SubscriptionStatus.Trialing,
+ interval: SubscriptionInterval.Month,
+ trialStart: addDays(new Date(), -6),
+ trialEnd: addDays(new Date(), 1),
+ billingCustomer: { hasPaymentMethod: false },
+ },
+ ],
+ });
+ CONFIG.IS_BILLING_ENABLED = false;
+
+ await service.processReminders();
+
+ expect(emailSend).not.toHaveBeenCalled();
+ CONFIG.IS_BILLING_ENABLED = true;
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/billing/reminders/services/billing-reminder.service.ts b/packages/twenty-server/src/engine/core-modules/billing/reminders/services/billing-reminder.service.ts
new file mode 100644
index 0000000000..b052933958
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/billing/reminders/services/billing-reminder.service.ts
@@ -0,0 +1,344 @@
+/* @license Enterprise */
+
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { msg } from '@lingui/core/macro';
+import { render } from '@react-email/render';
+import { addDays, differenceInCalendarDays } from 'date-fns';
+import {
+ BillingSubscriptionRenewingEmail,
+ BillingTrialConvertingEmail,
+ BillingTrialEndingEmail,
+} from 'twenty-emails';
+import { isDefined } from 'twenty-shared/utils';
+import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
+import { Between, Repository } from 'typeorm';
+
+import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
+import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
+import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
+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 { 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';
+import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
+import { UserService } from 'src/engine/core-modules/user/services/user.service';
+import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
+
+// Reminders the cron can send. A given trial gets exactly one of "ending" / "converting".
+type BillingReminderEmail =
+ | { type: 'trial-ending'; trialEndsAt: Date }
+ | { type: 'trial-converting'; trialEndsAt: Date; interval: 'month' | 'year' }
+ | { type: 'subscription-renewing'; renewsAt: Date };
+
+@Injectable()
+export class BillingReminderService {
+ private readonly logger = new Logger(BillingReminderService.name);
+
+ constructor(
+ private readonly twentyConfigService: TwentyConfigService,
+ // Billing reminders run as a cross-workspace cron, so no workspaceId is in scope.
+ // eslint-disable-next-line twenty/prefer-workspace-scoped-repository
+ @InjectRepository(BillingSubscriptionEntity)
+ private readonly billingSubscriptionRepository: Repository,
+ @InjectRepository(WorkspaceEntity)
+ private readonly workspaceRepository: Repository,
+ private readonly userService: UserService,
+ private readonly userVarsService: UserVarsService,
+ private readonly emailService: EmailService,
+ private readonly i18nService: I18nService,
+ ) {}
+
+ async processReminders(): Promise {
+ if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
+ return;
+ }
+
+ const now = new Date();
+
+ await this.processTrialReminders(now);
+ await this.processRenewalReminders(now);
+ }
+
+ private async processTrialReminders(now: Date): Promise {
+ const withoutCardDaysBefore = this.twentyConfigService.get(
+ 'BILLING_TRIAL_WITHOUT_CREDIT_CARD_REMINDER_DAYS_BEFORE',
+ );
+ const withCardDaysBefore = this.twentyConfigService.get(
+ 'BILLING_TRIAL_WITH_CREDIT_CARD_REMINDER_DAYS_BEFORE',
+ );
+ const windowEnd = addDays(
+ now,
+ Math.max(withoutCardDaysBefore, withCardDaysBefore) + 1,
+ );
+
+ const trialingSubscriptions = await this.billingSubscriptionRepository.find(
+ {
+ where: {
+ status: SubscriptionStatus.Trialing,
+ trialEnd: Between(now, windowEnd),
+ },
+ relations: ['billingCustomer'],
+ },
+ );
+
+ for (const subscription of trialingSubscriptions) {
+ const trialEnd = subscription.trialEnd;
+
+ if (!isDefined(trialEnd)) {
+ continue;
+ }
+
+ const daysUntilTrialEnd = differenceInCalendarDays(trialEnd, now);
+
+ if (daysUntilTrialEnd < 0) {
+ continue;
+ }
+
+ const isWithCardTrial = this.isWithCreditCardTrial(subscription);
+
+ let reminder: BillingReminderEmail | undefined;
+
+ if (isWithCardTrial && daysUntilTrialEnd <= withCardDaysBefore) {
+ reminder = {
+ type: 'trial-converting',
+ trialEndsAt: trialEnd,
+ interval:
+ subscription.interval === SubscriptionInterval.Year
+ ? 'year'
+ : 'month',
+ };
+ } else if (
+ !isWithCardTrial &&
+ daysUntilTrialEnd <= withoutCardDaysBefore
+ ) {
+ reminder = { type: 'trial-ending', trialEndsAt: trialEnd };
+ }
+
+ if (!isDefined(reminder)) {
+ continue;
+ }
+
+ await this.sendReminderIfNotAlreadySent({
+ workspaceId: subscription.workspaceId,
+ sentKey: BILLING_TRIAL_REMINDER_SENT_KEY,
+ boundary: trialEnd,
+ reminder,
+ });
+ }
+ }
+
+ private async processRenewalReminders(now: Date): Promise {
+ const renewalDaysBefore = this.twentyConfigService.get(
+ 'BILLING_SUBSCRIPTION_RENEWAL_REMINDER_DAYS_BEFORE',
+ );
+ const windowEnd = addDays(now, renewalDaysBefore + 1);
+
+ // Only yearly subscriptions get a renewal reminder; monthly ones would be noise.
+ const renewingSubscriptions = await this.billingSubscriptionRepository.find(
+ {
+ where: {
+ status: SubscriptionStatus.Active,
+ interval: SubscriptionInterval.Year,
+ cancelAtPeriodEnd: false,
+ currentPeriodEnd: Between(now, windowEnd),
+ },
+ },
+ );
+
+ for (const subscription of renewingSubscriptions) {
+ const renewsAt = subscription.currentPeriodEnd;
+
+ const daysUntilRenewal = differenceInCalendarDays(renewsAt, now);
+
+ if (daysUntilRenewal < 0 || daysUntilRenewal > renewalDaysBefore) {
+ continue;
+ }
+
+ await this.sendReminderIfNotAlreadySent({
+ workspaceId: subscription.workspaceId,
+ sentKey: BILLING_RENEWAL_REMINDER_SENT_KEY,
+ boundary: renewsAt,
+ reminder: { type: 'subscription-renewing', renewsAt },
+ });
+ }
+ }
+
+ private isWithCreditCardTrial(
+ subscription: BillingSubscriptionEntity,
+ ): boolean {
+ if (subscription.billingCustomer?.hasPaymentMethod === true) {
+ return true;
+ }
+
+ if (!isDefined(subscription.trialEnd)) {
+ return false;
+ }
+
+ // Fallback when the payment-method flag isn't synced yet: a with-credit-card trial
+ // is longer than a no-credit-card one, so the trial duration disambiguates. Fall back
+ // to createdAt when trialStart is missing so this still holds during sync gaps —
+ // otherwise a real card-on-file trial could be misread as no-card and wrongly told
+ // "no card will be charged" right before it is actually charged.
+ const withoutCardTrialDurationDays = this.twentyConfigService.get(
+ 'BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS',
+ );
+ const trialStartedAt = subscription.trialStart ?? subscription.createdAt;
+
+ return (
+ differenceInCalendarDays(subscription.trialEnd, trialStartedAt) >
+ withoutCardTrialDurationDays
+ );
+ }
+
+ private async sendReminderIfNotAlreadySent({
+ workspaceId,
+ sentKey,
+ boundary,
+ reminder,
+ }: {
+ workspaceId: string;
+ sentKey:
+ | typeof BILLING_TRIAL_REMINDER_SENT_KEY
+ | typeof BILLING_RENEWAL_REMINDER_SENT_KEY;
+ boundary: Date;
+ reminder: BillingReminderEmail;
+ }): Promise {
+ try {
+ const boundaryValue = boundary.toISOString();
+
+ const alreadySentBoundary = await this.userVarsService.get({
+ workspaceId,
+ key: sentKey,
+ });
+
+ if (alreadySentBoundary === boundaryValue) {
+ return;
+ }
+
+ const workspace = await this.workspaceRepository.findOne({
+ where: {
+ id: workspaceId,
+ activationStatus: WorkspaceActivationStatus.ACTIVE,
+ },
+ });
+
+ if (!isDefined(workspace)) {
+ return;
+ }
+
+ const workspaceMembers =
+ await this.userService.loadWorkspaceMembers(workspace);
+
+ for (const workspaceMember of workspaceMembers) {
+ await this.sendReminderEmail({
+ workspaceMember,
+ workspaceDisplayName: workspace.displayName,
+ reminder,
+ });
+ }
+
+ await this.userVarsService.set({
+ workspaceId,
+ key: sentKey,
+ value: boundaryValue,
+ });
+ } catch (error) {
+ this.logger.error(
+ `Failed to send ${reminder.type} reminder for workspace ${workspaceId}: ${error}`,
+ );
+ }
+ }
+
+ private async sendReminderEmail({
+ workspaceMember,
+ workspaceDisplayName,
+ reminder,
+ }: {
+ workspaceMember: WorkspaceMemberWorkspaceEntity;
+ workspaceDisplayName: string | undefined;
+ reminder: BillingReminderEmail;
+ }): Promise {
+ if (!isDefined(workspaceMember.userEmail)) {
+ return;
+ }
+
+ const userName = `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`;
+ const locale = workspaceMember.locale;
+ const i18n = this.i18nService.getI18nInstance(locale);
+
+ const { emailTemplate, subject } = this.buildReminderEmail({
+ reminder,
+ userName,
+ workspaceDisplayName,
+ locale,
+ });
+
+ const html = await render(emailTemplate, { pretty: true });
+ const text = await render(emailTemplate, { plainText: true });
+
+ await this.emailService.send({
+ to: workspaceMember.userEmail,
+ from: `${this.twentyConfigService.get(
+ 'EMAIL_FROM_NAME',
+ )} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
+ subject: i18n._(subject),
+ html,
+ text,
+ });
+ }
+
+ private buildReminderEmail({
+ reminder,
+ userName,
+ workspaceDisplayName,
+ locale,
+ }: {
+ reminder: BillingReminderEmail;
+ userName: string;
+ workspaceDisplayName: string | undefined;
+ locale: WorkspaceMemberWorkspaceEntity['locale'];
+ }) {
+ switch (reminder.type) {
+ case 'trial-ending':
+ return {
+ subject: msg`Your Twenty trial is ending soon`,
+ emailTemplate: BillingTrialEndingEmail({
+ userName,
+ workspaceDisplayName,
+ trialEndsAt: reminder.trialEndsAt,
+ dataRetentionDays: this.twentyConfigService.get(
+ 'WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION',
+ ),
+ locale,
+ }),
+ };
+ case 'trial-converting':
+ return {
+ subject: msg`A heads up before your Twenty trial ends`,
+ emailTemplate: BillingTrialConvertingEmail({
+ userName,
+ workspaceDisplayName,
+ trialEndsAt: reminder.trialEndsAt,
+ interval: reminder.interval,
+ locale,
+ }),
+ };
+ case 'subscription-renewing':
+ return {
+ subject: msg`Your Twenty plan renews soon`,
+ emailTemplate: BillingSubscriptionRenewingEmail({
+ userName,
+ workspaceDisplayName,
+ renewsAt: reminder.renewsAt,
+ locale,
+ }),
+ };
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts
index f1b0c88a20..fdf07931ad 100644
--- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts
@@ -9,6 +9,8 @@ import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/b
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/jobs/update-subscription-quantity.job';
+import { BillingReminderModule } from 'src/engine/core-modules/billing/reminders/billing-reminder.module';
+import { BillingReminderCronJob } from 'src/engine/core-modules/billing/reminders/crons/billing-reminder.cron.job';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
@@ -83,8 +85,10 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
LogicFunctionModule,
EnterpriseModule,
EmailingModule,
+ BillingReminderModule,
],
providers: [
+ BillingReminderCronJob,
CleanSuspendedWorkspacesJob,
CleanOnboardingWorkspacesJob,
EmailSenderJob,
diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts
index 027a6707a2..f41ef110c1 100644
--- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts
+++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts
@@ -846,6 +846,39 @@ export class ConfigVariables {
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD = 5_000_000;
+ @ConfigVariablesMetadata({
+ group: ConfigVariablesGroup.BILLING_CONFIG,
+ description:
+ 'Number of days before a trial WITHOUT a credit card ends to send the reminder to add a payment method',
+ type: ConfigVariableType.NUMBER,
+ })
+ @CastToPositiveNumber()
+ @IsOptional()
+ @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
+ BILLING_TRIAL_WITHOUT_CREDIT_CARD_REMINDER_DAYS_BEFORE = 1;
+
+ @ConfigVariablesMetadata({
+ group: ConfigVariablesGroup.BILLING_CONFIG,
+ description:
+ 'Number of days before a trial WITH a credit card ends to send the upcoming-charge reminder',
+ type: ConfigVariableType.NUMBER,
+ })
+ @CastToPositiveNumber()
+ @IsOptional()
+ @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
+ BILLING_TRIAL_WITH_CREDIT_CARD_REMINDER_DAYS_BEFORE = 7;
+
+ @ConfigVariablesMetadata({
+ group: ConfigVariablesGroup.BILLING_CONFIG,
+ description:
+ 'Number of days before a yearly subscription renews to send the renewal reminder',
+ type: ConfigVariableType.NUMBER,
+ })
+ @CastToPositiveNumber()
+ @IsOptional()
+ @ValidateIf((env) => env.IS_BILLING_ENABLED === true)
+ BILLING_SUBSCRIPTION_RENEWAL_REMINDER_DAYS_BEFORE = 7;
+
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BILLING_CONFIG,
isSensitive: true,
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts
index 2ceaf07269..4c609d57ac 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts
@@ -128,7 +128,7 @@ export class CleanerWorkspaceService {
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
- const workspaceDeletionMsg = msg`Action needed to prevent workspace deletion`;
+ const workspaceDeletionMsg = msg`Your workspace is paused — reactivate to keep your data`;
const i18n = this.i18nService.getI18nInstance(workspaceMember.locale);
const subject = i18n._(workspaceDeletionMsg);