diff --git a/apps/web/src/app/api/stripe/webhook/handlers/checkout/completed.ts b/apps/web/src/app/api/stripe/webhook/handlers/checkout/completed.ts new file mode 100644 index 000000000..4506e2671 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/checkout/completed.ts @@ -0,0 +1,57 @@ +import { type Stripe, stripe } from "@rallly/billing"; +import { prisma } from "@rallly/database"; +import { posthog } from "@rallly/posthog/server"; +import { z } from "zod"; + +import { createOrUpdatePaymentMethod } from "../utils"; + +const checkoutMetadataSchema = z.object({ + userId: z.string(), +}); + +export async function onCheckoutSessionCompleted(event: Stripe.Event) { + const checkoutSession = event.data.object as Stripe.Checkout.Session; + + if (checkoutSession.subscription === null) { + // This is a one-time payment (probably for Rallly Self-Hosted) + return; + } + + const { userId } = checkoutMetadataSchema.parse(checkoutSession.metadata); + + if (!userId) { + return; + } + + const customerId = checkoutSession.customer as string; + + await prisma.user.update({ + where: { + id: userId, + }, + data: { + customerId, + }, + }); + + const paymentMethods = await stripe.customers.listPaymentMethods(customerId); + + const [paymentMethod] = paymentMethods.data; + + await createOrUpdatePaymentMethod(userId, paymentMethod); + + const subscription = await stripe.subscriptions.retrieve( + checkoutSession.subscription as string, + ); + + posthog?.capture({ + distinctId: userId, + event: "upgrade", + properties: { + interval: subscription.items.data[0].price.recurring?.interval, + $set: { + tier: "pro", + }, + }, + }); +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/checkout/expired.ts b/apps/web/src/app/api/stripe/webhook/handlers/checkout/expired.ts new file mode 100644 index 000000000..3175b3cd2 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/checkout/expired.ts @@ -0,0 +1,69 @@ +import type { Stripe } from "@rallly/billing"; +import { prisma } from "@rallly/database"; +import * as Sentry from "@sentry/nextjs"; +import { kv } from "@vercel/kv"; + +import { getEmailClient } from "@/utils/emails"; + +export async function onCheckoutSessionExpired(event: Stripe.Event) { + 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) { + Sentry.captureMessage("No user ID found in Checkout Session metadata"); + return; + } + + // Do nothing if the Checkout Session has no email or recovery URL + if (!email || !recoveryUrl) { + Sentry.captureMessage("No email or recovery URL found in Checkout Session"); + return; + } + + const promoEmailKey = `promo_email_sent:${email}`; + // Track that a promotional email opportunity has been shown to this user + const hasReceivedPromo = await kv.get(promoEmailKey); + + 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, + }, + }, + ); + } +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/checkout/index.ts b/apps/web/src/app/api/stripe/webhook/handlers/checkout/index.ts new file mode 100644 index 000000000..f8e656308 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/checkout/index.ts @@ -0,0 +1,2 @@ +export * from "./completed"; +export * from "./expired"; diff --git a/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/created.ts b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/created.ts new file mode 100644 index 000000000..4013a3376 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/created.ts @@ -0,0 +1,49 @@ +import type { Stripe } from "@rallly/billing"; +import { prisma } from "@rallly/database"; + +import { + getExpandedSubscription, + getSubscriptionDetails, + isSubscriptionActive, + subscriptionMetadataSchema, + toDate, +} from "../utils"; + +export async function onCustomerSubscriptionCreated(event: Stripe.Event) { + const subscription = await getExpandedSubscription( + (event.data.object as Stripe.Subscription).id, + ); + + const isActive = isSubscriptionActive(subscription); + const { priceId, currency, interval, amount } = + getSubscriptionDetails(subscription); + + const res = subscriptionMetadataSchema.safeParse(subscription.metadata); + + if (!res.success) { + throw new Error("Missing user ID"); + } + + // Create and update user + await prisma.user.update({ + where: { + id: res.data.userId, + }, + data: { + subscription: { + create: { + id: subscription.id, + active: isActive, + priceId, + currency, + interval, + amount, + status: subscription.status, + createdAt: toDate(subscription.created), + periodStart: toDate(subscription.current_period_start), + periodEnd: toDate(subscription.current_period_end), + }, + }, + }, + }); +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/deleted.ts b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/deleted.ts new file mode 100644 index 000000000..ff53dd131 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/deleted.ts @@ -0,0 +1,44 @@ +import type { Stripe } from "@rallly/billing"; +import { stripe } from "@rallly/billing"; +import { prisma } from "@rallly/database"; +import { posthog } from "@rallly/posthog/server"; +import { z } from "zod"; + +const subscriptionMetadataSchema = z.object({ + userId: z.string(), +}); + +export async function onCustomerSubscriptionDeleted(event: Stripe.Event) { + const subscription = await stripe.subscriptions.retrieve( + (event.data.object as Stripe.Subscription).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, + }, + }); + + const { userId } = subscriptionMetadataSchema.parse(subscription.metadata); + + posthog?.capture({ + distinctId: userId, + event: "subscription cancel", + properties: { + $set: { + tier: "hobby", + }, + }, + }); +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/index.ts b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/index.ts new file mode 100644 index 000000000..f0e3ec474 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/index.ts @@ -0,0 +1,3 @@ +export * from "./created"; +export * from "./deleted"; +export * from "./updated"; diff --git a/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/updated.ts b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/updated.ts new file mode 100644 index 000000000..87b550c72 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/updated.ts @@ -0,0 +1,60 @@ +import type { Stripe } from "@rallly/billing"; +import { prisma } from "@rallly/database"; +import { posthog } from "@rallly/posthog/server"; + +import { + getExpandedSubscription, + getSubscriptionDetails, + isSubscriptionActive, + subscriptionMetadataSchema, + toDate, +} from "../utils"; + +export async function onCustomerSubscriptionUpdated(event: Stripe.Event) { + if (event.type !== "customer.subscription.updated") { + return; + } + + const subscription = await getExpandedSubscription( + (event.data.object as Stripe.Subscription).id, + ); + + const isActive = isSubscriptionActive(subscription); + const { priceId, currency, interval, amount } = + getSubscriptionDetails(subscription); + + const res = subscriptionMetadataSchema.safeParse(subscription.metadata); + + if (!res.success) { + throw new Error("Missing user ID"); + } + + // Update the subscription in the database + await prisma.subscription.update({ + where: { + id: subscription.id, + }, + data: { + active: isActive, + priceId, + currency, + interval, + amount, + status: subscription.status, + periodStart: toDate(subscription.current_period_start), + periodEnd: toDate(subscription.current_period_end), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + }, + }); + + posthog?.capture({ + distinctId: res.data.userId, + event: "subscription change", + properties: { + type: event.type, + $set: { + tier: isActive ? "pro" : "hobby", + }, + }, + }); +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/index.ts b/apps/web/src/app/api/stripe/webhook/handlers/index.ts new file mode 100644 index 000000000..a95a6a154 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/index.ts @@ -0,0 +1,36 @@ +import type { Stripe } from "@rallly/billing"; + +import { onCheckoutSessionCompleted } from "./checkout/completed"; +import { onCheckoutSessionExpired } from "./checkout/expired"; +import { onCustomerSubscriptionCreated } from "./customer-subscription/created"; +import { onCustomerSubscriptionDeleted } from "./customer-subscription/deleted"; +import { onCustomerSubscriptionUpdated } from "./customer-subscription/updated"; +import { + onPaymentMethodAttached, + onPaymentMethodDetached, + onPaymentMethodUpdated, +} from "./payment-method"; + +export function getEventHandler(eventType: Stripe.Event["type"]) { + switch (eventType) { + case "checkout.session.completed": + return onCheckoutSessionCompleted; + case "checkout.session.expired": + return onCheckoutSessionExpired; + case "customer.subscription.created": + return onCustomerSubscriptionCreated; + case "customer.subscription.deleted": + return onCustomerSubscriptionDeleted; + case "customer.subscription.updated": + return onCustomerSubscriptionUpdated; + case "payment_method.attached": + return onPaymentMethodAttached; + case "payment_method.detached": + return onPaymentMethodDetached; + case "payment_method.automatically_updated": + case "payment_method.updated": + return onPaymentMethodUpdated; + default: + return null; + } +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/payment-method/attached.ts b/apps/web/src/app/api/stripe/webhook/handlers/payment-method/attached.ts new file mode 100644 index 000000000..54c7b2466 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/payment-method/attached.ts @@ -0,0 +1,27 @@ +import type { Stripe } from "@rallly/billing"; +import { prisma } from "@rallly/database"; + +import { createOrUpdatePaymentMethod } from "../utils"; + +export async function onPaymentMethodAttached(event: Stripe.Event) { + const paymentMethod = event.data.object as Stripe.PaymentMethod; + + // Only handle payment methods that are attached to a customer + if (!paymentMethod.customer) { + return; + } + + // Find the user associated with this customer + const user = await prisma.user.findFirst({ + where: { + customerId: paymentMethod.customer as string, + }, + }); + + if (!user) { + throw new Error(`No user found for customer ${paymentMethod.customer}`); + } + + // Upsert the payment method in our database + await createOrUpdatePaymentMethod(user.id, paymentMethod); +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/payment-method/detached.ts b/apps/web/src/app/api/stripe/webhook/handlers/payment-method/detached.ts new file mode 100644 index 000000000..1432345c2 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/payment-method/detached.ts @@ -0,0 +1,13 @@ +import type { Stripe } from "@rallly/billing"; +import { prisma } from "@rallly/database"; + +export async function onPaymentMethodDetached(event: Stripe.Event) { + const paymentMethod = event.data.object as Stripe.PaymentMethod; + + // Delete the payment method from our database + await prisma.paymentMethod.delete({ + where: { + id: paymentMethod.id, + }, + }); +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/payment-method/index.ts b/apps/web/src/app/api/stripe/webhook/handlers/payment-method/index.ts new file mode 100644 index 000000000..e00523692 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/payment-method/index.ts @@ -0,0 +1,3 @@ +export * from "./attached"; +export * from "./detached"; +export * from "./updated"; diff --git a/apps/web/src/app/api/stripe/webhook/handlers/payment-method/updated.ts b/apps/web/src/app/api/stripe/webhook/handlers/payment-method/updated.ts new file mode 100644 index 000000000..f1e3744e3 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/payment-method/updated.ts @@ -0,0 +1,21 @@ +import type { Stripe } from "@rallly/billing"; +import { type Prisma, prisma } from "@rallly/database"; + +export async function onPaymentMethodUpdated(event: Stripe.Event) { + const paymentMethod = event.data.object as Stripe.PaymentMethod; + + if (!paymentMethod.customer) { + return; + } + + // Update the payment method data in our database + await prisma.paymentMethod.update({ + where: { + id: paymentMethod.id, + }, + data: { + type: paymentMethod.type, + data: paymentMethod[paymentMethod.type] as Prisma.JsonObject, + }, + }); +} diff --git a/apps/web/src/app/api/stripe/webhook/handlers/utils.ts b/apps/web/src/app/api/stripe/webhook/handlers/utils.ts new file mode 100644 index 000000000..8b3e63596 --- /dev/null +++ b/apps/web/src/app/api/stripe/webhook/handlers/utils.ts @@ -0,0 +1,72 @@ +import type { Stripe } from "@rallly/billing"; +import { stripe } from "@rallly/billing"; +import type { Prisma } from "@rallly/database"; +import { prisma } from "@rallly/database"; +import { z } from "zod"; + +export const subscriptionMetadataSchema = z.object({ + userId: z.string(), +}); + +export function toDate(date: number) { + return new Date(date * 1000); +} + +export async function getExpandedSubscription(subscriptionId: string) { + return stripe.subscriptions.retrieve(subscriptionId, { + expand: ["items.data.price.currency_options"], + }); +} + +export function isSubscriptionActive(subscription: Stripe.Subscription) { + return ( + subscription.status === "active" || + subscription.status === "trialing" || + subscription.status === "past_due" + ); +} + +export function getSubscriptionDetails(subscription: Stripe.Subscription) { + const subscriptionItem = subscription.items.data[0]; + const interval = subscriptionItem.price.recurring?.interval; + const currency = subscription.currency; + const amount = + subscriptionItem.price.currency_options?.[currency]?.unit_amount ?? + subscriptionItem.price.unit_amount; + + if (!interval) { + throw new Error(`Missing interval in subscription ${subscription.id}`); + } + + if (!amount) { + throw new Error(`Missing amount in subscription ${subscription.id}`); + } + + return { + interval, + currency, + amount, + priceId: subscriptionItem.price.id, + }; +} + +export async function createOrUpdatePaymentMethod( + userId: string, + paymentMethod: Stripe.PaymentMethod, +) { + await prisma.paymentMethod.upsert({ + where: { + id: paymentMethod.id, + }, + create: { + id: paymentMethod.id, + userId, + type: paymentMethod.type, + data: paymentMethod[paymentMethod.type] as Prisma.JsonObject, + }, + update: { + type: paymentMethod.type, + data: paymentMethod[paymentMethod.type] as Prisma.JsonObject, + }, + }); +} diff --git a/apps/web/src/app/api/stripe/webhook/route.ts b/apps/web/src/app/api/stripe/webhook/route.ts index 6378a4607..4c538faa2 100644 --- a/apps/web/src/app/api/stripe/webhook/route.ts +++ b/apps/web/src/app/api/stripe/webhook/route.ts @@ -1,36 +1,19 @@ import type { Stripe } from "@rallly/billing"; import { stripe } from "@rallly/billing"; -import { prisma } from "@rallly/database"; -import { posthog } from "@rallly/posthog/server"; +import { withPosthog } from "@rallly/posthog/server"; import * as Sentry from "@sentry/nextjs"; -import { waitUntil } from "@vercel/functions"; -import { kv } from "@vercel/kv"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { z } from "zod"; -import { getEmailClient } from "@/utils/emails"; +import { getEventHandler } from "./handlers"; -const checkoutMetadataSchema = z.object({ - userId: z.string(), -}); - -const subscriptionMetadataSchema = z.object({ - userId: z.string(), -}); - -function toDate(date: number) { - return new Date(date * 1000); -} - -export async function POST(request: NextRequest) { +export const POST = withPosthog(async (request: NextRequest) => { const body = await request.text(); const sig = request.headers.get("stripe-signature")!; const stripeSigningSecret = process.env.STRIPE_SIGNING_SECRET; if (!stripeSigningSecret) { Sentry.captureException(new Error("STRIPE_SIGNING_SECRET is not set")); - return NextResponse.json( { error: "STRIPE_SIGNING_SECRET is not set" }, { status: 500 }, @@ -50,284 +33,26 @@ export async function POST(request: NextRequest) { } try { - switch (event.type) { - case "checkout.session.completed": { - const checkoutSession = event.data.object as Stripe.Checkout.Session; + const handler = getEventHandler(event.type); - 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 }, - ); - } - - await prisma.user.update({ - where: { - id: userId, - }, - data: { - customerId: checkoutSession.customer as string, - }, - }); - - const subscription = await stripe.subscriptions.retrieve( - checkoutSession.subscription as string, - ); - - posthog?.capture({ - distinctId: userId, - event: "upgrade", - properties: { - interval: subscription.items.data[0].price.recurring?.interval, - $set: { - tier: "pro", - }, - }, - }); - - 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, - ); - - posthog?.capture({ - distinctId: userId, - event: "subscription cancel", - properties: { - $set: { - tier: "hobby", - }, - }, - }); - } catch (e) { - Sentry.captureException(e); - } - - break; - } - case "customer.subscription.updated": - case "customer.subscription.created": { - const { id } = event.data.object as Stripe.Subscription; - - const subscription = await stripe.subscriptions.retrieve(id, { - expand: ["items.data.price.currency_options"], - }); - - // check if the subscription is active - const isActive = - subscription.status === "active" || - subscription.status === "trialing" || - subscription.status === "past_due"; - - // 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; - const currency = subscription.currency; - const amount = - subscriptionItem.price.currency_options?.[currency]?.unit_amount ?? - subscriptionItem.price.unit_amount; - - if (!interval) { - throw new Error( - `Missing interval in subscription ${subscription.id}`, - ); - } - - if (!amount) { - throw new Error(`Missing amount 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, - interval, - 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, - interval, - amount, - status: subscription.status, - createdAt: toDate(subscription.created), - periodStart: toDate(subscription.current_period_start), - periodEnd: toDate(subscription.current_period_end), - cancelAtPeriodEnd: subscription.cancel_at_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, - }, - }, - }, - }); - - 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; - } - default: - Sentry.captureException( - new Error(`Unhandled event type: ${event.type}`), - ); - // Unexpected event type - return NextResponse.json( - { error: "Unhandled event type" }, - { status: 400 }, - ); + if (!handler) { + Sentry.captureException(new Error(`Unhandled event type: ${event.type}`)); + return NextResponse.json( + { error: "Unhandled event type" }, + { status: 400 }, + ); } + + await handler(event); + + return NextResponse.json({ received: true }); } catch (err) { - const error = - err instanceof Error ? err.message : "An unexpected error occurred"; Sentry.captureException(err); - return NextResponse.json({ error }, { status: 500 }); + return NextResponse.json( + { + error: `Webhook Error: ${err instanceof Error ? err.message : "Unknown error"}`, + }, + { status: 400 }, + ); } - - waitUntil(Promise.all([posthog?.shutdown()])); - - return NextResponse.json({ received: true }, { status: 200 }); -} +}); diff --git a/package.json b/package.json index 3e939d448..b26afa4e8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:reset": "prisma migrate reset", + "db:push": "prisma db push", "docker:up": "docker compose -f docker-compose.dev.yml up -d && wait-on --timeout 60000 tcp:localhost:5450", "docker:down": "docker compose -f docker-compose.dev.yml down --volumes --remove-orphans", "test:integration": "turbo test:integration", diff --git a/packages/billing/package.json b/packages/billing/package.json index b53f62504..cbf279a12 100644 --- a/packages/billing/package.json +++ b/packages/billing/package.json @@ -10,6 +10,7 @@ "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", + "sync-payment-methods": "dotenv -e ../../.env -- tsx ./src/scripts/sync-payment-methods.ts", "type-check": "tsc --pretty --noEmit", "lint": "eslint ./src" }, diff --git a/packages/billing/src/scripts/sync-cancel-at-period-end.ts b/packages/billing/src/scripts/sync-cancel-at-period-end.ts deleted file mode 100644 index a7ff6eb66..000000000 --- a/packages/billing/src/scripts/sync-cancel-at-period-end.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { prisma } from "@rallly/database"; - -import { stripe } from "../lib/stripe"; - -(async function syncCancelAtPeriodEnd() { - let processed = 0; - let failed = 0; - - const userSubscriptions = await prisma.subscription.findMany({ - select: { - id: true, - }, - }); - - console.info(`🚀 Syncing ${userSubscriptions.length} subscriptions...`); - - for (const userSubscription of userSubscriptions) { - try { - const subscription = await stripe.subscriptions.retrieve( - userSubscription.id, - ); - - await prisma.subscription.update({ - where: { - id: subscription.id, - }, - data: { - cancelAtPeriodEnd: subscription.cancel_at_period_end, - }, - }); - - 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`); -})(); diff --git a/packages/billing/src/scripts/sync-payment-methods.ts b/packages/billing/src/scripts/sync-payment-methods.ts new file mode 100644 index 000000000..f15884e63 --- /dev/null +++ b/packages/billing/src/scripts/sync-payment-methods.ts @@ -0,0 +1,53 @@ +import type { Prisma } from "@rallly/database"; +import { prisma } from "@rallly/database"; + +import { stripe } from "../lib/stripe"; + +(async function syncPaymentMethods() { + let processed = 0; + let failed = 0; + + const users = await prisma.user.findMany({ + select: { + id: true, + customerId: true, + email: true, + }, + where: { + customerId: { + not: null, + }, + }, + }); + + console.info(`🚀 Syncing ${users.length} users...`); + + for (const user of users) { + if (!user.customerId) continue; + try { + const paymentMethods = await stripe.customers.listPaymentMethods( + user.customerId, + ); + + await prisma.paymentMethod.createMany({ + data: paymentMethods.data.map((paymentMethod) => ({ + id: paymentMethod.id, + userId: user.id, + type: paymentMethod.type, + data: paymentMethod[paymentMethod.type] as Prisma.JsonObject, + })), + }); + + console.info(`✅ Payment methods synced for user ${user.email}`); + processed++; + } catch (error) { + console.error( + `❌ Failed to sync payment methods for user ${user.email}:`, + error, + ); + failed++; + } + } + + console.info(`📊 Sync complete: ${processed} processed, ${failed} failed`); +})(); diff --git a/packages/database/prisma/migrations/20250222172325_add_payment_method_table/migration.sql b/packages/database/prisma/migrations/20250222172325_add_payment_method_table/migration.sql new file mode 100644 index 000000000..d9bfb6d03 --- /dev/null +++ b/packages/database/prisma/migrations/20250222172325_add_payment_method_table/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "payment_methods" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "data" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "payment_methods_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "payment_methods" ADD CONSTRAINT "payment_methods_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 5ddf556a3..387c484ab 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -51,12 +51,13 @@ model User { customerId String? @map("customer_id") subscriptionId String? @unique @map("subscription_id") - comments Comment[] - polls Poll[] - watcher Watcher[] - events Event[] - accounts Account[] - participants Participant[] + comments Comment[] + polls Poll[] + watcher Watcher[] + events Event[] + accounts Account[] + participants Participant[] + paymentMethods PaymentMethod[] subscription Subscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull) @@ -82,6 +83,19 @@ enum SubscriptionInterval { @@map("subscription_interval") } +model PaymentMethod { + id String @id + userId String @map("user_id") + type String + data Json + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("payment_methods") +} + model UserPaymentData { userId String @id @map("user_id") subscriptionId String @map("subscription_id")