diff --git a/packages/api/src/router/stripe/index.ts b/packages/api/src/router/stripe/index.ts index 90ac92837..d6d7d4b99 100644 --- a/packages/api/src/router/stripe/index.ts +++ b/packages/api/src/router/stripe/index.ts @@ -32,6 +32,7 @@ import { getCurrentSubscription, hasPaymentMethod, stripe, + syncedLimits, trialEndsAtOf, } from "./shared"; import { @@ -232,11 +233,9 @@ export const stripeRouter = createTRPCRouter({ }); } - // Classify before mutating Stripe. An item on a price neither table - // knows throws, and throwing *after* the update would leave the - // customer re-priced and billed while the workspace kept the old plan - // — a split the webhook cannot repair either, since it throws on the - // same item. + // Classify before mutating Stripe: throwing *after* the update would + // leave the customer re-priced and billed while the workspace kept the + // old plan. Custom-deal prices were already refused above. buildFromSubscriptionOrThrow(current); // Every existing item is listed by id, so Stripe re-prices it in place @@ -275,7 +274,7 @@ export const stripeRouter = createTRPCRouter({ endsAt: getCurrentPeriodEnd(updated), paidUntil: getCurrentPeriodEnd(updated), trialEndsAt: trialEndsAtOf(updated), - limits: built.limits, + limits: syncedLimits(updated, built), reason: "plan_changed", }, }); diff --git a/packages/api/src/router/stripe/shared.ts b/packages/api/src/router/stripe/shared.ts index faabc0d18..fe325152e 100644 --- a/packages/api/src/router/stripe/shared.ts +++ b/packages/api/src/router/stripe/shared.ts @@ -14,8 +14,8 @@ export const stripe = new Stripe(env.STRIPE_SECRET_KEY || "sk_unset", { }, }); -// An unsupported price is a permanent misconfiguration; surface it as a 400 so -// Stripe stops retrying instead of hammering the endpoint on a 5xx. +// A subscription we cannot classify is a permanent misconfiguration; surface +// it as a 400 so Stripe stops retrying instead of hammering the endpoint. export function buildFromSubscriptionOrThrow( subscription: Stripe.Subscription, ) { @@ -30,6 +30,18 @@ export function buildFromSubscriptionOrThrow( } } +/** The limits to write, or undefined to keep a custom deal's hand-set ones. */ +export function syncedLimits( + subscription: Stripe.Subscription, + built: NonNullable>, +) { + if (built.customPriceIds.length === 0) return built.limits; + console.warn( + `Subscription ${subscription.id} has custom prices (${built.customPriceIds.join(", ")}); keeping workspace limits`, + ); + return undefined; +} + // Statuses that mean the customer still has a subscription. `incomplete` and // `incomplete_expired` are abandoned checkouts, `unpaid` and `paused` no longer // entitle anything, and `canceled` is gone. diff --git a/packages/api/src/router/stripe/utils.test.ts b/packages/api/src/router/stripe/utils.test.ts index 54be41d5c..50378bae3 100644 --- a/packages/api/src/router/stripe/utils.test.ts +++ b/packages/api/src/router/stripe/utils.test.ts @@ -103,12 +103,13 @@ describe("buildLimitsFromSubscription", () => { expect(built?.limits["status-pages"]).toBe(planDefault + 5); }); - test("throws on an unsupported price when a plan is present", () => { - expect(() => - buildLimitsFromSubscription( - subscriptionWith([{ priceId: STARTER }, { priceId: "price_unknown" }]), - ), - ).toThrow(/unsupported stripe price/i); + test("reports a custom price instead of throwing", () => { + const built = buildLimitsFromSubscription( + subscriptionWith([{ priceId: STARTER }, { priceId: "price_custom" }]), + ); + expect(built?.plan).toBe("starter"); + expect(built?.customPriceIds).toEqual(["price_custom"]); + expect(built?.limits).toEqual(getLimits("starter")); }); test("one pack addon unit grants `packSize` limit units", () => { diff --git a/packages/api/src/router/stripe/utils.ts b/packages/api/src/router/stripe/utils.ts index b15ad4cca..6abffec0c 100644 --- a/packages/api/src/router/stripe/utils.ts +++ b/packages/api/src/router/stripe/utils.ts @@ -22,12 +22,13 @@ type PriceIds = { priceIds: { test: string; production: string } }; * The plan item sets the baseline; each addon item then re-applies its flag or * quantity on top, so purchased addons survive subscription updates instead of * being reset to the plan default. Returns null when no plan item is present. - * Throws on a line item whose price is neither a known plan nor a known addon, - * so misconfigured prices surface instead of silently drifting from billing. + * A line item on a price neither table knows is a custom deal: its id is + * returned in `customPriceIds` and left out of `limits`, so callers keep the + * hand-set limits instead of overwriting them. */ export function buildLimitsFromSubscription( subscription: Stripe.Subscription, -): { plan: WorkspacePlan; limits: Limits } | null { +): { plan: WorkspacePlan; limits: Limits; customPriceIds: string[] } | null { const detectedPlan = subscription.items.data .map((item) => getPlanFromPriceId(item.price.id)) .find((plan) => plan !== undefined); @@ -35,14 +36,14 @@ export function buildLimitsFromSubscription( if (!detectedPlan) return null; let limits: Limits = getLimits(detectedPlan.plan); + const customPriceIds: string[] = []; for (const item of subscription.items.data) { if (getPlanFromPriceId(item.price.id)) continue; const feature = getFeatureFromPriceId(item.price.id); if (!feature) { - throw new Error( - `Unsupported Stripe price on subscription: ${item.price.id}`, - ); + customPriceIds.push(item.price.id); + continue; } // Accumulate onto the running value so repeated addon items add up; boolean // addons just flip on. One unit of a pack addon grants `packSize` units. @@ -54,7 +55,7 @@ export function buildLimitsFromSubscription( limits = updateAddonInLimits(limits, feature.feature, value); } - return { plan: detectedPlan.plan, limits }; + return { plan: detectedPlan.plan, limits, customPriceIds }; } /** diff --git a/packages/api/src/router/stripe/webhook.test.ts b/packages/api/src/router/stripe/webhook.test.ts index 212f5c058..b928588e6 100644 --- a/packages/api/src/router/stripe/webhook.test.ts +++ b/packages/api/src/router/stripe/webhook.test.ts @@ -466,6 +466,46 @@ describe("stripe webhook emails", () => { expect(keys.slice(0, 2)).toEqual(keys.slice(2, 4)); }); + test("a custom-deal price syncs the plan but keeps hand-set limits", async () => { + const s = await seed(); + const handSet = JSON.stringify({ monitors: 999 }); + await db + .update(workspace) + .set({ limits: handSet }) + .where(eq(workspace.id, s.workspace.id)); + const sub = subscription(s.stripeId, { + id: "sub_custom_deal", + items: { + data: [ + { + price: { id: TEAM_PRICE }, + quantity: 1, + current_period_end: now() + 10 * DAY, + }, + { + price: { id: "price_custom_deal" }, + quantity: 1, + current_period_end: now() + 10 * DAY, + }, + ], + }, + } as Partial); + live = [sub]; + + await caller().customerSubscriptionUpdated( + event("customer.subscription.updated", sub, { metadata: {} }), + ); + + const ws = await db + .select() + .from(workspace) + .where(eq(workspace.id, s.workspace.id)) + .get(); + expect(ws?.subscriptionId).toBe("sub_custom_deal"); + expect(ws?.plan).toBe("team"); + expect(ws?.limits).toBe(handSet); + }); + test("a mail failure does not fail the plan sync", async () => { const s = await seed(); const sub = subscription(s.stripeId, { cancel_at_period_end: true }); @@ -561,6 +601,45 @@ describe("stripe webhook emails", () => { assertSpyCalls(send, 0); }); + test("a mail failure fails the webhook so Stripe redelivers", async () => { + const s = await seed(); + failSends(); + const evt = event( + "customer.subscription.trial_will_end", + subscription(s.stripeId, { status: "trialing", trial_end: trialEnd }), + ); + + await expect( + caller().customerSubscriptionTrialWillEnd(evt), + ).rejects.toThrow(); + }); + + test("a Resend error response fails the webhook too", async () => { + const s = await seed(); + send.restore(); + stubs = stubs.filter((x) => x !== send); + send = stub(resend.emails, "send", () => + Promise.resolve({ + data: null, + error: { name: "application_error", message: "boom" }, + // biome-ignore lint/suspicious/noExplicitAny: Resend result double + } as any), + ); + stubs.push(send); + + await expect( + caller().customerSubscriptionTrialWillEnd( + event( + "customer.subscription.trial_will_end", + subscription(s.stripeId, { + status: "trialing", + trial_end: trialEnd, + }), + ), + ), + ).rejects.toThrow(); + }); + test("no trial_end → nothing sent", async () => { const s = await seed(); await caller().customerSubscriptionTrialWillEnd( diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 693775fab..7a3cfc211 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -36,6 +36,7 @@ import { isNewerSubscription, listLiveSubscriptions, stripe, + syncedLimits, trialEndsAtOf, } from "./shared"; @@ -267,7 +268,7 @@ export const webhookRouter = createTRPCRouter({ endsAt: getCurrentPeriodEnd(current), paidUntil: getCurrentPeriodEnd(current), trialEndsAt: trialEndsAtOf(current), - limits: built.limits, + limits: syncedLimits(current, built), }, }); @@ -427,7 +428,7 @@ export const webhookRouter = createTRPCRouter({ endsAt: getCurrentPeriodEnd(subscription), paidUntil: getCurrentPeriodEnd(subscription), trialEndsAt: trialEndsAtOf(subscription), - limits: built.limits, + limits: syncedLimits(subscription, built), reason: "checkout_session_completed", }, }); @@ -474,31 +475,33 @@ export const webhookRouter = createTRPCRouter({ }); } - try { - const withCard = await hasPaymentMethod(subscription); - const preview = withCard - ? undefined - : await previewWorkspaceDowngrade({ - ctx: { - workspace: ws, - actor: { type: "system", job: "stripe-trial-will-end" }, - db: opts.ctx.db, - }, - }); - await sendTrialEnding({ - to: await getBillingRecipients(opts.ctx.db, ws.id, customerId), - eventId: opts.input.event.id, - workspaceSlug: ws.slug, - trialEnd: new Date(subscription.trial_end * 1000), - plan: ws.plan ?? "starter", - hasPaymentMethod: withCard, - loss: preview - ? toPlanLoss(preview, preview.customDomains, preview.ssoEnabled) - : undefined, - }); - } catch (err) { - console.error("Failed to send trial ending email:", err); - } + // Stripe fires this once per trial, so a failure must reach Stripe as a + // non-2xx to be redelivered; the idempotency key dedupes the retry. + const withCard = await hasPaymentMethod(subscription); + const preview = withCard + ? undefined + : await previewWorkspaceDowngrade({ + ctx: { + workspace: ws, + actor: { type: "system", job: "stripe-trial-will-end" }, + db: opts.ctx.db, + }, + }).catch((err) => { + // The reminder matters more than the loss lines. + console.error("Failed to preview trial downgrade:", err); + return undefined; + }); + await sendTrialEnding({ + to: await getBillingRecipients(opts.ctx.db, ws.id, customerId), + eventId: opts.input.event.id, + workspaceSlug: ws.slug, + trialEnd: new Date(subscription.trial_end * 1000), + plan: ws.plan ?? "starter", + hasPaymentMethod: withCard, + loss: preview + ? toPlanLoss(preview, preview.customDomains, preview.ssoEnabled) + : undefined, + }); }), customerSubscriptionDeleted: webhookProcedure.mutation(async (opts) => { const subscription = opts.input.event.data.object as Stripe.Subscription; diff --git a/packages/emails/src/billing.tsx b/packages/emails/src/billing.tsx index 037818070..bb0060745 100644 --- a/packages/emails/src/billing.tsx +++ b/packages/emails/src/billing.tsx @@ -223,6 +223,9 @@ export async function sendTrialEnding( subject: trialEndingSubject(props), react: , }, - { idempotencyKey: stripeIdempotencyKey(eventId, "trial-ending") }, + { + idempotencyKey: stripeIdempotencyKey(eventId, "trial-ending"), + throwOnError: true, + }, ); } diff --git a/packages/emails/src/send.ts b/packages/emails/src/send.ts index 265521746..3fb4e790a 100644 --- a/packages/emails/src/send.ts +++ b/packages/emails/src/send.ts @@ -31,6 +31,8 @@ export interface SendOptions { idempotencyKey?: string; /** ISO 8601. Resend schedules at most 30 days out. */ scheduledAt?: string; + /** Throw instead of logging, so a webhook caller fails and gets redelivered. */ + throwOnError?: boolean; } /** Returns the Resend email id, or undefined when nothing was sent. */ @@ -49,6 +51,11 @@ export const sendEmail = async ( ); // Same key, different body: the first send already went out. if (error && error.name !== "invalid_idempotent_request") { + if (opts.throwOnError) { + throw new Error( + `Error sending email "${email.subject}": ${error.message}`, + ); + } console.error(`Error sending email "${email.subject}":`, error); } return data?.id; diff --git a/packages/services/src/workspace/schemas.ts b/packages/services/src/workspace/schemas.ts index ff795cbfc..7a0819d4c 100644 --- a/packages/services/src/workspace/schemas.ts +++ b/packages/services/src/workspace/schemas.ts @@ -83,7 +83,8 @@ export type UpdateWorkspaceNameInput = z.infer; * Set a workspace's billing plan and the columns that move with it * (subscription id, paid-until / ends-at dates, feature limits). Driven * by the Stripe webhook — `limits` is the structured object; the verb - * serialises it to the `text` column. `reason` is stamped into the audit + * serialises it to the `text` column. Omitted `limits` leaves the stored + * ones untouched (custom deals whose limits are set by hand). `reason` is stamped into the audit * row's `metadata` so a plan change from an involuntary cancellation is * distinguishable from a checkout upgrade. */ @@ -93,7 +94,7 @@ export const UpdateWorkspacePlanInput = z.object({ paidUntil: z.date().nullable(), endsAt: z.date().nullable(), trialEndsAt: z.date().nullable().optional(), - limits: limitsSchema, + limits: limitsSchema.optional(), reason: z.string().optional(), }); export type UpdateWorkspacePlanInput = z.infer; diff --git a/packages/services/src/workspace/update.ts b/packages/services/src/workspace/update.ts index 8b3492dba..cfd2ca2aa 100644 --- a/packages/services/src/workspace/update.ts +++ b/packages/services/src/workspace/update.ts @@ -87,7 +87,9 @@ export async function updateWorkspacePlan(args: { ...(input.trialEndsAt !== undefined && { trialEndsAt: input.trialEndsAt, }), - limits: JSON.stringify(input.limits), + ...(input.limits !== undefined && { + limits: JSON.stringify(input.limits), + }), updatedAt: new Date(), }) .where(eq(workspace.id, ctx.workspace.id))