feat(billing): replace Stripe trial emails with fair, well-timed reminders (#22186)

## Why

We currently rely on Stripe's automated trial-ending email. It misfires:
the global "remind 7 days before trial ends" setting lands the reminder
on **signup day** for the 7‑day no‑card trial, and the "your card will
be charged" copy makes no sense for a trial with no card. This replaces
it with our own honest, well‑timed, Twenty‑branded emails.

## 🔒 Safety — these emails are OFF by default

Because these reach real customers, the whole feature is gated behind a
kill‑switch that **defaults to `false`**:

- **`BILLING_REMINDER_EMAILS_ENABLED` (default `false`)** — checked
**both** at cron registration **and** on every job run (defense in
depth), so the emails can never be sent inadvertently (not on deploy,
not in staging, not via a stray trigger). They only go out once an
operator explicitly opts in.
- Also gated on `IS_BILLING_ENABLED` (cloud‑only; self‑hosters
unaffected).
- In non‑prod the email driver is typically `logger`, so even if enabled
there, nothing is actually sent.

A unit test asserts that with the switch off, **zero** emails are
produced.

## What it does

A daily cron (`0 8 * * *`) sends three honest, Twenty‑branded emails:

| Plan | Email | When |
|---|---|---|
| No‑card trial (7d) | "Add a card to keep your data" | **1 day before**
trial ends |
| Card‑on‑file trial (30d) | Upcoming‑charge heads‑up (cancel in one
click) | **7 days before** first charge |
| Yearly subscription | Renewal reminder (no surprise) | **7 days
before** each renewal |

- **Monthly renewals get no reminder** (avoids noise) — only the first
charge and annual renewals do.
- Branches no‑card vs with‑card on the customer's payment‑method flag
(with a trial‑duration fallback), so someone who adds a card mid‑trial
correctly gets the charge heads‑up instead of the add‑a‑card one.
- **Idempotent** per `(workspace, boundary date)` via workspace‑level
user vars — yearly reminders re‑fire each period, but the daily cron
never double‑sends.
- Offsets are configurable via new `BILLING_*_REMINDER_DAYS_BEFORE`
variables.

Also **warms up the tone** of the existing suspended / deleted workspace
emails (less robotic, fair, loss‑aversion framing) — these already act
as the "come back or lose your data" win‑back, so no extra win‑back
email was added.

## Rollout

1. Merge.
2. Disable Stripe's automated trial/renewal customer emails in the
Stripe dashboard.
3. Review copy/timing, then set `BILLING_REMINDER_EMAILS_ENABLED=true`
to turn the cron on.

## Notes for reviewers

- **i18n:** new English strings render via Lingui's msgid fallback;
translation catalogs are intentionally **not** included to keep the diff
focused (the repo extracts translations via its standard periodic
`lingui extract` sync — `main` already carries catalog drift). Diff is
18 code files.
- **Recipients:** reminders go to all workspace members, consistent with
the existing suspension emails. Happy to scope the charge‑related ones
to billing admins if preferred.
- **Follow‑ups discussed:** in‑app trial banner, loss‑aversion with real
record counts, and failed‑payment dunning are the higher‑leverage
conversion levers beyond this.

## Test plan

- [x] `typecheck` (twenty-server, twenty-emails)
- [x] oxlint type‑aware + oxfmt
- [x] Unit tests: no‑card path, with‑card path, idempotency, yearly
renewal, billing‑disabled, **kill‑switch off → no send** (6/6 green)
- [ ] Manual: set the flag on a staging instance with `logger` driver
and confirm the right email is logged at each boundary

https://claude.ai/code/session_0147ujzHv1X4vzimf4iGbnT4

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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22186?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-06-26 08:18:34 +02:00
committed by GitHub
parent c635a191bf
commit ea9e11581c
19 changed files with 982 additions and 18 deletions
@@ -0,0 +1 @@
export const BILLING_SETTINGS_URL = 'https://app.twenty.com/settings/billing';
@@ -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 (
<BaseEmail width={333} locale={locale}>
<Title value={i18n._('Your plan renews soon')} />
<MainText>
{userName?.length > 1 ? (
<Trans id="Hi {userName}," values={{ userName }} />
) : (
<Trans id="Hello," />
)}
<br />
<br />
<Trans id="Just so it's never a surprise, here's a heads up about your annual plan." />
<br />
<br />
<Trans
id="Your annual plan for <0>{workspaceDisplayName}</0> renews on {formattedDate}."
values={{ workspaceDisplayName, formattedDate }}
components={{ 0: <b /> }}
/>
<br />
<br />
<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')}
/>
<br />
<br />
</BaseEmail>
);
};
BillingSubscriptionRenewingEmail.PreviewProps = {
userName: 'John Doe',
workspaceDisplayName: 'Acme Inc.',
renewsAt: new Date('2027-07-02'),
locale: 'en',
} as BillingSubscriptionRenewingEmailProps;
export default BillingSubscriptionRenewingEmail;
@@ -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 (
<BaseEmail width={333} locale={locale}>
<Title value={i18n._('A heads up before your trial ends')} />
<MainText>
{userName?.length > 1 ? (
<Trans id="Hi {userName}," values={{ userName }} />
) : (
<Trans id="Hello," />
)}
<br />
<br />
<Trans id="We don't like surprise charges, so here's a friendly heads up." />
<br />
<br />
{interval === 'year' ? (
<Trans
id="Your free trial of <0>{workspaceDisplayName}</0> ends on {formattedDate}. Unless you cancel before then, the card on file will be charged for your annual plan."
values={{ workspaceDisplayName, formattedDate }}
components={{ 0: <b /> }}
/>
) : (
<Trans
id="Your free trial of <0>{workspaceDisplayName}</0> ends on {formattedDate}. Unless you cancel before then, the card on file will be charged for your monthly plan."
values={{ workspaceDisplayName, formattedDate }}
components={{ 0: <b /> }}
/>
)}
<br />
<br />
<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')}
/>
<br />
<br />
</BaseEmail>
);
};
BillingTrialConvertingEmail.PreviewProps = {
userName: 'John Doe',
workspaceDisplayName: 'Acme Inc.',
trialEndsAt: new Date('2026-07-02'),
interval: 'month',
locale: 'en',
} as BillingTrialConvertingEmailProps;
export default BillingTrialConvertingEmail;
@@ -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 (
<BaseEmail width={333} locale={locale}>
<Title value={i18n._('Your trial is ending soon')} />
<MainText>
{userName?.length > 1 ? (
<Trans id="Hi {userName}," values={{ userName }} />
) : (
<Trans id="Hello," />
)}
<br />
<br />
<Trans
id="Your free trial of <0>{workspaceDisplayName}</0> ends on {formattedDate}."
values={{ workspaceDisplayName, formattedDate }}
components={{ 0: <b /> }}
/>
<br />
<br />
<Trans id="To keep your workspace and everything you've built, add a payment method and pick a plan." />
<br />
<br />
<Trans
id="If you do nothing, we'll pause your workspace and keep your data safe for {dataRetentionDays} days in case you change your mind — no card will be charged."
values={{ dataRetentionDays }}
/>
</MainText>
<br />
<CallToAction
href={BILLING_SETTINGS_URL}
value={i18n._('Add a payment method')}
/>
<br />
<br />
</BaseEmail>
);
};
BillingTrialEndingEmail.PreviewProps = {
userName: 'John Doe',
workspaceDisplayName: 'Acme Inc.',
trialEndsAt: new Date('2026-07-02'),
dataRetentionDays: 14,
locale: 'en',
} as BillingTrialEndingEmailProps;
export default BillingTrialEndingEmail;
@@ -23,31 +23,31 @@ export const CleanSuspendedWorkspaceEmail = ({
return (
<BaseEmail width={333} locale={locale}>
<Title value={i18n._('Deleted Workspace')} />
<Title value={i18n._('Your workspace has been deleted')} />
<MainText>
{userName?.length > 1 ? (
<Trans id="Dear {userName}," values={{ userName }} />
<Trans id="Hi {userName}," values={{ userName }} />
) : (
<Trans id="Hello," />
)}
<br />
<br />
<Trans
id="Your workspace <0>{workspaceDisplayName}</0> has been deleted as your subscription expired {daysSinceInactive} days ago."
id="Your workspace <0>{workspaceDisplayName}</0> has now been permanently deleted — it was paused {daysSinceInactive} days ago and wasn't reactivated in time."
values={{ workspaceDisplayName, daysSinceInactive }}
components={{ 0: <b /> }}
/>
<br />
<br />
<Trans id="All data in this workspace has been permanently deleted." />
<Trans id="Its data has been removed and can no longer be recovered." />
<br />
<br />
<Trans id="If you wish to use Twenty again, you can create a new workspace." />
<Trans id="If you'd ever like to give Twenty another try, you can start a fresh workspace in minutes — we'd love to have you back." />
</MainText>
<br />
<CallToAction
href="https://app.twenty.com/"
value={i18n._('Create a new workspace')}
value={i18n._('Start a new workspace')}
/>
<br />
<br />
@@ -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 (
<BaseEmail width={333} locale={locale}>
<Title value={i18n._('Suspended Workspace')} />
<Title value={i18n._('Your workspace is paused')} />
<MainText>
{userName?.length > 1 ? (
<Trans id="Dear {userName}," values={{ userName }} />
<Trans id="Hi {userName}," values={{ userName }} />
) : (
<Trans id="Hello," />
)}
<br />
<br />
<Trans
id="It appears that your workspace <0>{workspaceDisplayName}</0> has been suspended for {daysSinceInactive} days."
values={{ workspaceDisplayName, daysSinceInactive }}
id="Good news first: your workspace <0>{workspaceDisplayName}</0> is only paused — none of your data is gone."
values={{ workspaceDisplayName }}
components={{ 0: <b /> }}
/>
<br />
<br />
<Trans
id="The workspace will be deactivated in {remainingDays} {dayOrDays}, and all its data will be deleted."
id="Reactivate it within the next {remainingDays} {dayOrDays} and you'll pick up exactly where you left off, with every record, view and setting right where you left it."
values={{ remainingDays, dayOrDays }}
/>
<br />
<br />
<Trans
id="If you wish to continue using Twenty, please update your subscription within the next {remainingDays} {dayOrDays}."
values={{ remainingDays, dayOrDays }}
/>
<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="https://app.twenty.com/settings/billing"
value={i18n._('Update your subscription')}
href={BILLING_SETTINGS_URL}
value={i18n._('Reactivate workspace')}
/>
<br />
<br />
+3
View File
@@ -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';