diff --git a/apps/web/src/app/api/stripe/webhook/route.ts b/apps/web/src/app/api/stripe/webhook/route.ts index 07afbbeab..d5caef535 100644 --- a/apps/web/src/app/api/stripe/webhook/route.ts +++ b/apps/web/src/app/api/stripe/webhook/route.ts @@ -49,240 +49,270 @@ export async function POST(request: NextRequest) { ); } - switch (event.type) { - case "checkout.session.completed": { - const checkoutSession = event.data.object as Stripe.Checkout.Session; + try { + switch (event.type) { + case "checkout.session.completed": { + const checkoutSession = event.data.object as Stripe.Checkout.Session; - if (checkoutSession.subscription === null) { - // This is a one-time payment (probably for Rallly Self-Hosted) - break; - } + if (checkoutSession.subscription === null) { + // This is a one-time payment (probably for Rallly Self-Hosted) + break; + } - const { userId } = checkoutMetadataSchema.parse(checkoutSession.metadata); - - if (!userId) { - return NextResponse.json( - { error: "Missing client reference ID" }, - { status: 400 }, + const { userId } = checkoutMetadataSchema.parse( + checkoutSession.metadata, ); - } - await prisma.user.update({ - where: { - id: userId, - }, - data: { - customerId: checkoutSession.customer as string, - }, - }); + if (!userId) { + return NextResponse.json( + { error: "Missing client reference ID" }, + { status: 400 }, + ); + } - const subscription = await stripe.subscriptions.retrieve( - checkoutSession.subscription as string, - ); - - posthog?.capture({ - distinctId: userId, - event: "upgrade", - properties: { - interval: subscription.items.data[0].plan.interval, - $set: { - tier: "pro", + await prisma.user.update({ + where: { + id: userId, }, - }, - }); + data: { + customerId: checkoutSession.customer as string, + }, + }); - break; - } - case "customer.subscription.deleted": { - const { id } = event.data.object as Stripe.Subscription; - const subscription = await stripe.subscriptions.retrieve(id); - - // void any unpaid invoices - const invoices = await stripe.invoices.list({ - subscription: subscription.id, - status: "open", - }); - - for (const invoice of invoices.data) { - await stripe.invoices.voidInvoice(invoice.id); - } - - // delete the subscription from the database - await prisma.subscription.delete({ - where: { - id: subscription.id, - }, - }); - - try { - const { userId } = subscriptionMetadataSchema.parse( - subscription.metadata, + const subscription = await stripe.subscriptions.retrieve( + checkoutSession.subscription as string, ); posthog?.capture({ distinctId: userId, - event: "subscription cancel", + event: "upgrade", properties: { + interval: subscription.items.data[0].price.recurring?.interval, $set: { - tier: "hobby", + tier: "pro", }, }, }); - } catch (e) { - Sentry.captureException(e); + + break; } + case "customer.subscription.deleted": { + const { id } = event.data.object as Stripe.Subscription; + const subscription = await stripe.subscriptions.retrieve(id); - break; - } - case "customer.subscription.updated": - case "customer.subscription.created": { - const { id } = event.data.object as Stripe.Subscription; + // void any unpaid invoices + const invoices = await stripe.invoices.list({ + subscription: subscription.id, + status: "open", + }); - const subscription = await stripe.subscriptions.retrieve(id); + for (const invoice of invoices.data) { + await stripe.invoices.voidInvoice(invoice.id); + } - // check if the subscription is active - const isActive = - subscription.status === "active" || - subscription.status === "trialing" || - subscription.status === "past_due"; + // delete the subscription from the database + await prisma.subscription.delete({ + where: { + id: subscription.id, + }, + }); - // get the subscription price details - const lineItem = subscription.items.data[0]; + try { + const { userId } = subscriptionMetadataSchema.parse( + subscription.metadata, + ); - // update/create the subscription in the database - const { price } = lineItem; + posthog?.capture({ + distinctId: userId, + event: "subscription cancel", + properties: { + $set: { + tier: "hobby", + }, + }, + }); + } catch (e) { + Sentry.captureException(e); + } - const res = subscriptionMetadataSchema.safeParse(subscription.metadata); - - if (!res.success) { - return NextResponse.json({ error: "Missing user ID" }, { status: 400 }); + break; } + case "customer.subscription.updated": + case "customer.subscription.created": { + const { id } = event.data.object as Stripe.Subscription; - // create or update the subscription in the database - await prisma.subscription.upsert({ - where: { - id: subscription.id, - }, - create: { - id: subscription.id, - active: isActive, - priceId: price.id, - currency: subscription.currency ?? null, - createdAt: toDate(subscription.created), - periodStart: toDate(subscription.current_period_start), - periodEnd: toDate(subscription.current_period_end), - }, - update: { - active: isActive, - priceId: price.id, - currency: subscription.currency ?? null, - createdAt: toDate(subscription.created), - periodStart: toDate(subscription.current_period_start), - periodEnd: toDate(subscription.current_period_end), - }, - }); + const subscription = await stripe.subscriptions.retrieve(id); - // update the user with the subscription id - await prisma.user.update({ - where: { - id: res.data.userId, - }, - data: { - subscriptionId: subscription.id, - }, - }); + // check if the subscription is active + const isActive = + subscription.status === "active" || + subscription.status === "trialing" || + subscription.status === "past_due"; - try { - posthog?.capture({ - distinctId: res.data.userId, - event: "subscription change", - properties: { - type: event.type, - $set: { - tier: isActive ? "pro" : "hobby", + // get the subscription price details + const lineItem = subscription.items.data[0]; + + // update/create the subscription in the database + const { price } = lineItem; + + const res = subscriptionMetadataSchema.safeParse(subscription.metadata); + + if (!res.success) { + return NextResponse.json( + { error: "Missing user ID" }, + { status: 400 }, + ); + } + + const subscriptionItem = subscription.items.data[0]; + const interval = subscriptionItem.price.recurring?.interval; + + if (!interval) { + throw new Error( + `Missing interval in subscription ${subscription.id}`, + ); + } + // create or update the subscription in the database + await prisma.subscription.upsert({ + where: { + id: subscription.id, + }, + create: { + id: subscription.id, + active: isActive, + priceId: price.id, + currency: subscriptionItem.price.currency, + interval, + amount: subscriptionItem.price.unit_amount, + status: subscription.status, + createdAt: toDate(subscription.created), + periodStart: toDate(subscription.current_period_start), + periodEnd: toDate(subscription.current_period_end), + }, + update: { + active: isActive, + priceId: price.id, + currency: subscriptionItem.price.currency, + interval, + amount: subscriptionItem.price.unit_amount, + status: subscription.status, + createdAt: toDate(subscription.created), + periodStart: toDate(subscription.current_period_start), + periodEnd: toDate(subscription.current_period_end), + }, + }); + + // update the user with the subscription id + await prisma.user.update({ + where: { + id: res.data.userId, + }, + data: { + subscriptionId: subscription.id, + }, + }); + + try { + posthog?.capture({ + distinctId: res.data.userId, + event: "subscription change", + properties: { + type: event.type, + $set: { + tier: isActive ? "pro" : "hobby", + }, + }, + }); + } catch (e) { + Sentry.captureException(e); + } + + break; + } + case "checkout.session.expired": { + console.info("Checkout session expired"); + const session = event.data.object as Stripe.Checkout.Session; + // When a Checkout Session expires, the customer's email isn't returned in + // the webhook payload unless they give consent for promotional content + const email = session.customer_details?.email; + const recoveryUrl = session.after_expiration?.recovery?.url; + const userId = session.metadata?.userId; + if (!userId) { + console.info("No user ID found in Checkout Session metadata"); + Sentry.captureMessage( + "No user ID found in Checkout Session metadata", + ); + break; + } + // Do nothing if the Checkout Session has no email or recovery URL + if (!email || !recoveryUrl) { + console.info("No email or recovery URL found in Checkout Session"); + Sentry.captureMessage( + "No email or recovery URL found in Checkout Session", + ); + break; + } + const promoEmailKey = `promo_email_sent:${email}`; + // Track that a promotional email opportunity has been shown to this user + const hasReceivedPromo = await kv.get(promoEmailKey); + console.info("Has received promo", hasReceivedPromo); + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + locale: true, + subscription: { + select: { + active: true, + }, }, }, }); - } catch (e) { - Sentry.captureException(e); - } - break; - } - case "checkout.session.expired": { - console.info("Checkout session expired"); - const session = event.data.object as Stripe.Checkout.Session; - // When a Checkout Session expires, the customer's email isn't returned in - // the webhook payload unless they give consent for promotional content - const email = session.customer_details?.email; - const recoveryUrl = session.after_expiration?.recovery?.url; - const userId = session.metadata?.userId; - if (!userId) { - console.info("No user ID found in Checkout Session metadata"); - Sentry.captureMessage("No user ID found in Checkout Session metadata"); + const isPro = !!user?.subscription?.active; + + // Avoid spamming people who abandon Checkout multiple times + if (user && !hasReceivedPromo && !isPro) { + console.info("Sending abandoned checkout email"); + // Set the flag with a 30-day expiration (in seconds) + await kv.set(promoEmailKey, 1, { ex: 30 * 24 * 60 * 60, nx: true }); + getEmailClient(user.locale ?? undefined).sendTemplate( + "AbandonedCheckoutEmail", + { + to: email, + from: { + name: "Luke from Rallly", + address: "luke@rallly.co", + }, + props: { + name: session.customer_details?.name ?? undefined, + discount: 20, + couponCode: "GETPRO1Y20", + recoveryUrl, + }, + }, + ); + } + break; } - // Do nothing if the Checkout Session has no email or recovery URL - if (!email || !recoveryUrl) { - console.info("No email or recovery URL found in Checkout Session"); - Sentry.captureMessage( - "No email or recovery URL found in Checkout Session", + default: + Sentry.captureException( + new Error(`Unhandled event type: ${event.type}`), ); - break; - } - const promoEmailKey = `promo_email_sent:${email}`; - // Track that a promotional email opportunity has been shown to this user - const hasReceivedPromo = await kv.get(promoEmailKey); - console.info("Has received promo", hasReceivedPromo); - - const user = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - locale: true, - subscription: { - select: { - active: true, - }, - }, - }, - }); - - const isPro = !!user?.subscription?.active; - - // Avoid spamming people who abandon Checkout multiple times - if (user && !hasReceivedPromo && !isPro) { - console.info("Sending abandoned checkout email"); - // Set the flag with a 30-day expiration (in seconds) - await kv.set(promoEmailKey, 1, { ex: 30 * 24 * 60 * 60, nx: true }); - getEmailClient(user.locale ?? undefined).sendTemplate( - "AbandonedCheckoutEmail", - { - to: email, - from: { - name: "Luke from Rallly", - address: "luke@rallly.co", - }, - props: { - name: session.customer_details?.name ?? undefined, - discount: 20, - couponCode: "GETPRO1Y20", - recoveryUrl, - }, - }, + // Unexpected event type + return NextResponse.json( + { error: "Unhandled event type" }, + { status: 400 }, ); - } - - break; } - default: - Sentry.captureException(new Error(`Unhandled event type: ${event.type}`)); - // Unexpected event type - return NextResponse.json( - { error: "Unhandled event type" }, - { status: 400 }, - ); + } catch (err) { + const error = + err instanceof Error ? err.message : "An unexpected error occurred"; + Sentry.captureException(err); + return NextResponse.json({ error }, { status: 500 }); } waitUntil(Promise.all([posthog?.shutdown()])); diff --git a/packages/billing/package.json b/packages/billing/package.json index 850f7b9d3..b53f62504 100644 --- a/packages/billing/package.json +++ b/packages/billing/package.json @@ -9,6 +9,7 @@ "scripts": { "normalize-subscription-metadata": "dotenv -e ../../.env -- tsx ./src/scripts/normalize-metadata.ts", "checkout-expiry": "dotenv -e ../../.env -- tsx ./src/scripts/checkout-expiry.ts", + "subscription-data-sync": "dotenv -e ../../.env -- tsx ./src/scripts/subscription-data-sync.ts", "type-check": "tsc --pretty --noEmit", "lint": "eslint ./src" }, diff --git a/packages/billing/src/scripts/subscription-data-sync.ts b/packages/billing/src/scripts/subscription-data-sync.ts new file mode 100644 index 000000000..521b70438 --- /dev/null +++ b/packages/billing/src/scripts/subscription-data-sync.ts @@ -0,0 +1,55 @@ +import { prisma } from "@rallly/database"; + +import { stripe } from "../lib/stripe"; + +(async function syncSubscriptionData() { + const BATCH_SIZE = 10; + let processed = 0; + let failed = 0; + + const userSubscriptions = await prisma.subscription.findMany({ + select: { + id: true, + }, + take: BATCH_SIZE, + }); + + console.info(`🚀 Syncing ${userSubscriptions.length} subscriptions...`) + + for (const userSubscription of userSubscriptions) { + try { + const subscription = await stripe.subscriptions.retrieve( + userSubscription.id, + ); + + const subscriptionItem = subscription.items.data[0]; + const interval = subscriptionItem.price.recurring?.interval; + + if (!interval) { + console.info(`🚨 Missing interval in subscription ${subscription.id}`); + + failed++; + continue; + } + + await prisma.subscription.update({ + where: { + id: subscription.id, + }, + data: { + amount: subscriptionItem.price.unit_amount, + currency: subscriptionItem.price.currency, + interval: subscriptionItem.price.recurring?.interval, + status: subscription.status, + }, + }); + + console.info(`✅ Subscription ${subscription.id} synced`); + processed++; + } catch (error) { + console.error(`❌ Failed to sync subscription ${userSubscription.id}:`, error); + failed++; + } + } + + console.info(`📊 Sync complete: ${processed} processed, ${failed} failed`); +})(); \ No newline at end of file diff --git a/packages/database/prisma/migrations/20250217082042_add_subscription_fields/migration.sql b/packages/database/prisma/migrations/20250217082042_add_subscription_fields/migration.sql new file mode 100644 index 000000000..5be24cb7e --- /dev/null +++ b/packages/database/prisma/migrations/20250217082042_add_subscription_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "subscriptions" ADD COLUMN "amount" INTEGER, +ADD COLUMN "status" TEXT; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 9595ac44d..6d112dc94 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -88,6 +88,8 @@ model UserPaymentData { model Subscription { id String @id priceId String @map("price_id") + amount Int? + status String? active Boolean currency String? interval String?