Files
Kordant/web/src/server/api/routers/billing.ts
Michael Freno 40a9ef146c 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
2026-05-25 16:07:00 -04:00

101 lines
2.8 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { wrap } from "@typeschema/valibot";
import { createTRPCRouter, protectedProcedure } from "../utils";
import {
CreateCheckoutSessionSchema,
CreatePortalSessionSchema,
CancelSubscriptionSchema,
ReactivateSubscriptionSchema,
ListInvoicesSchema,
} from "../schemas/billing";
import {
getOrCreateCustomer,
createCheckoutSession,
createPortalSession,
cancelSubscription,
reactivateSubscription,
listInvoices,
} from "~/server/services/billing.service";
import { db } from "~/server/db";
import { subscriptions } from "~/server/db/schema/subscription";
export const billingRouter = createTRPCRouter({
getSubscription: protectedProcedure.query(async ({ ctx }) => {
const sub = await db.query.subscriptions.findFirst({
where: eq(subscriptions.userId, ctx.user.id),
});
return sub ?? null;
}),
createCheckoutSession: protectedProcedure
.input(wrap(CreateCheckoutSessionSchema))
.mutation(async ({ ctx, input }) => {
const allowedPrices = [
process.env.STRIPE_PRICE_BASIC,
process.env.STRIPE_PRICE_PLUS,
process.env.STRIPE_PRICE_PREMIUM,
].filter(Boolean);
if (!allowedPrices.includes(input.priceId)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid price ID",
});
}
return createCheckoutSession(
ctx.user.id,
ctx.user.email,
input.priceId,
input.successUrl,
input.cancelUrl,
);
}),
createPortalSession: protectedProcedure
.input(wrap(CreatePortalSessionSchema))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
const stripeCustomerId = user.stripeCustomerId;
if (!stripeCustomerId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No Stripe customer found",
});
}
return createPortalSession(stripeCustomerId, input.returnUrl);
}),
cancelSubscription: protectedProcedure
.input(wrap(CancelSubscriptionSchema))
.mutation(async ({ input }) => {
return cancelSubscription(input.subscriptionId);
}),
reactivateSubscription: protectedProcedure
.input(wrap(ReactivateSubscriptionSchema))
.mutation(async ({ input }) => {
return reactivateSubscription(input.subscriptionId);
}),
listInvoices: protectedProcedure
.input(wrap(ListInvoicesSchema))
.query(async ({ ctx, input }) => {
const user = ctx.user;
const stripeCustomerId = user.stripeCustomerId;
if (!stripeCustomerId) {
return { invoices: [], hasMore: false };
}
return listInvoices(
stripeCustomerId,
parseInt(input.limit ?? "10", 10),
input.startingAfter,
);
}),
});