From c3d49c237c49a0c1106ebcf9a9b3d709ef8b1259 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Mon, 6 Jul 2026 20:11:50 -0300 Subject: [PATCH] cancel trialing subscriptions when trial payment validation fails --- .../customer-subscription-trial-will-end.ts | 157 ++++++++++++++++++ apps/web/app/(ee)/api/stripe/webhook/route.ts | 5 + .../[idOrSlug]/billing/upgrade/route.ts | 5 + 3 files changed, 167 insertions(+) create mode 100644 apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts diff --git a/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts b/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts new file mode 100644 index 00000000000..5293748de4e --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-trial-will-end.ts @@ -0,0 +1,157 @@ +import { prisma } from "@/lib/prisma"; +import { stripe } from "@/lib/stripe"; +import { getPlanAndTierFromPriceId, log } from "@dub/utils"; +import Stripe from "stripe"; + +export async function customerSubscriptionTrialWillEnd( + event: Stripe.CustomerSubscriptionTrialWillEndEvent, +) { + const subscription = event.data.object; + + if (subscription.status !== "trialing") { + return `Subscription ${subscription.id} is not trialing, skipping...`; + } + + const priceId = subscription.items.data[0]?.price.id; + if (!priceId) { + return `Subscription ${subscription.id} has no price, skipping...`; + } + + const { plan } = getPlanAndTierFromPriceId({ priceId }); + if (!plan) { + return `Invalid price ID in trial_will_end event: ${priceId}, skipping...`; + } + + const stripeId = subscription.customer.toString(); + + const workspace = await prisma.project.findUnique({ + where: { stripeId }, + select: { slug: true }, + }); + + if (!workspace) { + return `Workspace with Stripe ID ${stripeId} not found, skipping...`; + } + + const customer = await stripe.customers.retrieve(stripeId); + const paymentMethodId = getPaymentMethodId(subscription, customer); + + if (!paymentMethodId) { + const skipMessage = await cancelTrialingSubscription({ + subscriptionId: subscription.id, + comment: "Trial ended without a payment method on file.", + }); + if (skipMessage) { + return skipMessage; + } + + await log({ + message: `Canceled trialing subscription ${subscription.id} for workspace ${workspace.slug} (missing payment method).`, + type: "cron", + }); + + return `Canceled subscription ${subscription.id} for workspace ${workspace.slug} due to missing payment method.`; + } + + try { + const upcomingInvoice = await stripe.invoices.createPreview({ + customer: stripeId, + subscription: subscription.id, + }); + + const { amount_due: amount, currency } = upcomingInvoice; + + if (!amount || amount <= 0) { + return `Subscription ${subscription.id} has no amount due on upcoming invoice, skipping validation.`; + } + + const paymentIntent = await stripe.paymentIntents.create({ + amount, + currency, + customer: stripeId, + payment_method: paymentMethodId, + confirm: true, + off_session: true, + capture_method: "manual", + }); + + if (paymentIntent.status === "requires_capture") { + await stripe.paymentIntents.cancel(paymentIntent.id); + return `Validated payment method for subscription ${subscription.id} (workspace ${workspace.slug}).`; + } + + const skipMessage = await cancelTrialingSubscription({ + subscriptionId: subscription.id, + comment: `Trial payment validation returned status: ${paymentIntent.status}`, + }); + if (skipMessage) { + return skipMessage; + } + + return `Canceled subscription ${subscription.id} due to unexpected payment validation status (${paymentIntent.status}).`; + } catch (error) { + if (error instanceof Stripe.errors.StripeCardError) { + const skipMessage = await cancelTrialingSubscription({ + subscriptionId: subscription.id, + comment: `Trial payment validation failed: ${error.message}`, + }); + if (skipMessage) { + return skipMessage; + } + + await log({ + message: `Canceled trialing subscription ${subscription.id} for workspace ${workspace.slug} after failed payment validation (${error.decline_code ?? error.code}).`, + type: "cron", + mention: true, + }); + + return `Canceled subscription ${subscription.id} for workspace ${workspace.slug} due to failed payment validation.`; + } + + throw error; + } +} + +function getPaymentMethodId( + subscription: Stripe.Subscription, + customer: Stripe.Customer | Stripe.DeletedCustomer, +): string | null { + if (subscription.default_payment_method) { + return typeof subscription.default_payment_method === "string" + ? subscription.default_payment_method + : subscription.default_payment_method.id; + } + + if (customer.deleted) { + return null; + } + + const defaultPaymentMethod = + customer.invoice_settings?.default_payment_method; + if (!defaultPaymentMethod) { + return null; + } + + return typeof defaultPaymentMethod === "string" + ? defaultPaymentMethod + : defaultPaymentMethod.id; +} + +async function cancelTrialingSubscription({ + subscriptionId, + comment, +}: { + subscriptionId: string; + comment: string; +}) { + const current = await stripe.subscriptions.retrieve(subscriptionId); + if (current.status !== "trialing") { + return `Subscription ${subscriptionId} is no longer trialing, skipping cancel.`; + } + + await stripe.subscriptions.cancel(subscriptionId, { + cancellation_details: { comment }, + }); + + return null; +} diff --git a/apps/web/app/(ee)/api/stripe/webhook/route.ts b/apps/web/app/(ee)/api/stripe/webhook/route.ts index 17c5e8d69d9..1d78f785fb1 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/route.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/route.ts @@ -8,6 +8,7 @@ import { chargeSucceeded } from "./charge-succeeded"; import { checkoutSessionCompleted } from "./checkout-session-completed"; import { customerSubscriptionCreated } from "./customer-subscription-created"; import { customerSubscriptionDeleted } from "./customer-subscription-deleted"; +import { customerSubscriptionTrialWillEnd } from "./customer-subscription-trial-will-end"; import { customerSubscriptionUpdated } from "./customer-subscription-updated"; import { invoicePaymentFailed } from "./invoice-payment-failed"; import { paymentIntentRequiresAction } from "./payment-intent-requires-action"; @@ -20,6 +21,7 @@ const relevantEvents = new Set([ "checkout.session.completed", "customer.subscription.created", "customer.subscription.updated", + "customer.subscription.trial_will_end", "customer.subscription.deleted", "invoice.payment_failed", "payment_intent.requires_action", @@ -70,6 +72,9 @@ export const POST = async (req: Request) => { case "customer.subscription.updated": response = await customerSubscriptionUpdated(event); break; + case "customer.subscription.trial_will_end": + response = await customerSubscriptionTrialWillEnd(event); + break; case "customer.subscription.deleted": response = await customerSubscriptionDeleted(event); break; diff --git a/apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts b/apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts index 8a118c2d8fb..17c344f8dc0 100644 --- a/apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts +++ b/apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts @@ -148,6 +148,11 @@ export const POST = withWorkspace( ? { subscription_data: { trial_period_days: DUB_TRIAL_PERIOD_DAYS, + trial_settings: { + end_behavior: { + missing_payment_method: "cancel", + }, + }, }, } : {}),