From b48ddd0f8df3ef046976a11498c8659596a38779 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:17:56 +0200 Subject: [PATCH] feat(emails): trial-aware downgrade email and member warnings before a downgrade (#2924) * fix(emails): tell trial users their trial ended when downgraded to free The plan-downgraded email told workspaces whose trial ran out (or was cancelled) that their "subscription ended" and asked them to re-subscribe. It now reads the downgrade reason and speaks about the trial, with an Upgrade CTA instead. * feat(emails): warn workspace members before a downgrade removes them Non-owner members are removed when a workspace drops to free, but only owners were warned beforehand. Members now get a heads-up 3 days before a cancelled plan ends (cancelled again if the owner resumes), and when a trial is about to end without a payment method. * fix(emails): only call a downgrade a trial when no card converted it A subscription cancelled within the hour after trial_end was always treated as a trial, so a customer who converted and then cancelled read "You cancelled your trial". Trial-ending member warnings now always send right away, and very large workspaces skip scheduling so the reminder ids never overflow Stripe's metadata limit. * fix(emails): keep member warning chunks stable across webhook retries Recipients are sorted before chunking, so a Stripe redelivery reuses each idempotency key for the same members instead of skipping a different group. The webhook test now tells owner and member reminder ids apart. --- .../src/router/stripe/trial-lifecycle.test.ts | 35 +++++ .../api/src/router/stripe/webhook.test.ts | 109 ++++++++++++++ packages/api/src/router/stripe/webhook.ts | 135 +++++++++++++---- .../emails/emails/member-access-ending.tsx | 67 +++++++++ packages/emails/emails/plan-downgraded.tsx | 53 +++++-- packages/emails/src/billing.test.tsx | 140 ++++++++++++++++++ packages/emails/src/billing.tsx | 43 ++++++ packages/emails/src/index.ts | 1 + 8 files changed, 543 insertions(+), 40 deletions(-) create mode 100644 packages/emails/emails/member-access-ending.tsx diff --git a/packages/api/src/router/stripe/trial-lifecycle.test.ts b/packages/api/src/router/stripe/trial-lifecycle.test.ts index f9801412c..7f400049b 100644 --- a/packages/api/src/router/stripe/trial-lifecycle.test.ts +++ b/packages/api/src/router/stripe/trial-lifecycle.test.ts @@ -683,6 +683,41 @@ describe("trial lifecycle", () => { expect(await lastPlanAuditReason(s.ws.id)).toBe("trial_cancelled"); }); + test("cancelled inside the slack after trial_end: card → churn, no card → trial", async () => { + const send = stub(resend.emails, "send"); + const enabled = stub(delivery, "enabled", () => true); + stubs.push(send, enabled); + live = []; + const trialEnd = now() - 2 * 60 * 60; + const endedSoonAfter = (stripeId: string) => + event( + "customer.subscription.deleted", + subscription(stripeId, { + status: "canceled", + trial_end: trialEnd, + ended_at: trialEnd + 30 * 60, + cancellation_details: { + reason: "cancellation_requested", + } as Stripe.Subscription.CancellationDetails, + }), + ); + + const cardless = await seedTrial(); + await caller().customerSubscriptionDeleted( + endedSoonAfter(cardless.stripeId), + ); + expect(await lastPlanAuditReason(cardless.ws.id)).toBe("trial_cancelled"); + + withCard(); + const converted = await seedTrial(); + await caller().customerSubscriptionDeleted( + endedSoonAfter(converted.stripeId), + ); + expect(await lastPlanAuditReason(converted.ws.id)).toBe( + "subscription_deleted", + ); + }); + test("a converted trial cancelled later is churn even if the marker is stale", async () => { const send = stub(resend.emails, "send"); const enabled = stub(delivery, "enabled", () => true); diff --git a/packages/api/src/router/stripe/webhook.test.ts b/packages/api/src/router/stripe/webhook.test.ts index b928588e6..a3b2811a8 100644 --- a/packages/api/src/router/stripe/webhook.test.ts +++ b/packages/api/src/router/stripe/webhook.test.ts @@ -144,6 +144,14 @@ describe("stripe webhook emails", () => { }; } + async function addMember(workspaceId: number) { + const member = await createUser(); + const email = `member-${member.id}@example.test`; + await db.update(user).set({ email }).where(eq(user.id, member.id)); + await addUserToWorkspace(member.id, workspaceId, "member"); + return email; + } + describe("customer.subscription.deleted", () => { test("live subscriptions left → no downgrade, no email", async () => { const s = await seed(); @@ -296,6 +304,75 @@ describe("stripe webhook emails", () => { ]); }); + test("cancellation scheduled → members get a reminder 3 days before, id stored", async () => { + const s = await seed(); + const memberEmail = await addMember(s.workspace.id); + const sub = subscription(s.stripeId, { cancel_at_period_end: true }); + live = [sub]; + send.restore(); + stubs = stubs.filter((x) => x !== send); + let n = 0; + send = stub(resend.emails, "send", () => + // biome-ignore lint/suspicious/noExplicitAny: Resend result double + Promise.resolve({ data: { id: `email_${n++}` }, error: null } as any), + ); + stubs.push(send); + + const evt = event("customer.subscription.updated", sub, { + cancel_at_period_end: false, + }); + await caller().customerSubscriptionUpdated(evt); + + assertSpyCalls(send, 3); + const [warning, options] = send.calls[2].args; + expect(warning.to).toEqual([memberEmail]); + expect(warning.subject).toContain("ends on"); + expect(warning.react.props.owners).toEqual([s.ownerEmail]); + expect(new Date(warning.scheduledAt).getTime()).toBe( + (sub.items.data[0].current_period_end - 3 * DAY) * 1000, + ); + expect(options).toEqual({ + idempotencyKey: `stripe:${evt.event.id}:member-access-ending:0`, + }); + assertSpyCalls(batch, 0); + expect(updateSubscription.calls[0].args).toEqual([ + sub.id, + { + metadata: { + reminder_email_id: "email_1", + member_reminder_email_ids: "email_2", + }, + }, + ]); + }); + + test("true → false: member reminders are cancelled with the owner one", async () => { + const s = await seed(); + const sub = subscription(s.stripeId, { + metadata: { + reminder_email_id: "email_owner", + member_reminder_email_ids: "email_m1,email_m2", + }, + }); + live = [sub]; + + await caller().customerSubscriptionUpdated( + event("customer.subscription.updated", sub, { + cancel_at_period_end: true, + }), + ); + + expect(cancelEmail.calls.map((c) => c.args[0])).toEqual([ + "email_owner", + "email_m1", + "email_m2", + ]); + expect(updateSubscription.calls[0].args).toEqual([ + sub.id, + { metadata: { reminder_email_id: "", member_reminder_email_ids: "" } }, + ]); + }); + test("period end beyond 30 days: confirmation only, nothing stored", async () => { const s = await seed(); const sub = subscription(s.stripeId, { @@ -562,6 +639,38 @@ describe("stripe webhook emails", () => { }); }); + test("no card → non-owner members are warned now; card on file → not", async () => { + const s = await seed(); + const memberEmail = await addMember(s.workspace.id); + + const evt = event( + "customer.subscription.trial_will_end", + subscription(s.stripeId, { status: "trialing", trial_end: trialEnd }), + ); + await caller().customerSubscriptionTrialWillEnd(evt); + + assertSpyCalls(send, 2); + const [warning, options] = send.calls[1].args; + expect(warning.to).toEqual([memberEmail]); + expect(warning.react.props.reason).toBe("trial"); + expect(warning.scheduledAt).toBeUndefined(); + expect(options).toEqual({ + idempotencyKey: `stripe:${evt.event.id}:member-access-ending:0`, + }); + + await caller().customerSubscriptionTrialWillEnd( + event( + "customer.subscription.trial_will_end", + subscription(s.stripeId, { + status: "trialing", + trial_end: trialEnd, + default_payment_method: "pm_test_1", + }), + ), + ); + assertSpyCalls(send, 3); + }); + test("card on file → plan continues, no loss lines", async () => { const s = await seed(); await createPage(s.workspace.id, { diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index db9a5d5a8..0db72f37e 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -2,6 +2,7 @@ import { type EventProps, Events, setupAnalytics } from "@openstatus/analytics"; import { billingRecipients, cancelScheduledEmail, + scheduleMemberAccessEnding, schedulePlanEndingSoon, sendCancellationScheduled, sendMemberRemoved, @@ -60,6 +61,39 @@ const webhookProcedure = publicProcedure.input( // "plan ends in 3 days" reminder, so a resume can cancel it without a schema // change. const REMINDER_METADATA_KEY = "reminder_email_id"; +// Comma-separated: one scheduled email per 50 members. +const MEMBER_REMINDER_METADATA_KEY = "member_reminder_email_ids"; +// Stripe caps a metadata value at 500 chars: 13 Resend ids (36 chars + comma). +// Past that the warning goes out now, as an untracked schedule could not be +// cancelled on resume. +const MAX_SCHEDULED_MEMBERS = 13 * 50; + +/** + * Cancels the scheduled owner and member reminders and clears the ids that + * were cancelled; a failed cancel keeps its id so a retry can still cancel it. + */ +async function cancelReminders( + subscriptionId: string, + metadata: Record, +) { + const cleared: Record = {}; + const reminderId = metadata[REMINDER_METADATA_KEY]; + if (reminderId && (await cancelScheduledEmail(reminderId))) { + cleared[REMINDER_METADATA_KEY] = ""; + } + const memberIds = + metadata[MEMBER_REMINDER_METADATA_KEY]?.split(",").filter(Boolean) ?? []; + const kept: string[] = []; + for (const id of memberIds) { + if (!(await cancelScheduledEmail(id))) kept.push(id); + } + if (kept.length < memberIds.length) { + cleared[MEMBER_REMINDER_METADATA_KEY] = kept.join(","); + } + if (Object.keys(cleared).length > 0) { + await stripe.subscriptions.update(subscriptionId, { metadata: cleared }); + } +} type Db = Parameters[0]["db"]; @@ -67,14 +101,20 @@ type Db = Parameters[0]["db"]; // processing can lag behind that timestamp by a little. const TRIAL_END_SLACK_S = 60 * 60; -function endedDuringTrial( +async function endedDuringTrial( subscription: Stripe.Subscription, eventCreated: number, ) { if (!subscription.trial_end) return false; const endedAt = subscription.ended_at ?? subscription.canceled_at ?? eventCreated; - return endedAt <= subscription.trial_end + TRIAL_END_SLACK_S; + if (endedAt <= subscription.trial_end) return true; + // Inside the slack, a trial with a card converted to paid before it was + // cancelled: that is churn, not a trial running out. + return ( + endedAt <= subscription.trial_end + TRIAL_END_SLACK_S && + !(await hasPaymentMethod(subscription)) + ); } async function getOwnerEmails(db: NonNullable, workspaceId: number) { @@ -89,7 +129,10 @@ async function getBillingRecipients( ) { const owners = await getOwnerEmails(db, workspaceId); const customer = await stripe.customers.retrieve(customerId); - return billingRecipients(owners, customer.deleted ? null : customer.email); + return { + to: billingRecipients(owners, customer.deleted ? null : customer.email), + owners: billingRecipients(owners), + }; } function toPlanLoss( @@ -154,7 +197,7 @@ async function sendCancellationEmails(args: { }) { const { db, ws, customerId, current, plan, eventId } = args; const endsAt = getCurrentPeriodEnd(current); - const to = await getBillingRecipients(db, ws.id, customerId); + const { to, owners } = await getBillingRecipients(db, ws.id, customerId); const preview = await previewWorkspaceDowngrade({ ctx: { workspace: ws, @@ -179,20 +222,34 @@ async function sendCancellationEmails(args: { endsAt, loss, }); - if (!reminderId) return; + let memberIds: string[] = []; + try { + memberIds = await scheduleMemberAccessEnding({ + to: preview.membersRemoved, + eventId, + workspaceName: ws.name || ws.slug, + plan, + endsAt, + reason: "cancellation", + owners, + schedule: preview.membersRemoved.length <= MAX_SCHEDULED_MEMBERS, + }); + } catch (err) { + console.error("Failed to schedule member-access-ending emails:", err); + } - const updated = await stripe.subscriptions.update(current.id, { - metadata: { [REMINDER_METADATA_KEY]: reminderId }, - }); + const metadata: Record = {}; + if (reminderId) metadata[REMINDER_METADATA_KEY] = reminderId; + if (memberIds.length > 0) { + metadata[MEMBER_REMINDER_METADATA_KEY] = memberIds.join(","); + } + if (Object.keys(metadata).length === 0) return; + + const updated = await stripe.subscriptions.update(current.id, { metadata }); // A resume handled while we were scheduling found no id to cancel. The - // update response is the state after our write, so undo the reminder here. - if ( - !updated.cancel_at_period_end && - (await cancelScheduledEmail(reminderId)) - ) { - await stripe.subscriptions.update(current.id, { - metadata: { [REMINDER_METADATA_KEY]: "" }, - }); + // update response is the state after our write, so undo the reminders here. + if (!updated.cancel_at_period_end) { + await cancelReminders(current.id, metadata); } } @@ -349,13 +406,7 @@ export const webhookRouter = createTRPCRouter({ eventId: opts.input.event.id, }); } else { - const reminderId = current.metadata?.[REMINDER_METADATA_KEY]; - // Keep the id when the cancel fails, so a retry can still cancel it. - if (reminderId && (await cancelScheduledEmail(reminderId))) { - await stripe.subscriptions.update(current.id, { - metadata: { [REMINDER_METADATA_KEY]: "" }, - }); - } + await cancelReminders(current.id, current.metadata ?? {}); } } catch (err) { console.error("Failed to handle cancellation emails:", err); @@ -567,18 +618,44 @@ export const webhookRouter = createTRPCRouter({ console.error("Failed to preview trial downgrade:", err); return undefined; }); + const { to, owners } = await getBillingRecipients( + opts.ctx.db, + ws.id, + customerId, + ); + const trialEnd = new Date(subscription.trial_end * 1000); + const plan = ws.plan ?? "starter"; await sendTrialEnding({ - to: await getBillingRecipients(opts.ctx.db, ws.id, customerId), + to, eventId: opts.input.event.id, workspaceSlug: ws.slug, - trialEnd: new Date(subscription.trial_end * 1000), - plan: ws.plan ?? "starter", + trialEnd, + plan, hasPaymentMethod: withCard, loss: preview ? toPlanLoss(preview, preview.customDomains, preview.ssoEnabled) : undefined, }); + // Best-effort: a failed member warning must not fail the webhook. Sent + // now, never scheduled: nothing stores the id to cancel it later. + if (preview) { + try { + await scheduleMemberAccessEnding({ + to: preview.membersRemoved, + eventId: opts.input.event.id, + workspaceName: ws.name || ws.slug, + plan, + endsAt: trialEnd, + reason: "trial", + owners, + schedule: false, + }); + } catch (err) { + console.error("Failed to send member-access-ending emails:", err); + } + } + await trackForWorkspaceOwner({ db: opts.ctx.db, ws, @@ -635,7 +712,10 @@ export const webhookRouter = createTRPCRouter({ // not a paying customer churning — keep the audit trail honest. Read // off the subscription rather than `ws.trialEndsAt`: a delayed // trial-to-active webhook leaves that marker stale on a paying customer. - const reason = endedDuringTrial(subscription, opts.input.event.created) + const reason = (await endedDuringTrial( + subscription, + opts.input.event.created, + )) ? subscription.cancellation_details?.reason === "cancellation_requested" ? "trial_cancelled" : "trial_ended" @@ -658,6 +738,7 @@ export const webhookRouter = createTRPCRouter({ workspaceSlug: ws.slug, previousPlan: ws.plan ?? "paid", loss: toPlanLoss(trimmed, customDomains, ssoDisabled), + reason, }); } catch (err) { console.error("Failed to send plan-downgraded email:", err); diff --git a/packages/emails/emails/member-access-ending.tsx b/packages/emails/emails/member-access-ending.tsx new file mode 100644 index 000000000..94eff7fbd --- /dev/null +++ b/packages/emails/emails/member-access-ending.tsx @@ -0,0 +1,67 @@ +/** @jsxRuntime automatic @jsxImportSource react */ + +import { Footer } from "./_components/footer"; +import { formatDay, formatLongDay, formatShortDay } from "./_components/format"; +import { Heading } from "./_components/heading"; +import { KeyValue } from "./_components/key-value"; +import { Layout } from "./_components/layout"; + +export interface MemberAccessEndingProps { + workspaceName: string; + plan: string; + endsAt: Date; + reason: "cancellation" | "trial"; + /** Who can keep the plan, or ask for access again. */ + owners: string[]; +} + +export function memberAccessEndingSubject( + props: MemberAccessEndingProps, +): string { + return `Your access to ${props.workspaceName} on openstatus ends on ${formatShortDay(props.endsAt)}`; +} + +const MemberAccessEndingEmail = (props: MemberAccessEndingProps) => { + const ask = + props.owners.length > 0 + ? `Ask ${props.owners[0]} to keep the plan if you still need access.` + : "Ask a workspace owner to keep the plan if you still need access."; + return ( + + } + > + + {props.reason === "trial" + ? `The workspace’s ${props.plan} trial ends without a payment method on file.` + : `The workspace’s ${props.plan} subscription is cancelled and ends on that day.`}{" "} + The free plan holds a single member, so every other member is removed + unless an owner keeps the plan. Your openstatus account and your other + workspaces are unchanged. + + 0 + ? [{ label: "Ask to keep it", value: props.owners.join(", ") }] + : []), + ]} + /> + + ); +}; + +MemberAccessEndingEmail.PreviewProps = { + workspaceName: "Acme", + plan: "team", + endsAt: new Date("2026-09-25T00:00:00Z"), + reason: "cancellation", + owners: ["max@acme.dev"], +} satisfies MemberAccessEndingProps; + +export default MemberAccessEndingEmail; diff --git a/packages/emails/emails/plan-downgraded.tsx b/packages/emails/emails/plan-downgraded.tsx index db5a5cc9b..4a363ccde 100644 --- a/packages/emails/emails/plan-downgraded.tsx +++ b/packages/emails/emails/plan-downgraded.tsx @@ -9,11 +9,22 @@ import { Layout } from "./_components/layout"; import { type PlanLoss, planLossRows } from "./_components/plan-loss"; import { Signature } from "./_components/signature"; +export type PlanDowngradedReason = + | "subscription_deleted" + | "trial_ended" + | "trial_cancelled"; + export interface PlanDowngradedProps { workspaceSlug: string; /** Plan the workspace was on, e.g. "team". */ previousPlan: string; loss: PlanLoss; + /** Defaults to `subscription_deleted`. */ + reason?: PlanDowngradedReason; +} + +function isTrial(props: PlanDowngradedProps) { + return props.reason === "trial_ended" || props.reason === "trial_cancelled"; } export const BILLING_URL = "https://app.openstatus.dev/settings/billing"; @@ -22,20 +33,31 @@ export const BILLING_FOOTER_REASON = export function planDowngradedSubject(props: PlanDowngradedProps): string { const pages = props.loss.pagesDeleted.length; + const lead = isTrial(props) + ? "Your trial ended" + : "Your workspace is on the free plan"; return pages > 0 - ? `Your workspace is on the free plan — ${pages} status page${ - pages === 1 ? "" : "s" - } deleted` - : "Your workspace is on the free plan — here is what changed"; + ? `${lead} — ${pages} status page${pages === 1 ? "" : "s"} deleted` + : `${lead} — here is what changed`; } const PlanDowngradedEmail = (props: PlanDowngradedProps) => { const { loss } = props; const rows = planLossRows(loss, true); + const trial = isTrial(props); + const verb = trial ? "upgrading" : "re-subscribing"; + const intro = + props.reason === "trial_ended" + ? `Your ${props.previousPlan} trial ended without a payment method` + : props.reason === "trial_cancelled" + ? `You cancelled your ${props.previousPlan} trial` + : `Your ${props.previousPlan} subscription ended`; return ( } > { } > - Your {props.previousPlan} subscription ended, so the workspace was - trimmed to what the free plan holds. + {intro}, so the workspace was trimmed to what the free plan holds. {loss.pagesDeleted.length > 0 ? ( Everything else is reversible: paused monitors keep their history and - resume when you switch them back on, and re-subscribing lets you - invite your team again. + resume when you switch them back on, and {verb} lets you invite your + team again. ) : ( Paused monitors keep their history and resume when you switch them - back on, and re-subscribing lets you invite your team again. + back on, and {verb} lets you invite your team again. )} { ); }); + test("plan-downgraded after a trial says trial and offers upgrade", async () => { + const props = { + workspaceSlug: "acme-dev", + previousPlan: "team", + loss, + reason: "trial_ended" as const, + }; + const html = await render(); + expect(html).toContain("TRIAL"); + expect(html).toContain("team trial ended without a payment method"); + expect(html).toContain("team trial → free"); + expect(html).toContain("Upgrade"); + expect(html).not.toContain("subscription ended"); + expect(html).not.toContain("Re-subscribe"); + expect(planDowngradedSubject(props)).toBe( + "Your trial ended — 2 status pages deleted", + ); + + const cancelled = await render( + , + ); + expect(cancelled).toContain("You cancelled your team trial"); + }); + test("plan-ending-soon is dated and in the future tense", async () => { const props = { workspaceSlug: "acme-dev", plan: "team", endsAt, loss }; const html = await render(); @@ -104,6 +132,33 @@ describe("billing templates", () => { ); }); + test("member-access-ending: dated, names owners, trial vs cancellation", async () => { + const props = { + workspaceName: "Acme", + plan: "team", + endsAt, + reason: "cancellation" as const, + owners: ["max@acme.dev", "thibault@acme.dev"], + }; + const html = await render(); + expect(html).toContain("HEADS UP"); + expect(html).toContain("You lose access to Acme on Friday 25 September"); + expect(html).toContain("team subscription is cancelled"); + expect(html).toContain("Fri 25 Sep 2026"); + expect(html).toContain("max@acme.dev, thibault@acme.dev"); + expect(html).toContain("you are a member of this workspace"); + expect(memberAccessEndingSubject(props)).toBe( + "Your access to Acme on openstatus ends on 25 Sep", + ); + + const trial = await render( + , + ); + expect(trial).toContain("team trial ends without a payment method"); + expect(trial).toContain("Ask a workspace owner"); + expect(trial).not.toContain("Ask to keep it"); + }); + test("member-removed: one line, who to ask, no CTA", async () => { const html = await render( { expect(await cancelScheduledEmail("email_123")).toBe(false); }); + test("scheduleMemberAccessEnding: 3 days ahead, 50 recipients per email", async () => { + const now = new Date(endsAt.getTime() - 10 * 86_400_000); + const ids = await scheduleMemberAccessEnding({ + to: Array.from({ length: 60 }, (_, i) => `m${i}@acme.dev`), + eventId: "evt_1", + workspaceName: "Acme", + plan: "team", + endsAt, + reason: "cancellation", + owners: ["max@acme.dev"], + now, + }); + assertSpyCalls(send, 2); + expect(send.calls.map((c) => c.args[0].to.length)).toEqual([50, 10]); + expect(send.calls[0].args[0].scheduledAt).toBe("2026-09-22T00:00:00.000Z"); + expect(send.calls[1].args[1]).toEqual({ + idempotencyKey: "stripe:evt_1:member-access-ending:1", + }); + expect(ids.length).toBe(2); + }); + + test("scheduleMemberAccessEnding chunks a stable order across redeliveries", async () => { + const req = { + eventId: "evt_1", + workspaceName: "Acme", + plan: "team", + endsAt, + reason: "trial" as const, + owners: [], + }; + await scheduleMemberAccessEnding({ + ...req, + to: ["b@acme.dev", "a@acme.dev"], + }); + await scheduleMemberAccessEnding({ + ...req, + to: ["a@acme.dev", "b@acme.dev"], + }); + expect(send.calls[0].args[0].to).toEqual(send.calls[1].args[0].to); + }); + + test("scheduleMemberAccessEnding inside the lead window sends now, no ids", async () => { + const ids = await scheduleMemberAccessEnding({ + to: ["a@acme.dev"], + eventId: "evt_1", + workspaceName: "Acme", + plan: "team", + endsAt, + reason: "trial", + owners: [], + now: new Date(endsAt.getTime() - 86_400_000), + }); + assertSpyCalls(send, 1); + expect("scheduledAt" in send.calls[0].args[0]).toBe(false); + expect(ids).toEqual([]); + + const unscheduled = await scheduleMemberAccessEnding({ + to: ["a@acme.dev"], + eventId: "evt_3", + workspaceName: "Acme", + plan: "team", + endsAt, + reason: "cancellation", + owners: [], + schedule: false, + now: new Date(endsAt.getTime() - 10 * 86_400_000), + }); + assertSpyCalls(send, 2); + expect("scheduledAt" in send.calls[1].args[0]).toBe(false); + expect(unscheduled).toEqual([]); + + expect( + await scheduleMemberAccessEnding({ + to: [], + eventId: "evt_2", + workspaceName: "Acme", + plan: "team", + endsAt, + reason: "trial", + owners: [], + }), + ).toEqual([]); + assertSpyCalls(send, 2); + }); + test("sendMemberRemoved chunks at Resend's 100-email batch limit", async () => { await sendMemberRemoved({ to: Array.from({ length: 250 }, (_, i) => `m${i}@acme.dev`), diff --git a/packages/emails/src/billing.tsx b/packages/emails/src/billing.tsx index f4cc46a98..cf964b9a6 100644 --- a/packages/emails/src/billing.tsx +++ b/packages/emails/src/billing.tsx @@ -7,6 +7,10 @@ import type { PlanLoss } from "../emails/_components/plan-loss"; import CancellationScheduledEmail, { CANCELLATION_SCHEDULED_SUBJECT, } from "../emails/cancellation-scheduled"; +import MemberAccessEndingEmail, { + type MemberAccessEndingProps, + memberAccessEndingSubject, +} from "../emails/member-access-ending"; import MemberRemovedEmail, { type MemberRemovedProps, memberRemovedSubject, @@ -37,6 +41,7 @@ const DAY_MS = 86_400_000; export const REMINDER_LEAD_DAYS = 3; const RESEND_MAX_SCHEDULE_DAYS = 30; const RESEND_BATCH_SIZE = 100; +const RESEND_MAX_RECIPIENTS = 50; /** `stripe::