diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-07-13 09:28:24 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-07-13 20:44:00 +0000 |
| commit | d1d5263486f96db578aad918a59007045c3c077f (patch) | |
| tree | df65f062b6eda93364f7d509fc2c52663561097a /packages/trpc/routers/subscriptions.ts | |
| parent | 845ccf1ad46c8635782f8e10280b07c48c08eaf5 (diff) | |
| download | karakeep-d1d5263486f96db578aad918a59007045c3c077f.tar.zst | |
feat: Add stripe based subscriptions
Diffstat (limited to 'packages/trpc/routers/subscriptions.ts')
| -rw-r--r-- | packages/trpc/routers/subscriptions.ts | 427 |
1 files changed, 427 insertions, 0 deletions
diff --git a/packages/trpc/routers/subscriptions.ts b/packages/trpc/routers/subscriptions.ts new file mode 100644 index 00000000..4915a225 --- /dev/null +++ b/packages/trpc/routers/subscriptions.ts @@ -0,0 +1,427 @@ +// Thanks to @t3dotgg for the recommendations (https://github.com/t3dotgg/stripe-recommendations)! + +import { TRPCError } from "@trpc/server"; +import { count, eq, sum } from "drizzle-orm"; +import Stripe from "stripe"; +import { z } from "zod"; + +import { assets, bookmarks, subscriptions, users } from "@karakeep/db/schema"; +import serverConfig from "@karakeep/shared/config"; + +import { authedProcedure, Context, publicProcedure, router } from "../index"; + +const stripe = serverConfig.stripe.secretKey + ? new Stripe(serverConfig.stripe.secretKey, { + apiVersion: "2025-06-30.basil", + }) + : null; + +function requireStripeConfig() { + if (!stripe || !serverConfig.stripe.priceId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Stripe is not configured. Please contact your administrator.", + }); + } + return { stripe, priceId: serverConfig.stripe.priceId }; +} + +// Taken from https://github.com/t3dotgg/stripe-recommendations + +const allowedEvents: Stripe.Event.Type[] = [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.subscription.paused", + "customer.subscription.resumed", + "customer.subscription.pending_update_applied", + "customer.subscription.pending_update_expired", + "customer.subscription.trial_will_end", + "invoice.paid", + "invoice.payment_failed", + "invoice.payment_action_required", + "invoice.upcoming", + "invoice.marked_uncollectible", + "invoice.payment_succeeded", + "payment_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.canceled", +]; + +async function syncStripeDataToDatabase(customerId: string, db: Context["db"]) { + if (!stripe) { + throw new Error("Stripe is not configured"); + } + + const existingSubscription = await db.query.subscriptions.findFirst({ + where: eq(subscriptions.stripeCustomerId, customerId), + }); + + if (!existingSubscription) { + console.error( + `ERROR: No subscription found for customer with this ID ${customerId}`, + ); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "No subscription found for customer with this ID", + }); + } + + try { + const subscriptionsList = await stripe.subscriptions.list({ + customer: customerId, + limit: 1, + status: "all", + }); + + if (subscriptionsList.data.length === 0) { + await db.transaction(async (trx) => { + await trx + .update(subscriptions) + .set({ + status: "canceled", + tier: "free", + stripeSubscriptionId: null, + priceId: null, + cancelAtPeriodEnd: false, + startDate: null, + endDate: null, + }) + .where(eq(subscriptions.stripeCustomerId, customerId)); + + // Update user quotas to free tier limits + await trx + .update(users) + .set({ + bookmarkQuota: serverConfig.quotas.free.bookmarkLimit, + storageQuota: serverConfig.quotas.free.assetSizeBytes, + }) + .where(eq(users.id, existingSubscription.userId)); + }); + return; + } + + const subscription = subscriptionsList.data[0]; + const subscriptionItem = subscription.items.data[0]; + + const subData = { + stripeSubscriptionId: subscription.id, + status: subscription.status, + tier: + subscription.status === "active" || subscription.status === "trialing" + ? ("paid" as const) + : ("free" as const), + priceId: subscription.items.data[0]?.price.id || null, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + startDate: subscriptionItem.current_period_start + ? new Date(subscriptionItem.current_period_start * 1000) + : null, + endDate: subscriptionItem.current_period_end + ? new Date(subscriptionItem.current_period_end * 1000) + : null, + }; + + await db.transaction(async (trx) => { + await trx + .update(subscriptions) + .set(subData) + .where(eq(subscriptions.stripeCustomerId, customerId)); + + if (subData.status === "active" || subData.status === "trialing") { + await trx + .update(users) + .set({ + bookmarkQuota: serverConfig.quotas.paid.bookmarkLimit, + storageQuota: serverConfig.quotas.paid.assetSizeBytes, + }) + .where(eq(users.id, existingSubscription.userId)); + } else { + await trx + .update(users) + .set({ + bookmarkQuota: serverConfig.quotas.free.bookmarkLimit, + storageQuota: serverConfig.quotas.free.assetSizeBytes, + }) + .where(eq(users.id, existingSubscription.userId)); + } + }); + + return subData; + } catch (error) { + console.error("Error syncing Stripe data:", error); + throw error; + } +} + +async function processEvent(event: Stripe.Event, db: Context["db"]) { + if (!allowedEvents.includes(event.type)) { + return; + } + + const { customer: customerId } = event.data.object as { + customer: string; + }; + + if (typeof customerId !== "string") { + throw new Error( + `[STRIPE HOOK] Customer ID isn't string. Event type: ${event.type}`, + ); + } + + return await syncStripeDataToDatabase(customerId, db); +} + +export const subscriptionsRouter = router({ + getSubscriptionStatus: authedProcedure.query(async ({ ctx }) => { + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, ctx.user.id), + }); + + if (!subscription) { + return { + tier: "free" as const, + status: null, + startDate: null, + endDate: null, + hasActiveSubscription: false, + cancelAtPeriodEnd: false, + }; + } + + return { + tier: subscription.tier, + status: subscription.status, + startDate: subscription.startDate, + endDate: subscription.endDate, + hasActiveSubscription: + subscription.status === "active" || subscription.status === "trialing", + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || false, + }; + }), + + getSubscriptionPrice: authedProcedure.query(async () => { + if (!stripe) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Stripe is not configured. Please contact your administrator.", + }); + } + + const { priceId } = requireStripeConfig(); + + const price = await stripe.prices.retrieve(priceId); + + return { + priceId: price.id, + currency: price.currency, + amount: price.unit_amount, + }; + }), + + createCheckoutSession: authedProcedure.mutation(async ({ ctx }) => { + const { stripe, priceId } = requireStripeConfig(); + + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.user.id), + columns: { + email: true, + }, + with: { + subscription: true, + }, + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const existingSubscription = user.subscription; + + if (existingSubscription?.status === "active") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "User already has an active subscription", + }); + } + + let customerId = existingSubscription?.stripeCustomerId; + + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { + userId: ctx.user.id, + }, + }); + customerId = customer.id; + + if (!existingSubscription) { + await ctx.db.insert(subscriptions).values({ + userId: ctx.user.id, + stripeCustomerId: customerId, + status: "unpaid", + }); + } else { + await ctx.db + .update(subscriptions) + .set({ stripeCustomerId: customerId }) + .where(eq(subscriptions.userId, ctx.user.id)); + } + } + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + payment_method_types: ["card"], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "subscription", + success_url: `${serverConfig.publicUrl}/settings/subscription?success=true`, + cancel_url: `${serverConfig.publicUrl}/settings/subscription?canceled=true`, + metadata: { + userId: ctx.user.id, + }, + automatic_tax: { + enabled: true, + }, + customer_update: { + address: "auto", + }, + }); + + return { + sessionId: session.id, + url: session.url, + }; + }), + + syncWithStripe: authedProcedure.mutation(async ({ ctx }) => { + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, ctx.user.id), + }); + + if (!subscription?.stripeCustomerId) { + // No Stripe customer found for user + return { success: true }; + } + + await syncStripeDataToDatabase(subscription.stripeCustomerId, ctx.db); + return { success: true }; + }), + + createPortalSession: authedProcedure.mutation(async ({ ctx }) => { + const { stripe } = requireStripeConfig(); + + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, ctx.user.id), + }); + + if (!subscription?.stripeCustomerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No Stripe customer found", + }); + } + + const session = await stripe.billingPortal.sessions.create({ + customer: subscription.stripeCustomerId, + return_url: `${serverConfig.publicUrl}/settings/subscription`, + }); + + return { + url: session.url, + }; + }), + + getQuotaUsage: authedProcedure.query(async ({ ctx }) => { + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.user.id), + columns: { + bookmarkQuota: true, + storageQuota: true, + }, + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + // Get current bookmark count + const [{ bookmarkCount }] = await ctx.db + .select({ bookmarkCount: count() }) + .from(bookmarks) + .where(eq(bookmarks.userId, ctx.user.id)); + + // Get current storage usage + const [{ storageUsed }] = await ctx.db + .select({ storageUsed: sum(assets.size) }) + .from(assets) + .where(eq(assets.userId, ctx.user.id)); + + return { + bookmarks: { + used: bookmarkCount, + quota: user.bookmarkQuota, + unlimited: user.bookmarkQuota === null, + }, + storage: { + used: Number(storageUsed) || 0, + quota: user.storageQuota, + unlimited: user.storageQuota === null, + }, + }; + }), + + handleWebhook: publicProcedure + .input( + z.object({ + body: z.string(), + signature: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (!stripe || !serverConfig.stripe.webhookSecret) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Stripe is not configured", + }); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + input.body, + input.signature, + serverConfig.stripe.webhookSecret, + ); + } catch (err) { + console.error("Webhook signature verification failed:", err); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid signature", + }); + } + + try { + await processEvent(event, ctx.db); + return { received: true }; + } catch (error) { + console.error("Error processing webhook:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Internal server error", + }); + } + }), +}); |
