diff --git a/package-lock.json b/package-lock.json index de29e9d..63f6561 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.6", + "@stripe/stripe-js": "^9.3.1", "@tailwindcss/vite": "^4.0.0", "@tanstack/react-query": "^5.62.7", "@trpc/client": "11.0.0-rc.660", @@ -62,6 +63,7 @@ "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "sonner": "^1.7.1", + "stripe": "^22.1.0", "tailwind-merge": "^2.6.0", "tailwindcss": "^4.0.0", "vite-plugin-pwa": "^1.2.0", @@ -4878,6 +4880,15 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@stripe/stripe-js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.3.1.tgz", + "integrity": "sha512-oWpAEENuVg8aw4W2OUAM9WxRDtIV2YTLr2nr6qHT+D8tHPW7bya61ufinPpUespyRNUVXqesnHo+jQdUNsGywA==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -11383,6 +11394,23 @@ "node": ">=10" } }, + "node_modules/stripe": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.0.tgz", + "integrity": "sha512-w/xHyJGxXWnLPbNHG13sz/fae0MrFGC80Oz7YbICQymbfpqfEcsoG+6yG+9BWb81PWc4rrkeSO4wmTcmefmbLw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", diff --git a/package.json b/package.json index dfa1ec1..2cbfd05 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.6", + "@stripe/stripe-js": "^9.3.1", "@tailwindcss/vite": "^4.0.0", "@tanstack/react-query": "^5.62.7", "@trpc/client": "11.0.0-rc.660", @@ -70,6 +71,7 @@ "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "sonner": "^1.7.1", + "stripe": "^22.1.0", "tailwind-merge": "^2.6.0", "tailwindcss": "^4.0.0", "vite-plugin-pwa": "^1.2.0", diff --git a/server/_core/index.ts b/server/_core/index.ts index 99fe0fa..e6e9a57 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -14,6 +14,11 @@ import { createContext } from "./context.js"; import { authMiddleware, assertAuthEnv } from "../auth.js"; import { getDb } from "../db.js"; import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.js"; +import { + handleWebhook as handleStripeWebhook, + isStripeConfigured, + verifyAndConstructEvent, +} from "../services/stripe.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, "..", ".."); diff --git a/server/services/stripe.ts b/server/services/stripe.ts new file mode 100644 index 0000000..67e362a --- /dev/null +++ b/server/services/stripe.ts @@ -0,0 +1,273 @@ +import Stripe from "stripe"; +import { eq, desc } from "drizzle-orm"; +import { getDb, getSubscription, getUserById, updateSubscription } from "../db.js"; +import { subscriptions } from "../schema.js"; + +let stripeInstance: Stripe | null = null; + +export function getStripe(): Stripe { + if (stripeInstance) return stripeInstance; + const key = process.env.STRIPE_SECRET_KEY; + if (!key) { + throw new Error("STRIPE_SECRET_KEY is not set"); + } + stripeInstance = new Stripe(key, { + apiVersion: "2026-04-22.dahlia", + typescript: true, + }); + return stripeInstance; +} + +export function isStripeConfigured(): boolean { + return Boolean(process.env.STRIPE_SECRET_KEY); +} + +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 = 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 = 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 = 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, + }; +} + +function planFromPriceId(priceId: string | null | undefined): "basic" | "pro" { + const proPriceId = process.env.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 = 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 = 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 function verifyAndConstructEvent( + rawBody: Buffer, + signature: string +): Stripe.Event { + const stripe = getStripe(); + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!secret) { + throw new Error("STRIPE_WEBHOOK_SECRET is not set"); + } + return stripe.webhooks.constructEvent(rawBody, signature, secret); +}