aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc
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
parent845ccf1ad46c8635782f8e10280b07c48c08eaf5 (diff)
downloadkarakeep-d1d5263486f96db578aad918a59007045c3c077f.tar.zst
feat: Add stripe based subscriptions
Diffstat (limited to 'packages/trpc')
-rw-r--r--packages/trpc/package.json1
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/subscriptions.test.ts881
-rw-r--r--packages/trpc/routers/subscriptions.ts427
-rw-r--r--packages/trpc/routers/users.ts2
5 files changed, 1313 insertions, 0 deletions
diff --git a/packages/trpc/package.json b/packages/trpc/package.json
index aa438dbe..df359f5d 100644
--- a/packages/trpc/package.json
+++ b/packages/trpc/package.json
@@ -21,6 +21,7 @@
"drizzle-orm": "^0.44.2",
"nodemailer": "^7.0.4",
"prom-client": "^15.1.3",
+ "stripe": "^18.3.0",
"superjson": "^2.2.1",
"tiny-invariant": "^1.3.3",
"zod": "^3.24.2"
diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts
index 54335da3..651c8d88 100644
--- a/packages/trpc/routers/_app.ts
+++ b/packages/trpc/routers/_app.ts
@@ -10,6 +10,7 @@ import { listsAppRouter } from "./lists";
import { promptsAppRouter } from "./prompts";
import { publicBookmarks } from "./publicBookmarks";
import { rulesAppRouter } from "./rules";
+import { subscriptionsRouter } from "./subscriptions";
import { tagsAppRouter } from "./tags";
import { usersAppRouter } from "./users";
import { webhooksAppRouter } from "./webhooks";
@@ -29,6 +30,7 @@ export const appRouter = router({
rules: rulesAppRouter,
invites: invitesAppRouter,
publicBookmarks: publicBookmarks,
+ subscriptions: subscriptionsRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
diff --git a/packages/trpc/routers/subscriptions.test.ts b/packages/trpc/routers/subscriptions.test.ts
new file mode 100644
index 00000000..b077c067
--- /dev/null
+++ b/packages/trpc/routers/subscriptions.test.ts
@@ -0,0 +1,881 @@
+import { eq } from "drizzle-orm";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { assets, AssetTypes, subscriptions, users } from "@karakeep/db/schema";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+
+import type { CustomTestContext } from "../testUtils";
+import { defaultBeforeEach, getApiCaller } from "../testUtils";
+
+// Mock Stripe using vi.hoisted to ensure it's available during module initialization
+const mockStripeInstance = vi.hoisted(() => ({
+ customers: {
+ create: vi.fn(),
+ },
+ checkout: {
+ sessions: {
+ create: vi.fn(),
+ },
+ },
+ billingPortal: {
+ sessions: {
+ create: vi.fn(),
+ },
+ },
+ subscriptions: {
+ update: vi.fn(),
+ list: vi.fn(),
+ },
+ webhooks: {
+ constructEvent: vi.fn(),
+ },
+}));
+
+vi.mock("stripe", () => {
+ return {
+ default: vi.fn(() => mockStripeInstance),
+ };
+});
+
+// Mock server config with Stripe settings
+vi.mock("@karakeep/shared/config", async (original) => {
+ const mod = (await original()) as typeof import("@karakeep/shared/config");
+ return {
+ ...mod,
+ default: {
+ ...mod.default,
+ stripe: {
+ secretKey: "sk_test_123",
+ priceId: "price_123",
+ webhookSecret: "whsec_123",
+ isConfigured: true,
+ },
+ publicUrl: "https://test.karakeep.com",
+ quotas: {
+ free: {
+ bookmarkLimit: 100,
+ assetSizeBytes: 1000000, // 1MB
+ },
+ paid: {
+ bookmarkLimit: null,
+ assetSizeBytes: null,
+ },
+ },
+ },
+ };
+});
+
+beforeEach<CustomTestContext>(defaultBeforeEach(false));
+
+describe("Subscription Routes", () => {
+ let mockCustomersCreate: ReturnType<typeof vi.fn>;
+ let mockCheckoutSessionsCreate: ReturnType<typeof vi.fn>;
+ let mockBillingPortalSessionsCreate: ReturnType<typeof vi.fn>;
+ let mockWebhooksConstructEvent: ReturnType<typeof vi.fn>;
+ let mockSubscriptionsList: ReturnType<typeof vi.fn>;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Set up mock functions using the global mock instance
+ mockCustomersCreate = mockStripeInstance.customers.create;
+ mockCheckoutSessionsCreate = mockStripeInstance.checkout.sessions.create;
+ mockBillingPortalSessionsCreate =
+ mockStripeInstance.billingPortal.sessions.create;
+ mockWebhooksConstructEvent = mockStripeInstance.webhooks.constructEvent;
+ mockSubscriptionsList = mockStripeInstance.subscriptions.list;
+ });
+
+ describe("getSubscriptionStatus", () => {
+ test<CustomTestContext>("returns free tier when no subscription exists", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id);
+
+ const status = await caller.subscriptions.getSubscriptionStatus();
+
+ expect(status).toEqual({
+ tier: "free",
+ status: null,
+ startDate: null,
+ endDate: null,
+ hasActiveSubscription: false,
+ cancelAtPeriodEnd: false,
+ });
+ });
+
+ test<CustomTestContext>("returns subscription data when subscription exists", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id);
+
+ const startDate = new Date("2024-01-01");
+ const endDate = new Date("2024-02-01");
+
+ // Create subscription record
+ await db.insert(subscriptions).values({
+ userId: user.id,
+ stripeCustomerId: "cus_123",
+ stripeSubscriptionId: "sub_123",
+ status: "active",
+ tier: "paid",
+ startDate,
+ endDate,
+ cancelAtPeriodEnd: true,
+ });
+
+ const status = await caller.subscriptions.getSubscriptionStatus();
+
+ expect(status).toEqual({
+ tier: "paid",
+ status: "active",
+ startDate,
+ endDate,
+ hasActiveSubscription: true,
+ cancelAtPeriodEnd: true,
+ });
+ });
+ });
+
+ describe("createCheckoutSession", () => {
+ test<CustomTestContext>("creates checkout session for new customer", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id);
+
+ mockCustomersCreate.mockResolvedValue({
+ id: "cus_new123",
+ });
+
+ mockCheckoutSessionsCreate.mockResolvedValue({
+ id: "cs_123",
+ url: "https://checkout.stripe.com/pay/cs_123",
+ });
+
+ const result = await caller.subscriptions.createCheckoutSession();
+
+ expect(result).toEqual({
+ sessionId: "cs_123",
+ url: "https://checkout.stripe.com/pay/cs_123",
+ });
+
+ expect(mockCustomersCreate).toHaveBeenCalledWith({
+ email: "test@test.com",
+ metadata: {
+ userId: user.id,
+ },
+ });
+
+ expect(mockCheckoutSessionsCreate).toHaveBeenCalledWith({
+ customer: "cus_new123",
+ payment_method_types: ["card"],
+ line_items: [
+ {
+ price: "price_123",
+ quantity: 1,
+ },
+ ],
+ mode: "subscription",
+ success_url:
+ "https://test.karakeep.com/settings/subscription?success=true",
+ cancel_url:
+ "https://test.karakeep.com/settings/subscription?canceled=true",
+ metadata: {
+ userId: user.id,
+ },
+ automatic_tax: {
+ enabled: true,
+ },
+ customer_update: {
+ address: "auto",
+ },
+ });
+ });
+
+ test<CustomTestContext>("throws error if user already has active subscription", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id);
+
+ await db.insert(subscriptions).values({
+ userId: user.id,
+ stripeCustomerId: "cus_123",
+ stripeSubscriptionId: "sub_123",
+ status: "active",
+ tier: "paid",
+ });
+
+ await expect(
+ caller.subscriptions.createCheckoutSession(),
+ ).rejects.toThrow(/User already has an active subscription/);
+ });
+ });
+
+ describe("createPortalSession", () => {
+ test<CustomTestContext>("creates portal session for user with subscription", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id);
+
+ await db.insert(subscriptions).values({
+ userId: user.id,
+ stripeCustomerId: "cus_123",
+ stripeSubscriptionId: "sub_123",
+ status: "active",
+ tier: "paid",
+ });
+
+ mockBillingPortalSessionsCreate.mockResolvedValue({
+ url: "https://billing.stripe.com/session/123",
+ });
+
+ const result = await caller.subscriptions.createPortalSession();
+
+ expect(result).toEqual({
+ url: "https://billing.stripe.com/session/123",
+ });
+
+ expect(mockBillingPortalSessionsCreate).toHaveBeenCalledWith({
+ customer: "cus_123",
+ return_url: "https://test.karakeep.com/settings/subscription",
+ });
+ });
+
+ test<CustomTestContext>("throws error if user has no subscription", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id);
+
+ await expect(caller.subscriptions.createPortalSession()).rejects.toThrow(
+ /No Stripe customer found/,
+ );
+ });
+ });
+
+ describe("getQuotaUsage", () => {
+ test<CustomTestContext>("returns quota usage for user with no data", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id);
+
+ const usage = await caller.subscriptions.getQuotaUsage();
+
+ expect(usage).toEqual({
+ bookmarks: {
+ used: 0,
+ quota: 100,
+ unlimited: false,
+ },
+ storage: {
+ used: 0,
+ quota: 1000000,
+ unlimited: false,
+ },
+ });
+ });
+
+ test<CustomTestContext>("returns quota usage with bookmarks and assets", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+ const caller = getApiCaller(db, user.id);
+
+ // Set user quotas
+ await db
+ .update(users)
+ .set({
+ bookmarkQuota: 100,
+ storageQuota: 1000000, // 1MB
+ })
+ .where(eq(users.id, user.id));
+
+ // Create test bookmarks
+ const bookmark1 = await caller.bookmarks.createBookmark({
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ const bookmark2 = await caller.bookmarks.createBookmark({
+ text: "Test note",
+ type: BookmarkTypes.TEXT,
+ });
+
+ // Create test assets
+ await db.insert(assets).values([
+ {
+ id: "asset1",
+ assetType: AssetTypes.LINK_SCREENSHOT,
+ size: 50000, // 50KB
+ contentType: "image/png",
+ bookmarkId: bookmark1.id,
+ userId: user.id,
+ },
+ {
+ id: "asset2",
+ assetType: AssetTypes.LINK_BANNER_IMAGE,
+ size: 75000, // 75KB
+ contentType: "image/jpeg",
+ bookmarkId: bookmark2.id,
+ userId: user.id,
+ },
+ ]);
+
+ const usage = await caller.subscriptions.getQuotaUsage();
+
+ expect(usage).toEqual({
+ bookmarks: {
+ used: 2,
+ quota: 100,
+ unlimited: false,
+ },
+ storage: {
+ used: 125000, // 50KB + 75KB
+ quota: 1000000,
+ unlimited: false,
+ },
+ });
+ });
+ });
+
+ describe("handleWebhook", () => {
+ test<CustomTestContext>("handles customer.subscription.created event", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ // Create existing subscription record
+ await db.insert(subscriptions).values({
+ userId: user.id,
+ stripeCustomerId: "cus_123",
+ status: "unpaid",
+ tier: "free",
+ });
+
+ const mockEvent = {
+ type: "customer.subscription.created",
+ data: {
+ object: {
+ id: "sub_123",
+ customer: "cus_123",
+ status: "active",
+ current_period_start: 1640995200, // 2022-01-01
+ current_period_end: 1643673600, // 2022-02-01
+ metadata: {
+ userId: user.id,
+ },
+ },
+ },
+ };
+
+ // Mock the Stripe subscriptions.list response
+ mockSubscriptionsList.mockResolvedValue({
+ data: [
+ {
+ id: "sub_123",
+ status: "active",
+ cancel_at_period_end: false,
+ items: {
+ data: [
+ {
+ price: { id: "price_123" },
+ current_period_start: 1640995200,
+ current_period_end: 1643673600,
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ mockWebhooksConstructEvent.mockReturnValue(mockEvent);
+
+ const result = await unauthedAPICaller.subscriptions.handleWebhook({
+ body: "webhook-body",
+ signature: "webhook-signature",
+ });
+
+ expect(result).toEqual({ received: true });
+
+ // Verify subscription was updated
+ const subscription = await db.query.subscriptions.findFirst({
+ where: eq(subscriptions.userId, user.id),
+ });
+
+ expect(subscription).toBeTruthy();
+ expect(subscription?.stripeCustomerId).toBe("cus_123");
+ expect(subscription?.stripeSubscriptionId).toBe("sub_123");
+ expect(subscription?.status).toBe("active");
+ expect(subscription?.tier).toBe("paid");
+ });
+
+ test<CustomTestContext>("handles customer.subscription.updated event", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ // Create existing subscription
+ await db.insert(subscriptions).values({
+ userId: user.id,
+ stripeCustomerId: "cus_123",
+ stripeSubscriptionId: "sub_123",
+ status: "active",
+ tier: "paid",
+ });
+
+ const mockEvent = {
+ type: "customer.subscription.updated",
+ data: {
+ object: {
+ id: "sub_123",
+ customer: "cus_123",
+ status: "past_due",
+ current_period_start: 1640995200,
+ current_period_end: 1643673600,
+ metadata: {
+ userId: user.id,
+ },
+ },
+ },
+ };
+
+ // Mock the Stripe subscriptions.list response
+ mockSubscriptionsList.mockResolvedValue({
+ data: [
+ {
+ id: "sub_123",
+ status: "past_due",
+ cancel_at_period_end: false,
+ items: {
+ data: [
+ {
+ price: { id: "price_123" },
+ current_period_start: 1640995200,
+ current_period_end: 1643673600,
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ mockWebhooksConstructEvent.mockReturnValue(mockEvent);
+
+ const result = await unauthedAPICaller.subscriptions.handleWebhook({
+ body: "webhook-body",
+ signature: "webhook-signature",
+ });
+
+ expect(result).toEqual({ received: true });
+
+ // Verify subscription was updated
+ const subscription = await db.query.subscriptions.findFirst({
+ where: eq(subscriptions.userId, user.id),
+ });
+
+ expect(subscription?.status).toBe("past_due");
+ expect(subscription?.tier).toBe("free"); // past_due status should set tier to free
+ });
+
+ test<CustomTestContext>("handles customer.subscription.deleted event", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ // Create existing subscription
+ await db.insert(subscriptions).values({
+ userId: user.id,
+ stripeCustomerId: "cus_123",
+ stripeSubscriptionId: "sub_123",
+ status: "active",
+ tier: "paid",
+ });
+
+ const mockEvent = {
+ type: "customer.subscription.deleted",
+ data: {
+ object: {
+ id: "sub_123",
+ customer: "cus_123",
+ metadata: {
+ userId: user.id,
+ },
+ },
+ },
+ };
+
+ // Mock the Stripe subscriptions.list response for deleted subscription (empty list)
+ mockSubscriptionsList.mockResolvedValue({
+ data: [],
+ });
+
+ mockWebhooksConstructEvent.mockReturnValue(mockEvent);
+
+ const result = await unauthedAPICaller.subscriptions.handleWebhook({
+ body: "webhook-body",
+ signature: "webhook-signature",
+ });
+
+ expect(result).toEqual({ received: true });
+
+ // Verify subscription was updated to canceled state
+ const subscription = await db.query.subscriptions.findFirst({
+ where: eq(subscriptions.userId, user.id),
+ });
+
+ expect(subscription).toBeTruthy();
+ expect(subscription?.status).toBe("canceled");
+ expect(subscription?.tier).toBe("free");
+ expect(subscription?.stripeSubscriptionId).toBeNull();
+ expect(subscription?.priceId).toBeNull();
+ expect(subscription?.cancelAtPeriodEnd).toBe(false);
+ expect(subscription?.startDate).toBeNull();
+ expect(subscription?.endDate).toBeNull();
+ });
+
+ test<CustomTestContext>("handles unknown webhook event type", async ({
+ unauthedAPICaller,
+ }) => {
+ const mockEvent = {
+ type: "unknown.event.type",
+ data: {
+ object: {},
+ },
+ };
+
+ mockWebhooksConstructEvent.mockReturnValue(mockEvent);
+
+ const result = await unauthedAPICaller.subscriptions.handleWebhook({
+ body: "webhook-body",
+ signature: "webhook-signature",
+ });
+
+ expect(result).toEqual({ received: true });
+ });
+
+ test<CustomTestContext>("handles invalid webhook signature", async ({
+ unauthedAPICaller,
+ }) => {
+ mockWebhooksConstructEvent.mockImplementation(() => {
+ throw new Error("Invalid signature");
+ });
+
+ await expect(
+ unauthedAPICaller.subscriptions.handleWebhook({
+ body: "webhook-body",
+ signature: "invalid-signature",
+ }),
+ ).rejects.toThrow(/Invalid signature/);
+ });
+ });
+
+ describe("quota updates on tier changes", () => {
+ test<CustomTestContext>("updates quotas to paid limits on tier promotion", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ // Set initial free tier quotas
+ await db
+ .update(users)
+ .set({
+ bookmarkQuota: 100,
+ storageQuota: 1000000, // 1MB
+ })
+ .where(eq(users.id, user.id));
+
+ // Create subscription record
+ await db.insert(subscriptions).values({
+ userId: user.id,
+ stripeCustomerId: "cus_123",
+ status: "unpaid",
+ tier: "free",
+ });
+
+ const mockEvent = {
+ type: "customer.subscription.created",
+ data: {
+ object: {
+ id: "sub_123",
+ customer: "cus_123",
+ status: "active",
+ current_period_start: 1640995200,
+ current_period_end: 1643673600,
+ metadata: {
+ userId: user.id,
+ },
+ },
+ },
+ };
+
+ // Mock the Stripe subscriptions.list response
+ mockSubscriptionsList.mockResolvedValue({
+ data: [
+ {
+ id: "sub_123",
+ status: "active",
+ cancel_at_period_end: false,
+ items: {
+ data: [
+ {
+ price: { id: "price_123" },
+ current_period_start: 1640995200,
+ current_period_end: 1643673600,
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ mockWebhooksConstructEvent.mockReturnValue(mockEvent);
+
+ await unauthedAPICaller.subscriptions.handleWebhook({
+ body: "webhook-body",
+ signature: "webhook-signature",
+ });
+
+ // Verify user quotas were updated to paid limits
+ const updatedUser = await db.query.users.findFirst({
+ where: eq(users.id, user.id),
+ columns: {
+ bookmarkQuota: true,
+ storageQuota: true,
+ },
+ });
+
+ expect(updatedUser?.bookmarkQuota).toBeNull(); // unlimited for paid
+ expect(updatedUser?.storageQuota).toBeNull(); // unlimited for paid
+ });
+
+ test<CustomTestContext>("updates quotas to free limits on tier demotion", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ // Set initial paid tier quotas (unlimited)
+ await db
+ .update(users)
+ .set({
+ bookmarkQuota: null,
+ storageQuota: null,
+ })
+ .where(eq(users.id, user.id));
+
+ // Create active subscription
+ await db.insert(subscriptions).values({
+ userId: user.id,
+ stripeCustomerId: "cus_123",
+ stripeSubscriptionId: "sub_123",
+ status: "active",
+ tier: "paid",
+ });
+
+ const mockEvent = {
+ type: "customer.subscription.updated",
+ data: {
+ object: {
+ id: "sub_123",
+ customer: "cus_123",
+ status: "past_due",
+ current_period_start: 1640995200,
+ current_period_end: 1643673600,
+ metadata: {
+ userId: user.id,
+ },
+ },
+ },
+ };
+
+ // Mock the Stripe subscriptions.list response for past_due status
+ mockSubscriptionsList.mockResolvedValue({
+ data: [
+ {
+ id: "sub_123",
+ status: "past_due",
+ cancel_at_period_end: false,
+ items: {
+ data: [
+ {
+ price: { id: "price_123" },
+ current_period_start: 1640995200,
+ current_period_end: 1643673600,
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ mockWebhooksConstructEvent.mockReturnValue(mockEvent);
+
+ await unauthedAPICaller.subscriptions.handleWebhook({
+ body: "webhook-body",
+ signature: "webhook-signature",
+ });
+
+ // Verify user quotas were updated to free limits
+ const updatedUser = await db.query.users.findFirst({
+ where: eq(users.id, user.id),
+ columns: {
+ bookmarkQuota: true,
+ storageQuota: true,
+ },
+ });
+
+ expect(updatedUser?.bookmarkQuota).toBe(100); // free tier limit
+ expect(updatedUser?.storageQuota).toBe(1000000); // free tier limit (1MB)
+ });
+
+ test<CustomTestContext>("updates quotas to free limits on subscription cancellation", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "test@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ // Set initial paid tier quotas (unlimited)
+ await db
+ .update(users)
+ .set({
+ bookmarkQuota: null,
+ storageQuota: null,
+ })
+ .where(eq(users.id, user.id));
+
+ // Create active subscription
+ await db.insert(subscriptions).values({
+ userId: user.id,
+ stripeCustomerId: "cus_123",
+ stripeSubscriptionId: "sub_123",
+ status: "active",
+ tier: "paid",
+ });
+
+ const mockEvent = {
+ type: "customer.subscription.deleted",
+ data: {
+ object: {
+ id: "sub_123",
+ customer: "cus_123",
+ metadata: {
+ userId: user.id,
+ },
+ },
+ },
+ };
+
+ // Mock the Stripe subscriptions.list response for deleted subscription (empty list)
+ mockSubscriptionsList.mockResolvedValue({
+ data: [],
+ });
+
+ mockWebhooksConstructEvent.mockReturnValue(mockEvent);
+
+ await unauthedAPICaller.subscriptions.handleWebhook({
+ body: "webhook-body",
+ signature: "webhook-signature",
+ });
+
+ // Verify user quotas were updated to free limits
+ const updatedUser = await db.query.users.findFirst({
+ where: eq(users.id, user.id),
+ columns: {
+ bookmarkQuota: true,
+ storageQuota: true,
+ },
+ });
+
+ expect(updatedUser?.bookmarkQuota).toBe(100); // free tier limit
+ expect(updatedUser?.storageQuota).toBe(1000000); // free tier limit (1MB)
+ });
+ });
+});
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",
+ });
+ }
+ }),
+});
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index 4531875c..97784901 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -124,6 +124,8 @@ export async function createUserRaw(
salt: input.salt,
role: userRole,
emailVerified: input.emailVerified,
+ bookmarkQuota: serverConfig.quotas.free.bookmarkLimit,
+ storageQuota: serverConfig.quotas.free.assetSizeBytes,
})
.returning({
id: users.id,