aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/routers/subscriptions.ts
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-07-13 09:28:24 +0000
committerMohamed Bassem <me@mbassem.com>2025-07-13 20:44:00 +0000
commitd1d5263486f96db578aad918a59007045c3c077f (patch)
treedf65f062b6eda93364f7d509fc2c52663561097a /packages/trpc/routers/subscriptions.ts
parent845ccf1ad46c8635782f8e10280b07c48c08eaf5 (diff)
downloadkarakeep-d1d5263486f96db578aad918a59007045c3c077f.tar.zst
feat: Add stripe based subscriptions
Diffstat (limited to 'packages/trpc/routers/subscriptions.ts')
-rw-r--r--packages/trpc/routers/subscriptions.ts427
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",
+ });
+ }
+ }),
+});