import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { safeParse } from "valibot"; import { db } from "~/server/db"; import { stripe } from "~/server/stripe"; import { users } from "~/server/db/schema/auth"; import { subscriptions } from "~/server/db/schema/subscription"; import type Stripe from "stripe"; import { CheckoutSessionSchema, SubscriptionSchema, InvoiceSchema, } from "~/server/api/schemas/webhook"; type Tier = "basic" | "plus" | "premium"; export async function getOrCreateCustomer(userId: string, email: string) { const [user] = await db .select() .from(users) .where(eq(users.id, userId)) .limit(1); if (!user) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); } if (user.stripeCustomerId) { return user.stripeCustomerId; } const customer = await stripe.customers.create({ email, metadata: { userId }, }); await db .update(users) .set({ stripeCustomerId: customer.id }) .where(eq(users.id, userId)); return customer.id; } export async function createCheckoutSession( userId: string, email: string, priceId: string, returnUrl: string, ) { const customerId = await getOrCreateCustomer(userId, email); const session = await stripe.checkout.sessions.create({ customer: customerId, mode: "subscription", ui_mode: "embedded_page", line_items: [{ price: priceId, quantity: 1 }], return_url: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`, metadata: { userId }, }); return { clientSecret: session.client_secret ?? "", sessionId: session.id }; } export async function createPortalSession(customerId: string, returnUrl: string) { const session = await stripe.billingPortal.sessions.create({ customer: customerId, return_url: returnUrl, }); return { url: session.url }; } export async function cancelSubscription(stripeSubscriptionId: string) { await stripe.subscriptions.update(stripeSubscriptionId, { cancel_at_period_end: true, }); await db .update(subscriptions) .set({ cancelAtPeriodEnd: true }) .where(eq(subscriptions.stripeId, stripeSubscriptionId)); return { cancelAtPeriodEnd: true }; } export async function reactivateSubscription(stripeSubscriptionId: string) { await stripe.subscriptions.update(stripeSubscriptionId, { cancel_at_period_end: false, }); await db .update(subscriptions) .set({ cancelAtPeriodEnd: false }) .where(eq(subscriptions.stripeId, stripeSubscriptionId)); return { cancelAtPeriodEnd: false }; } export async function listInvoices( customerId: string, limit: number = 10, startingAfter?: string, ) { const params: Stripe.InvoiceListParams = { customer: customerId, limit, }; if (startingAfter) { params.starting_after = startingAfter; } const invoices = await stripe.invoices.list(params); return { invoices: invoices.data, hasMore: invoices.has_more, }; } export async function updateSubscriptionInDB( stripeId: string, data: { tier?: Tier; status?: string; currentPeriodStart?: Date; currentPeriodEnd?: Date; cancelAtPeriodEnd?: boolean; }, ) { const [existing] = await db .select() .from(subscriptions) .where(eq(subscriptions.stripeId, stripeId)) .limit(1); if (existing) { const [updated] = await db .update(subscriptions) .set(data as Record) .where(eq(subscriptions.stripeId, stripeId)) .returning(); return updated; } return null; } function safeParseSubscription(obj: unknown) { const result = safeParse(SubscriptionSchema, obj); if (!result.success) { console.error(`[webhook] Failed to parse subscription data: ${result.issues?.map((i) => i.message).join(", ")}`); return null; } return result.output; } function safeParseCheckoutSession(obj: unknown) { const result = safeParse(CheckoutSessionSchema, obj); if (!result.success) { console.error(`[webhook] Failed to parse checkout session data: ${result.issues?.map((i) => i.message).join(", ")}`); return null; } return result.output; } function safeParseInvoice(obj: unknown) { const result = safeParse(InvoiceSchema, obj); if (!result.success) { console.error(`[webhook] Failed to parse invoice data: ${result.issues?.map((i) => i.message).join(", ")}`); return null; } return result.output; } export async function handleWebhookEvent(event: Stripe.Event) { switch (event.type) { case "checkout.session.completed": { const session = safeParseCheckoutSession(event.data.object); if (!session) break; const userId = session.metadata?.userId; if (!userId || !session.subscription) break; const stripeSub = await stripe.subscriptions.retrieve(session.subscription); // Fetch fresh subscription data from Stripe for accurate fields const subData = stripeSub as unknown as Record; await db.insert(subscriptions).values({ userId, stripeId: stripeSub.id, tier: mapStripeProductToTier( stripeSub.items.data[0]?.price?.id ?? "", ), status: (subData.status as typeof subscriptions.$inferSelect.status) ?? "active", currentPeriodStart: subData.current_period_start ? new Date((subData.current_period_start as number) * 1000) : undefined, currentPeriodEnd: subData.current_period_end ? new Date((subData.current_period_end as number) * 1000) : undefined, cancelAtPeriodEnd: Boolean(subData.cancel_at_period_end), }).onConflictDoNothing(); break; } case "invoice.paid": { const invoice = safeParseInvoice(event.data.object); if (!invoice?.subscription) break; await updateSubscriptionInDB(invoice.subscription, { status: "active", }); break; } case "invoice.payment_failed": { const invoice = safeParseInvoice(event.data.object); if (!invoice?.subscription) break; await updateSubscriptionInDB(invoice.subscription, { status: "past_due", }); break; } case "customer.subscription.updated": { const validatedSub = safeParseSubscription(event.data.object); if (!validatedSub) break; const userId = validatedSub.metadata?.userId; if (!userId) { const [existingSub] = await db .select() .from(subscriptions) .where(eq(subscriptions.stripeId, validatedSub.id)) .limit(1); if (!existingSub) break; } const tier = validatedSub.items?.data?.price?.id ? mapStripeProductToTier(validatedSub.items.data.price.id) : undefined; await updateSubscriptionInDB(validatedSub.id, { tier, status: validatedSub.status ?? undefined, currentPeriodStart: validatedSub.current_period_start ? new Date(validatedSub.current_period_start * 1000) : undefined, currentPeriodEnd: validatedSub.current_period_end ? new Date(validatedSub.current_period_end * 1000) : undefined, cancelAtPeriodEnd: validatedSub.cancel_at_period_end ?? undefined, }); break; } case "customer.subscription.deleted": { const stripeSub = safeParseSubscription(event.data.object); if (!stripeSub) break; await updateSubscriptionInDB(stripeSub.id, { status: "canceled", }); break; } } } export function mapStripeProductToTier(priceId: string): Tier { if (priceId === process.env.STRIPE_PRICE_BASIC) return "basic"; if (priceId === process.env.STRIPE_PRICE_PLUS) return "plus"; if (priceId === process.env.STRIPE_PRICE_PREMIUM) return "premium"; return "basic"; }