- 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
101 lines
2.8 KiB
TypeScript
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,
|
|
);
|
|
}),
|
|
});
|