feat(billing): add subscription and Stripe billing router

- Add stripeCustomerId column to users table
- Create Stripe client initialization (web/src/server/stripe.ts)
- Add billing service with getOrCreateCustomer, checkout/portal sessions,
  subscription management, invoice listing, and webhook event handling
- Create billing tRPC router with getSubscription, createCheckoutSession,
  createPortalSession, cancelSubscription, reactivateSubscription, listInvoices
- Add raw webhook endpoint at /api/stripe/webhook with signature verification
- Define Valibot schemas for all billing procedure inputs
- Wire billing router into root tRPC router
- Update schema tests for new column/index counts
- Write unit tests for billing service and router
This commit is contained in:
2026-05-25 16:07:00 -04:00
parent 28c33a930d
commit 40a9ef146c
14 changed files with 1006 additions and 4 deletions

View File

@@ -0,0 +1,235 @@
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
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";
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,
successUrl: string,
cancelUrl: string,
) {
const customerId = await getOrCreateCustomer(userId, email);
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
metadata: { userId },
});
return { url: session.url, 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<string, unknown>)
.where(eq(subscriptions.stripeId, stripeId))
.returning();
return updated;
}
return null;
}
export async function handleWebhookEvent(event: Stripe.Event) {
const obj = event.data.object as unknown as Record<string, unknown>;
switch (event.type) {
case "checkout.session.completed": {
const session = obj as unknown as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
if (!userId || !session.subscription) break;
const stripeSub = await stripe.subscriptions.retrieve(
session.subscription as string,
);
const sub = stripeSub as unknown as Record<string, unknown>;
await db.insert(subscriptions).values({
userId,
stripeId: stripeSub.id,
tier: mapStripeProductToTier(
stripeSub.items.data[0]?.price?.id ?? "",
),
status: sub.status as typeof subscriptions.$inferSelect.status,
currentPeriodStart: new Date((sub.current_period_start as number) * 1000),
currentPeriodEnd: new Date((sub.current_period_end as number) * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end as boolean,
}).onConflictDoNothing();
break;
}
case "invoice.paid": {
const invoice = obj;
if (!invoice.subscription) break;
await updateSubscriptionInDB(invoice.subscription as string, {
status: "active",
});
break;
}
case "invoice.payment_failed": {
const invoice = obj;
if (!invoice.subscription) break;
await updateSubscriptionInDB(invoice.subscription as string, {
status: "past_due",
});
break;
}
case "customer.subscription.updated": {
const stripeSub = obj as unknown as Stripe.Subscription;
const userId = stripeSub.metadata?.userId;
const sub = stripeSub as unknown as Record<string, unknown>;
if (!userId) {
const [existingSub] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.stripeId, stripeSub.id))
.limit(1);
if (!existingSub) break;
}
const tier = stripeSub.items.data[0]?.price?.id
? mapStripeProductToTier(stripeSub.items.data[0].price.id)
: undefined;
await updateSubscriptionInDB(stripeSub.id, {
tier,
status: sub.status as string,
currentPeriodStart: new Date((sub.current_period_start as number) * 1000),
currentPeriodEnd: new Date((sub.current_period_end as number) * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end as boolean,
});
break;
}
case "customer.subscription.deleted": {
const stripeSub = obj as unknown as Stripe.Subscription;
await updateSubscriptionInDB(stripeSub.id, {
status: "canceled",
});
break;
}
}
}
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";
}