/** @jsxRuntime automatic @jsxImportSource react */
import "./test-preload.ts";
import { expect } from "@std/expect";
import { afterEach, beforeEach, describe, test } from "@std/testing/bdd";
import { assertSpyCalls, type Stub, stub } from "@std/testing/mock";
import { render } from "react-email";
import type { PlanLoss } from "../emails/_components/plan-loss";
import CancellationScheduledEmail from "../emails/cancellation-scheduled";
import MemberRemovedEmail from "../emails/member-removed";
import PlanDowngradedEmail, {
planDowngradedSubject,
} from "../emails/plan-downgraded";
import PlanEndingSoonEmail, {
planEndingSoonSubject,
} from "../emails/plan-ending-soon";
import TrialEndingEmail, { trialEndingSubject } from "../emails/trial-ending";
import {
billingRecipients,
planLossLines,
reminderScheduledAt,
schedulePlanEndingSoon,
sendCancellationScheduled,
sendMemberRemoved,
sendPlanDowngraded,
sendTrialEnding,
} from "./billing";
import { cancelScheduledEmail, delivery, resend, sendEmail } from "./send";
const loss = {
monitorsDeactivated: 11,
pagesDeleted: ["Acme API", "Acme EU"],
keptPageTitle: "Acme",
notificationsDeleted: 3,
invitationsDeleted: 1,
membersRemoved: 4,
customDomains: ["status.acme.dev"],
sso: true,
} satisfies PlanLoss;
const nothing = {
monitorsDeactivated: 0,
pagesDeleted: [],
keptPageTitle: null,
notificationsDeleted: 0,
invitationsDeleted: 0,
membersRemoved: 0,
customDomains: [],
sso: false,
} satisfies PlanLoss;
const endsAt = new Date("2026-09-25T00:00:00Z");
describe("billing templates", () => {
test("plan-downgraded names deleted pages and counts everything else", async () => {
const props = { workspaceSlug: "acme-dev", previousPlan: "team", loss };
const html = await render();
expect(html).toContain("ACCOUNT CHANGE");
expect(html).toContain("acme-dev");
expect(html).toContain("team → free");
expect(html).toContain("Acme API, Acme EU");
expect(html).toContain("Deleted status pages cannot be restored");
expect(html).toContain("11 monitors, oldest stays active");
expect(html).toContain("4 members");
expect(html).toContain("status.acme.dev");
expect(html).toContain("Off, IdP configuration kept");
expect(html).toContain("workspace owners and the billing contact");
expect(html).toContain("Co-founder, openstatus");
expect(html).toContain(
'href="https://app.openstatus.dev/settings/billing"',
);
expect(planDowngradedSubject(props)).toBe(
"Your workspace is on the free plan — 2 status pages deleted",
);
});
test("plan-downgraded with nothing trimmed drops every loss row", async () => {
const props = {
workspaceSlug: "acme-dev",
previousPlan: "starter",
loss: nothing,
};
const html = await render();
expect(html).toContain("No status page was deleted");
expect(html).not.toContain("Status pages deleted");
expect(html).not.toContain("Members removed");
expect(html).not.toContain("SAML SSO");
expect(planDowngradedSubject(props)).toBe(
"Your workspace is on the free plan — here is what changed",
);
});
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();
expect(html).toContain("ACTION NEEDED");
expect(html).toContain("Your team plan ends on Friday 25 September");
expect(html).toContain("Fri 25 Sep 2026");
expect(html).toContain("Status pages that get deleted");
expect(html).toContain("Resume subscription");
expect(planEndingSoonSubject(props)).toBe(
"Your team plan ends on 25 Sep — resume to keep everything",
);
});
test("member-removed: one line, who to ask, no CTA", async () => {
const html = await render(
,
);
expect(html).toContain("You were removed from Acme");
expect(html).toContain("subscription ended");
expect(html).toContain("max@acme.dev");
expect(html).not.toContain("app.openstatus.dev");
const manual = await render(
,
);
expect(manual).toContain("A workspace owner removed you");
expect(manual).not.toContain("Ask for access");
});
test("cancellation-scheduled is raw: no layout, date, losses, resume link", async () => {
const html = await render(
,
);
expect(html).toContain("Fri 25 Sep 2026");
expect(html).toContain("2 status pages get deleted (Acme API, Acme EU)");
expect(html).toContain("4 members lose access");
expect(html).toContain("what made you cancel?");
expect(html).toContain(
'href="https://app.openstatus.dev/settings/billing"',
);
expect(html).not.toContain("max-width:600px");
const bare = await render(
,
);
expect(bare).not.toContain("moves to the free plan");
});
test("trial-ending without a card lists what gets removed", async () => {
const props = {
workspaceSlug: "acme-dev",
plan: "starter",
trialEnd: endsAt,
hasPaymentMethod: false,
loss,
};
const html = await render();
expect(trialEndingSubject(props)).toBe(
"Your trial ends on 25 Sep — add a payment method to keep starter",
);
expect(html).toContain("ACTION NEEDED");
expect(html).toContain("Your trial ends on Friday 25 September");
expect(html).toContain("acme-dev");
expect(html).toContain("starter → free");
expect(html).toContain("Acme API, Acme EU");
expect(html).toContain("Add payment method");
expect(html).not.toContain("on file, so");
});
test("trial-ending with a card says the plan continues", async () => {
const props = {
workspaceSlug: "acme-dev",
plan: "starter",
trialEnd: endsAt,
hasPaymentMethod: true,
loss,
};
const html = await render();
expect(trialEndingSubject(props)).toBe(
"Your trial ends on 25 Sep — your starter plan continues",
);
expect(html).toContain("Nothing to do");
expect(html).toContain("starter, continues");
expect(html).toContain("Manage subscription");
expect(html).not.toContain("Acme API, Acme EU");
expect(html).not.toContain("Add payment method");
});
test("trial-ending without a card and nothing to trim", async () => {
const html = await render(
,
);
expect(html).toContain("free plan on that day.");
expect(html).not.toContain("trimmed");
});
test("loss lines use singular forms", () => {
expect(
planLossLines({
...nothing,
pagesDeleted: ["A"],
monitorsDeactivated: 1,
membersRemoved: 1,
}),
).toEqual([
"1 status page gets deleted (A)",
"1 monitor gets paused",
"1 member loses access",
]);
});
test("loss lines cover every destructive change", () => {
expect(
planLossLines({
...nothing,
invitationsDeleted: 2,
notificationsDeleted: 1,
customDomains: ["a.dev", "b.dev"],
sso: true,
}),
).toEqual([
"2 pending invitations get deleted",
"1 notification channel gets removed",
"the custom domains a.dev, b.dev are released",
"SAML SSO turns off",
]);
});
test("custom domain label follows the count", async () => {
const one = await render(
,
);
expect(one).toContain("Custom domain released");
const two = await render(
,
);
expect(two).toContain("Custom domains released");
});
});
describe("billingRecipients", () => {
test("owners + customer, case-insensitive dedupe, empties dropped", () => {
expect(
billingRecipients(
["Max@Acme.dev", "", null, " ", "thibault@acme.dev"],
"max@acme.dev",
),
).toEqual(["Max@Acme.dev", "thibault@acme.dev"]);
expect(billingRecipients([], "billing@acme.dev")).toEqual([
"billing@acme.dev",
]);
expect(billingRecipients([undefined], null)).toEqual([]);
});
});
describe("reminderScheduledAt", () => {
const now = new Date("2026-09-01T00:00:00Z");
test("period end minus 3 days", () => {
expect(reminderScheduledAt(endsAt, now)?.toISOString()).toBe(
"2026-09-22T00:00:00.000Z",
);
});
test("null inside the lead window and beyond Resend's 30 days", () => {
expect(reminderScheduledAt(new Date("2026-09-03T00:00:00Z"), now)).toBe(
null,
);
expect(reminderScheduledAt(new Date("2027-03-01T00:00:00Z"), now)).toBe(
null,
);
});
});
describe("send plumbing", () => {
let send: Stub, batch: Stub, cancel: Stub, enabled: Stub;
// safe because the verbs only read `data.id` and `error.name`
const result = (value: unknown) => Promise.resolve(value as never);
const ok = { data: { id: "email_123" }, error: null };
beforeEach(() => {
enabled = stub(delivery, "enabled", () => true);
send = stub(resend.emails, "send", () => result(ok));
batch = stub(resend.batch, "send", () => result(ok));
cancel = stub(resend.emails, "cancel", () => result(ok));
});
afterEach(() => {
for (const s of [enabled, send, batch, cancel]) s.restore();
});
const email = {
from: "a ",
to: ["b@example.com"],
subject: "s",
react: (
),
};
test("sendEmail forwards idempotencyKey and scheduledAt, returns the id", async () => {
const id = await sendEmail(
{ ...email, reply_to: "r@openstatus.dev" },
{ idempotencyKey: "k", scheduledAt: "2026-09-22T00:00:00.000Z" },
);
expect(id).toBe("email_123");
expect(send.calls[0].args[0].scheduledAt).toBe("2026-09-22T00:00:00.000Z");
expect(send.calls[0].args[0].replyTo).toBe("r@openstatus.dev");
expect(send.calls[0].args[1]).toEqual({ idempotencyKey: "k" });
});
test("sendEmail omits both when not given", async () => {
await sendEmail(email);
expect("scheduledAt" in send.calls[0].args[0]).toBe(false);
expect(send.calls[0].args[1]).toBeUndefined();
});
test("sendEmail does nothing outside production", async () => {
enabled.restore();
enabled = stub(delivery, "enabled", () => false);
expect(await sendEmail(email)).toBeUndefined();
assertSpyCalls(send, 0);
});
test("an idempotency conflict or Resend error never throws", async () => {
for (const name of ["invalid_idempotent_request", "application_error"]) {
send.restore();
send = stub(resend.emails, "send", () =>
result({ data: null, error: { name } }),
);
expect(await sendEmail(email, { idempotencyKey: "k" })).toBeUndefined();
assertSpyCalls(send, 1);
}
});
test("cancelScheduledEmail wraps emails.cancel and reports success", async () => {
expect(await cancelScheduledEmail("email_123")).toBe(true);
expect(cancel.calls[0].args[0]).toBe("email_123");
cancel.restore();
cancel = stub(resend.emails, "cancel", () =>
result({ data: null, error: { name: "application_error" } }),
);
expect(await cancelScheduledEmail("email_123")).toBe(false);
});
test("sendMemberRemoved chunks at Resend's 100-email batch limit", async () => {
await sendMemberRemoved({
to: Array.from({ length: 250 }, (_, i) => `m${i}@acme.dev`),
idempotencyKey: "stripe:evt_1:member-removed",
workspaceName: "Acme",
reason: "downgrade",
owners: [],
});
assertSpyCalls(batch, 3);
expect(batch.calls.map((c) => c.args[0].length)).toEqual([100, 100, 50]);
expect(batch.calls.map((c) => c.args[1].idempotencyKey)).toEqual([
"stripe:evt_1:member-removed:0",
"stripe:evt_1:member-removed:1",
"stripe:evt_1:member-removed:2",
]);
});
test("a batch error is swallowed, not thrown", async () => {
batch.restore();
batch = stub(resend.batch, "send", () =>
result({ data: null, error: { name: "application_error" } }),
);
await sendMemberRemoved({
to: ["a@acme.dev"],
workspaceName: "Acme",
reason: "downgrade",
owners: [],
});
assertSpyCalls(batch, 1);
});
test("sendPlanDowngraded", async () => {
await sendPlanDowngraded({
to: ["max@acme.dev"],
eventId: "evt_1",
workspaceSlug: "acme-dev",
previousPlan: "team",
loss,
});
const [payload, options] = send.calls[0].args;
expect(payload.to).toEqual(["max@acme.dev"]);
expect(payload.from).toBe(
"Thibault from openstatus ",
);
expect(payload.replyTo).toBe("thibault@openstatus.dev");
expect(payload.subject).toBe(
"Your workspace is on the free plan — 2 status pages deleted",
);
expect(options).toEqual({ idempotencyKey: "stripe:evt_1:plan-downgraded" });
});
test("sendMemberRemoved batches one mail per member under one key", async () => {
await sendMemberRemoved({
to: ["a@acme.dev", "b@acme.dev"],
idempotencyKey: "stripe:evt_1:member-removed",
workspaceName: "Acme",
reason: "downgrade",
owners: ["max@acme.dev"],
});
const [emails, options] = batch.calls[0].args;
expect(emails.map((e: { to: string }) => e.to)).toEqual([
"a@acme.dev",
"b@acme.dev",
]);
expect(emails[0].from).toBe(
"openstatus ",
);
expect(emails[0].replyTo).toBe("ping@openstatus.dev");
expect(emails[0].subject).toBe(
"You no longer have access to Acme on openstatus",
);
expect(emails[0].html).toContain("max@acme.dev");
expect(options).toEqual({
idempotencyKey: "stripe:evt_1:member-removed:0",
});
});
test("sendCancellationScheduled is raw, from the root domain", async () => {
await sendCancellationScheduled({
to: ["max@acme.dev"],
eventId: "evt_2",
plan: "team",
endsAt,
loss,
});
const [payload, options] = send.calls[0].args;
expect(payload.from).toBe(
"Thibault from openstatus ",
);
expect(payload.subject).toBe("Your openstatus cancellation");
expect(options).toEqual({
idempotencyKey: "stripe:evt_2:cancellation-scheduled",
});
});
test("schedulePlanEndingSoon schedules 3 days before and returns the id", async () => {
const id = await schedulePlanEndingSoon({
to: ["max@acme.dev"],
eventId: "evt_2",
workspaceSlug: "acme-dev",
plan: "team",
endsAt,
loss,
now: new Date("2026-09-01T00:00:00Z"),
});
expect(id).toBe("email_123");
const [payload, options] = send.calls[0].args;
expect(payload.scheduledAt).toBe("2026-09-22T00:00:00.000Z");
expect(options).toEqual({
idempotencyKey: "stripe:evt_2:plan-ending-soon",
});
});
test("schedulePlanEndingSoon skips a period end beyond 30 days", async () => {
const id = await schedulePlanEndingSoon({
to: ["max@acme.dev"],
eventId: "evt_2",
workspaceSlug: "acme-dev",
plan: "team",
endsAt: new Date("2027-03-01T00:00:00Z"),
loss,
now: new Date("2026-09-01T00:00:00Z"),
});
expect(id).toBeUndefined();
assertSpyCalls(send, 0);
});
test("sendTrialEnding", async () => {
await sendTrialEnding({
to: ["max@acme.dev"],
eventId: "evt_3",
workspaceSlug: "acme-dev",
trialEnd: endsAt,
plan: "starter",
hasPaymentMethod: false,
loss,
});
const [payload, options] = send.calls[0].args;
expect(payload.from).toBe(
"Thibault from openstatus ",
);
expect(payload.replyTo).toBe("thibault@openstatus.dev");
expect(payload.subject).toBe(
"Your trial ends on 25 Sep — add a payment method to keep starter",
);
expect(options).toEqual({ idempotencyKey: "stripe:evt_3:trial-ending" });
});
test("no verb sends without recipients", async () => {
await sendPlanDowngraded({
to: [],
eventId: "e",
workspaceSlug: "a",
previousPlan: "team",
loss,
});
await sendMemberRemoved({
to: [],
workspaceName: "A",
reason: "downgrade",
owners: [],
});
await sendTrialEnding({
to: [],
eventId: "e",
workspaceSlug: "a",
trialEnd: endsAt,
plan: "starter",
hasPaymentMethod: false,
});
assertSpyCalls(send, 0);
assertSpyCalls(batch, 0);
});
});