Files
Kordant/web/src/server/services/billing.service.ts

279 lines
7.6 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { safeParse } from "valibot";
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";
import {
CheckoutSessionSchema,
SubscriptionSchema,
InvoiceSchema,
} from "~/server/api/schemas/webhook";
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,
returnUrl: string,
) {
const customerId = await getOrCreateCustomer(userId, email);
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
ui_mode: "embedded_page",
line_items: [{ price: priceId, quantity: 1 }],
return_url: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId },
});
return { clientSecret: session.client_secret ?? "", 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;
}
function safeParseSubscription(obj: unknown) {
const result = safeParse(SubscriptionSchema, obj);
if (!result.success) {
console.error(`[webhook] Failed to parse subscription data: ${result.issues?.map((i) => i.message).join(", ")}`);
return null;
}
return result.output;
}
function safeParseCheckoutSession(obj: unknown) {
const result = safeParse(CheckoutSessionSchema, obj);
if (!result.success) {
console.error(`[webhook] Failed to parse checkout session data: ${result.issues?.map((i) => i.message).join(", ")}`);
return null;
}
return result.output;
}
function safeParseInvoice(obj: unknown) {
const result = safeParse(InvoiceSchema, obj);
if (!result.success) {
console.error(`[webhook] Failed to parse invoice data: ${result.issues?.map((i) => i.message).join(", ")}`);
return null;
}
return result.output;
}
export async function handleWebhookEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = safeParseCheckoutSession(event.data.object);
if (!session) break;
const userId = session.metadata?.userId;
if (!userId || !session.subscription) break;
const stripeSub = await stripe.subscriptions.retrieve(session.subscription);
// Fetch fresh subscription data from Stripe for accurate fields
const subData = 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: (subData.status as typeof subscriptions.$inferSelect.status) ?? "active",
currentPeriodStart: subData.current_period_start
? new Date((subData.current_period_start as number) * 1000)
: undefined,
currentPeriodEnd: subData.current_period_end
? new Date((subData.current_period_end as number) * 1000)
: undefined,
cancelAtPeriodEnd: Boolean(subData.cancel_at_period_end),
}).onConflictDoNothing();
break;
}
case "invoice.paid": {
const invoice = safeParseInvoice(event.data.object);
if (!invoice?.subscription) break;
await updateSubscriptionInDB(invoice.subscription, {
status: "active",
});
break;
}
case "invoice.payment_failed": {
const invoice = safeParseInvoice(event.data.object);
if (!invoice?.subscription) break;
await updateSubscriptionInDB(invoice.subscription, {
status: "past_due",
});
break;
}
case "customer.subscription.updated": {
const validatedSub = safeParseSubscription(event.data.object);
if (!validatedSub) break;
const userId = validatedSub.metadata?.userId;
if (!userId) {
const [existingSub] = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.stripeId, validatedSub.id))
.limit(1);
if (!existingSub) break;
}
const tier = validatedSub.items?.data?.price?.id
? mapStripeProductToTier(validatedSub.items.data.price.id)
: undefined;
await updateSubscriptionInDB(validatedSub.id, {
tier,
status: validatedSub.status ?? undefined,
currentPeriodStart: validatedSub.current_period_start
? new Date(validatedSub.current_period_start * 1000)
: undefined,
currentPeriodEnd: validatedSub.current_period_end
? new Date(validatedSub.current_period_end * 1000)
: undefined,
cancelAtPeriodEnd: validatedSub.cancel_at_period_end ?? undefined,
});
break;
}
case "customer.subscription.deleted": {
const stripeSub = safeParseSubscription(event.data.object);
if (!stripeSub) break;
await updateSubscriptionInDB(stripeSub.id, {
status: "canceled",
});
break;
}
}
}
export 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";
}