import Stripe from "stripe"; import { eq, desc } from "drizzle-orm"; import { getDb, getSubscription, getUserById, updateSubscription, getConfigValue } from "../db.js"; import { subscriptions } from "../schema.js"; let stripeInstance: Stripe | null = null; let stripeInstanceKey: string | null = null; async function resolveStripeKey(): Promise { const dbKey = await getConfigValue("STRIPE_SECRET_KEY"); if (dbKey) return dbKey; return process.env.STRIPE_SECRET_KEY ?? null; } export async function getStripe(): Promise { const key = await resolveStripeKey(); if (!key) { throw new Error("STRIPE_SECRET_KEY is not set"); } if (stripeInstance && stripeInstanceKey === key) return stripeInstance; stripeInstance = new Stripe(key, { apiVersion: "2026-04-22.dahlia", typescript: true, }); stripeInstanceKey = key; return stripeInstance; } export async function isStripeConfigured(): Promise { const key = await resolveStripeKey(); return Boolean(key); } async function resolvePriceId(envName: string, configKey: string): Promise { const dbVal = await getConfigValue(configKey); if (dbVal) return dbVal; return process.env[envName] ?? null; } function publicBaseUrl(): string { const url = process.env.PUBLIC_BASE_URL ?? ""; if (!url) { throw new Error("PUBLIC_BASE_URL is not set"); } return url.replace(/\/$/, ""); } async function ensureStripeCustomer(userId: number): Promise { const sub = await getSubscription(userId); if (sub?.stripeCustomerId) return sub.stripeCustomerId; const user = await getUserById(userId); if (!user) throw new Error("User not found"); const stripe = await getStripe(); const customer = await stripe.customers.create({ email: user.email, name: user.name ?? undefined, metadata: { userId: String(userId) }, }); await updateSubscription(userId, { stripeCustomerId: customer.id }); return customer.id; } export async function createCheckoutSession( userId: number, priceId: string ): Promise<{ url: string; sessionId: string }> { const stripe = await getStripe(); const customerId = await ensureStripeCustomer(userId); const base = publicBaseUrl(); const session = await stripe.checkout.sessions.create({ mode: "subscription", customer: customerId, line_items: [{ price: priceId, quantity: 1 }], success_url: `${base}/subscription/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${base}/subscription/cancel`, allow_promotion_codes: true, subscription_data: { metadata: { userId: String(userId) } }, metadata: { userId: String(userId) }, }); if (!session.url) throw new Error("Stripe Checkout did not return a URL"); return { url: session.url, sessionId: session.id }; } export async function createPortalSession(userId: number): Promise<{ url: string }> { const stripe = await getStripe(); const sub = await getSubscription(userId); if (!sub?.stripeCustomerId) { throw new Error("No Stripe customer for this user yet"); } const base = publicBaseUrl(); const portal = await stripe.billingPortal.sessions.create({ customer: sub.stripeCustomerId, return_url: `${base}/dashboard/subscription`, }); return { url: portal.url }; } export async function getSubscriptionStatus(userId: number): Promise<{ plan: "trial" | "basic" | "pro" | null; status: string | null; currentPeriodEnd: Date | null; trialEndsAt: Date | null; stripeCustomerId: string | null; stripeSubscriptionId: string | null; } | null> { const sub = await getSubscription(userId); if (!sub) return null; return { plan: sub.plan, status: sub.status, currentPeriodEnd: sub.currentPeriodEnd, trialEndsAt: sub.trialEndsAt, stripeCustomerId: sub.stripeCustomerId, stripeSubscriptionId: sub.stripeSubscriptionId, }; } async function planFromPriceId(priceId: string | null | undefined): Promise<"basic" | "pro"> { const proPriceId = await resolvePriceId("STRIPE_PRO_PRICE_ID", "STRIPE_PRO_PRICE_ID"); if (priceId && proPriceId && priceId === proPriceId) return "pro"; return "basic"; } async function findUserBySubscriptionId( stripeSubscriptionId: string ): Promise { const db = await getDb(); const rows = await db .select() .from(subscriptions) .where(eq(subscriptions.stripeSubscriptionId, stripeSubscriptionId)) .orderBy(desc(subscriptions.createdAt)) .limit(1); return rows[0]?.userId ?? null; } async function findUserByCustomerId(stripeCustomerId: string): Promise { const db = await getDb(); const rows = await db .select() .from(subscriptions) .where(eq(subscriptions.stripeCustomerId, stripeCustomerId)) .orderBy(desc(subscriptions.createdAt)) .limit(1); return rows[0]?.userId ?? null; } function toDate(epochSeconds: number | null | undefined): Date | null { if (!epochSeconds) return null; return new Date(epochSeconds * 1000); } function mapStripeStatus( s: Stripe.Subscription.Status ): "trialing" | "active" | "past_due" | "canceled" | "expired" { switch (s) { case "trialing": return "trialing"; case "active": return "active"; case "past_due": case "unpaid": return "past_due"; case "canceled": return "canceled"; case "incomplete": case "incomplete_expired": case "paused": return "expired"; default: return "expired"; } } async function syncSubscriptionFromStripe( userId: number, subscription: Stripe.Subscription ): Promise { const item = subscription.items.data[0]; const priceId = item?.price?.id ?? null; const plan = await planFromPriceId(priceId); const status = mapStripeStatus(subscription.status); await updateSubscription(userId, { stripeSubscriptionId: subscription.id, stripeCustomerId: typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id, stripePriceId: priceId, plan, status, currentPeriodStart: toDate(item?.current_period_start ?? null), currentPeriodEnd: toDate(item?.current_period_end ?? null), canceledAt: toDate(subscription.canceled_at), }); } export async function handleWebhook(event: Stripe.Event): Promise { const stripe = await getStripe(); switch (event.type) { case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; const userIdRaw = session.metadata?.userId; const userId = userIdRaw ? Number(userIdRaw) : null; if (!userId || Number.isNaN(userId)) return; if (typeof session.subscription === "string") { const sub = await stripe.subscriptions.retrieve(session.subscription); await syncSubscriptionFromStripe(userId, sub); } break; } case "customer.subscription.created": case "customer.subscription.updated": { const sub = event.data.object as Stripe.Subscription; const metaUserId = sub.metadata?.userId ? Number(sub.metadata.userId) : null; let userId = metaUserId && !Number.isNaN(metaUserId) ? metaUserId : null; if (!userId) { userId = await findUserBySubscriptionId(sub.id); } if (!userId) { const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer.id; userId = await findUserByCustomerId(customerId); } if (!userId) return; await syncSubscriptionFromStripe(userId, sub); break; } case "customer.subscription.deleted": { const sub = event.data.object as Stripe.Subscription; let userId = await findUserBySubscriptionId(sub.id); if (!userId) { const customerId = typeof sub.customer === "string" ? sub.customer : sub.customer.id; userId = await findUserByCustomerId(customerId); } if (!userId) return; await updateSubscription(userId, { status: "canceled", canceledAt: toDate(sub.canceled_at) ?? new Date(), }); break; } case "invoice.paid": { const invoice = event.data.object as Stripe.Invoice; const subRef = invoice.parent?.subscription_details?.subscription ?? null; const subscriptionId = typeof subRef === "string" ? subRef : subRef?.id ?? null; const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id ?? null; let userId: number | null = null; if (subscriptionId) userId = await findUserBySubscriptionId(subscriptionId); if (!userId && customerId) userId = await findUserByCustomerId(customerId); if (!userId || !subscriptionId) return; const sub = await stripe.subscriptions.retrieve(subscriptionId); await syncSubscriptionFromStripe(userId, sub); break; } case "invoice.payment_failed": { const invoice = event.data.object as Stripe.Invoice; const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id ?? null; if (!customerId) return; const userId = await findUserByCustomerId(customerId); if (!userId) return; await updateSubscription(userId, { status: "past_due" }); break; } default: break; } } export async function verifyAndConstructEvent( rawBody: Buffer, signature: string ): Promise { const stripe = await getStripe(); const secret = (await getConfigValue("STRIPE_WEBHOOK_SECRET")) ?? process.env.STRIPE_WEBHOOK_SECRET; if (!secret) { throw new Error("STRIPE_WEBHOOK_SECRET is not set"); } return stripe.webhooks.constructEvent(rawBody, signature, secret); }