feat: Phase 4 WIP — Stripe service layer + webhook handler
This commit is contained in:
parent
2a449c6664
commit
f93690610b
4 changed files with 308 additions and 0 deletions
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
273
server/services/stripe.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue