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:
100
web/src/server/api/routers/billing.ts
Normal file
100
web/src/server/api/routers/billing.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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,
|
||||
);
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user