diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 192f5e5c..d37d6238 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -11,14 +11,18 @@ import { workspace, } from "@openstatus/db/src/schema"; import { getLimits } from "@openstatus/db/src/schema/plan/utils"; +import { EmailClient } from "@openstatus/emails"; import { TRPCError } from "@trpc/server"; import type Stripe from "stripe"; import { z } from "zod"; +import { env } from "../../env"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { stripe } from "./shared"; import { buildLimitsFromSubscription } from "./utils"; +const emailClient = new EmailClient({ apiKey: env.RESEND_API_KEY }); + // An unsupported price is a permanent misconfiguration; surface it as a 400 so // Stripe stops retrying instead of hammering the endpoint on a 5xx. function buildFromSubscriptionOrThrow(subscription: Stripe.Subscription) { @@ -112,6 +116,27 @@ export const webhookRouter = createTRPCRouter({ const newPlan = built.plan; if (newPlan !== oldPlan) { + const planOrder = ["free", "starter", "team", "scale"] as const; + const oldIndex = planOrder.indexOf(oldPlan ?? "free"); + const newIndex = planOrder.indexOf(newPlan ?? "free"); + + if (newIndex < oldIndex) { + const members = await opts.ctx.db + .select({ email: user.email }) + .from(usersToWorkspaces) + .innerJoin(user, eq(usersToWorkspaces.userId, user.id)) + .where(eq(usersToWorkspaces.workspaceId, result.id)); + + await emailClient.sendPlanDowngrade({ + to: members + .map((m) => m.email) + .filter((email): email is string => Boolean(email)), + workspaceName: ws.name, + oldPlan, + newPlan, + }); + } + const customer = await stripe.customers.retrieve(customerId); if (!customer.deleted && customer.email) { const userResult = await opts.ctx.db @@ -121,10 +146,6 @@ export const webhookRouter = createTRPCRouter({ .get(); if (!userResult) return; - const planOrder = ["free", "starter", "team", "scale"] as const; - const oldIndex = planOrder.indexOf(oldPlan ?? "free"); - const newIndex = planOrder.indexOf(newPlan ?? "free"); - const event = newIndex > oldIndex ? Events.UpgradeWorkspace @@ -223,106 +244,120 @@ export const webhookRouter = createTRPCRouter({ return; } - const _workspace = await opts.ctx.db.transaction(async (tx) => { - const _workspace = await tx - .update(workspace) - .set({ - subscriptionId: null, - plan: "free", - paidUntil: null, - endsAt: null, - limits: JSON.stringify(getLimits("free")), - }) - .where(eq(workspace.stripeId, customerId)) - .returning(); - - if (!_workspace.length) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Workspace not found", - }); - } - - const workspaceId = _workspace[0].id; - - const activeMonitors = await tx - .select({ id: monitor.id }) - .from(monitor) - .where( - and( - eq(monitor.workspaceId, workspaceId), - eq(monitor.active, true), - isNull(monitor.deletedAt), - ), - ) - .orderBy(asc(monitor.createdAt)); - - for (const m of activeMonitors.slice(1)) { + const { _workspace, memberEmails } = await opts.ctx.db.transaction( + async (tx) => { + const _workspace = await tx + .update(workspace) + .set({ + subscriptionId: null, + plan: "free", + paidUntil: null, + endsAt: null, + limits: JSON.stringify(getLimits("free")), + }) + .where(eq(workspace.stripeId, customerId)) + .returning(); + + if (!_workspace.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Workspace not found", + }); + } + + const workspaceId = _workspace[0].id; + + const activeMonitors = await tx + .select({ id: monitor.id }) + .from(monitor) + .where( + and( + eq(monitor.workspaceId, workspaceId), + eq(monitor.active, true), + isNull(monitor.deletedAt), + ), + ) + .orderBy(asc(monitor.createdAt)); + + for (const m of activeMonitors.slice(1)) { + await tx + .update(monitor) + .set({ active: false, updatedAt: new Date() }) + .where(eq(monitor.id, m.id)) + .run(); + } + + const statusPages = await tx + .select({ id: page.id }) + .from(page) + .where(eq(page.workspaceId, workspaceId)) + .orderBy(asc(page.createdAt)); + + for (const p of statusPages.slice(1)) { + await tx.delete(page).where(eq(page.id, p.id)).run(); + } + + if (statusPages.length > 0) { + await tx + .update(page) + .set({ + customDomain: "", + password: null, + accessType: "public", + authEmailDomains: null, + updatedAt: new Date(), + }) + .where(eq(page.id, statusPages[0].id)) + .run(); + } + + const notifications = await tx + .select({ id: notification.id, provider: notification.provider }) + .from(notification) + .where(eq(notification.workspaceId, workspaceId)) + .orderBy(asc(notification.createdAt)); + + const keepNotification = + notifications.find((n) => n.provider === "email") ?? notifications[0]; + + for (const n of notifications.filter( + (n) => n.id !== keepNotification?.id, + )) { + await tx.delete(notification).where(eq(notification.id, n.id)).run(); + } + + // Collect member emails before non-owner members are removed below + const members = await tx + .select({ email: user.email }) + .from(usersToWorkspaces) + .innerJoin(user, eq(usersToWorkspaces.userId, user.id)) + .where(eq(usersToWorkspaces.workspaceId, workspaceId)); + + // Remove all non-owner members from the workspace await tx - .update(monitor) - .set({ active: false, updatedAt: new Date() }) - .where(eq(monitor.id, m.id)) + .delete(usersToWorkspaces) + .where( + and( + eq(usersToWorkspaces.workspaceId, workspaceId), + ne(usersToWorkspaces.role, "owner"), + ), + ) .run(); - } - - const statusPages = await tx - .select({ id: page.id }) - .from(page) - .where(eq(page.workspaceId, workspaceId)) - .orderBy(asc(page.createdAt)); - - for (const p of statusPages.slice(1)) { - await tx.delete(page).where(eq(page.id, p.id)).run(); - } - if (statusPages.length > 0) { + // Remove all pending invitations for the workspace await tx - .update(page) - .set({ - customDomain: "", - password: null, - accessType: "public", - authEmailDomains: null, - updatedAt: new Date(), - }) - .where(eq(page.id, statusPages[0].id)) + .delete(invitation) + .where(eq(invitation.workspaceId, workspaceId)) .run(); - } - const notifications = await tx - .select({ id: notification.id, provider: notification.provider }) - .from(notification) - .where(eq(notification.workspaceId, workspaceId)) - .orderBy(asc(notification.createdAt)); - - const keepNotification = - notifications.find((n) => n.provider === "email") ?? notifications[0]; - - for (const n of notifications.filter( - (n) => n.id !== keepNotification?.id, - )) { - await tx.delete(notification).where(eq(notification.id, n.id)).run(); - } - - // Remove all non-owner members from the workspace - await tx - .delete(usersToWorkspaces) - .where( - and( - eq(usersToWorkspaces.workspaceId, workspaceId), - ne(usersToWorkspaces.role, "owner"), - ), - ) - .run(); - - // Remove all pending invitations for the workspace - await tx - .delete(invitation) - .where(eq(invitation.workspaceId, workspaceId)) - .run(); - - return _workspace; - }); + return { + _workspace, + memberEmails: members + .map((m) => m.email) + .filter((email): email is string => Boolean(email)), + }; + }, + ); if (!_workspace[0]) { throw new TRPCError({ @@ -332,6 +367,13 @@ export const webhookRouter = createTRPCRouter({ } const workspaceId = _workspace[0].id; + + await emailClient.sendPlanDowngrade({ + to: memberEmails, + workspaceName: _workspace[0].name, + newPlan: "free", + }); + const customer = await stripe.customers.retrieve(customerId); if (!customer.deleted && customer.email) { diff --git a/packages/emails/emails/plan-downgrade.tsx b/packages/emails/emails/plan-downgrade.tsx new file mode 100644 index 00000000..d8cc3ba4 --- /dev/null +++ b/packages/emails/emails/plan-downgrade.tsx @@ -0,0 +1,65 @@ +/** @jsxImportSource react */ + +import { Body, Head, Heading, Html, Link, Preview, Text } from "react-email"; +import { z } from "zod"; + +import { Layout } from "./_components/layout"; +import { styles } from "./_components/styles"; + +export const PlanDowngradeSchema = z.object({ + workspaceName: z.string().optional().nullable(), + oldPlan: z.string().optional().nullable(), + newPlan: z.string(), +}); + +export type PlanDowngradeProps = z.infer; + +const PlanDowngradeEmail = ({ + workspaceName, + oldPlan, + newPlan, +}: PlanDowngradeProps) => { + return ( + + + Your OpenStatus workspace plan has been downgraded + + + + {workspaceName + ? `The "${workspaceName}" workspace` + : "Your workspace"}{" "} + has been downgraded to the {newPlan} plan + + + {oldPlan + ? `Your workspace plan changed from ${oldPlan} to ${newPlan}.` + : `Your workspace is now on the ${newPlan} plan.`}{" "} + Some features and limits may no longer be available. Monitors, + status pages, notification channels or team members exceeding the + new plan limits can be affected. + + + Review your workspace in the{" "} + + billing settings + + . + + If you have any questions, please reply to this email. + + + + ); +}; + +PlanDowngradeEmail.PreviewProps = { + workspaceName: "OpenStatus", + oldPlan: "team", + newPlan: "starter", +} satisfies PlanDowngradeProps; + +export default PlanDowngradeEmail; diff --git a/packages/emails/src/client.tsx b/packages/emails/src/client.tsx index 275293ef..3fabd77e 100644 --- a/packages/emails/src/client.tsx +++ b/packages/emails/src/client.tsx @@ -8,6 +8,8 @@ import FollowUpEmail from "../emails/followup"; import type { MonitorAlertProps } from "../emails/monitor-alert"; import PageSubscriptionEmail from "../emails/page-subscription"; import type { PageSubscriptionProps } from "../emails/page-subscription"; +import PlanDowngradeEmail from "../emails/plan-downgrade"; +import type { PlanDowngradeProps } from "../emails/plan-downgrade"; import SlackFeedbackEmail from "../emails/slack-feedback"; import StatusPageMagicLinkEmail from "../emails/status-page-magic-link"; import type { StatusPageMagicLinkProps } from "../emails/status-page-magic-link"; @@ -266,6 +268,39 @@ export class EmailClient { } } + public async sendPlanDowngrade(req: PlanDowngradeProps & { to: string[] }) { + if (req.to.length === 0) return; + + if (process.env.NODE_ENV === "development") { + console.log(`Sending plan downgrade emails to ${req.to.join(", ")}`); + return; + } + + const html = await render(); + + for (const recipients of chunk(req.to, 100)) { + const result = await this.client.batch.send( + recipients.map((member) => ({ + from: "OpenStatus ", + subject: `Your workspace has been downgraded to the ${req.newPlan} plan`, + to: member, + html, + })), + ); + + if (result.error) { + if (result.error?.name === "rate_limit_exceeded") { + throw result.error; + } + console.error( + `Error sending plan downgrade emails to ${recipients}: ${result.error}`, + ); + } + } + + console.log(`Sent plan downgrade emails to ${req.to.join(", ")}`); + } + public async sendMonitorAlert(req: MonitorAlertProps & { to: string }) { if (process.env.NODE_ENV === "development") { console.log(`Sending monitor alert email to ${req.to}`); diff --git a/packages/emails/src/index.ts b/packages/emails/src/index.ts index d8c9f0dd..87c65c8e 100644 --- a/packages/emails/src/index.ts +++ b/packages/emails/src/index.ts @@ -8,6 +8,7 @@ export { default as TeamInvitationEmail } from "../emails/team-invitation"; export { default as MonitorPausedEmail } from "../emails/monitor-paused"; export { default as MonitorDeactivationEmail } from "../emails/monitor-deactivation"; export { default as StatusPageMagicLinkEmail } from "../emails/status-page-magic-link"; +export { default as PlanDowngradeEmail } from "../emails/plan-downgrade"; export { monitorDeactivationEmail } from "../hotfix/monitor-deactivation"; export { monitorPausedEmail } from "../hotfix/monitor-paused";