feat: Phase 4 WIP — Stripe service layer + webhook handler

This commit is contained in:
Hermes 2026-04-25 17:31:00 +00:00
parent 2a449c6664
commit f93690610b
4 changed files with 308 additions and 0 deletions

28
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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, "..", "..");

273
server/services/stripe.ts Normal file
View file

@ -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<string> {
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<number | null> {
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<number | null> {
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<void> {
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<void> {
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);
}