rcgit

/ karakeep

Commit d1d52634

SHA d1d5263486f96db578aad918a59007045c3c077f
Author Mohamed Bassem <me at mbassem dot com>
Author Date 2025-07-13 09:28 +0000
Committer Mohamed Bassem <me at mbassem dot com>
Commit Date 2025-07-13 20:44 +0000
Parent(s) 845ccf1ad46c (diff)
Tree df65f062b6ed

patch snapshot

feat: Add stripe based subscriptions
File + - Graph
M apps/web/app/settings/layout.tsx +75 -57
A apps/web/app/settings/subscription/page.tsx +18 -0
A apps/web/components/settings/SubscriptionSettings.tsx +233 -0
A apps/web/components/subscription/QuotaProgress.tsx +177 -0
M apps/web/lib/i18n/locales/en/translation.json +24 -1
M packages/api/index.ts +3 -1
A packages/api/routes/webhooks.ts +44 -0
A packages/db/drizzle/0058_add_subscription.sql +19 -0
A packages/db/drizzle/meta/0058_snapshot.json +2338 -0
M packages/db/drizzle/meta/_journal.json +7 -0
M packages/db/schema.ts +53 -0
M packages/shared/config.ts +28 -0
M packages/trpc/package.json +1 -0
M packages/trpc/routers/_app.ts +2 -0
A packages/trpc/routers/subscriptions.test.ts +881 -0
A packages/trpc/routers/subscriptions.ts +427 -0
M packages/trpc/routers/users.ts +2 -0
M pnpm-lock.yaml +19 -1
18 file(s) changed, 4351 insertions(+), 60 deletions(-)

apps/web/app/settings/layout.tsx

diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx
index 217e02ba..b9ba80a0 100644
--- a/apps/web/app/settings/layout.tsx
+++ b/apps/web/app/settings/layout.tsx
@@ -7,6 +7,7 @@ import { TFunction } from "i18next";
 import {
   ArrowLeft,
   BarChart3,
+  CreditCard,
   Download,
   GitBranch,
   Image,
@@ -18,69 +19,86 @@ import {
   Webhook,
 } from "lucide-react";
 
+import serverConfig from "@karakeep/shared/config";
+
 const settingsSidebarItems = (
   t: TFunction,
 ): {
   name: string;
   icon: JSX.Element;
   path: string;
-}[] => [
-  {
-    name: t("settings.back_to_app"),
-    icon: <ArrowLeft size={18} />,
-    path: "/dashboard/bookmarks",
-  },
-  {
-    name: t("settings.info.user_info"),
-    icon: <User size={18} />,
-    path: "/settings/info",
-  },
-  {
-    name: t("settings.stats.usage_statistics"),
-    icon: <BarChart3 size={18} />,
-    path: "/settings/stats",
-  },
-  {
-    name: t("settings.ai.ai_settings"),
-    icon: <Sparkles size={18} />,
-    path: "/settings/ai",
-  },
-  {
-    name: t("settings.feeds.rss_subscriptions"),
-    icon: <Rss size={18} />,
-    path: "/settings/feeds",
-  },
-  {
-    name: t("settings.import.import_export"),
-    icon: <Download size={18} />,
-    path: "/settings/import",
-  },
-  {
-    name: t("settings.api_keys.api_keys"),
-    icon: <KeyRound size={18} />,
-    path: "/settings/api-keys",
-  },
-  {
-    name: t("settings.broken_links.broken_links"),
-    icon: <Link size={18} />,
-    path: "/settings/broken-links",
-  },
-  {
-    name: t("settings.webhooks.webhooks"),
-    icon: <Webhook size={18} />,
-    path: "/settings/webhooks",
-  },
-  {
-    name: t("settings.rules.rules"),
-    icon: <GitBranch size={18} />,
-    path: "/settings/rules",
-  },
-  {
-    name: t("settings.manage_assets.manage_assets"),
-    icon: <Image size={18} />,
-    path: "/settings/assets",
-  },
-];
+}[] => {
+  const baseItems = [
+    {
+      name: t("settings.back_to_app"),
+      icon: <ArrowLeft size={18} />,
+      path: "/dashboard/bookmarks",
+    },
+    {
+      name: t("settings.info.user_info"),
+      icon: <User size={18} />,
+      path: "/settings/info",
+    },
+    {
+      name: t("settings.stats.usage_statistics"),
+      icon: <BarChart3 size={18} />,
+      path: "/settings/stats",
+    },
+  ];
+
+  // Add subscription item if Stripe is configured
+  if (serverConfig.stripe.isConfigured) {
+    baseItems.push({
+      name: t("settings.subscription.subscription"),
+      icon: <CreditCard size={18} />,
+      path: "/settings/subscription",
+    });
+  }
+
+  return [
+    ...baseItems,
+    {
+      name: t("settings.ai.ai_settings"),
+      icon: <Sparkles size={18} />,
+      path: "/settings/ai",
+    },
+    {
+      name: t("settings.feeds.rss_subscriptions"),
+      icon: <Rss size={18} />,
+      path: "/settings/feeds",
+    },
+    {
+      name: t("settings.import.import_export"),
+      icon: <Download size={18} />,
+      path: "/settings/import",
+    },
+    {
+      name: t("settings.api_keys.api_keys"),
+      icon: <KeyRound size={18} />,
+      path: "/settings/api-keys",
+    },
+    {
+      name: t("settings.broken_links.broken_links"),
+      icon: <Link size={18} />,
+      path: "/settings/broken-links",
+    },
+    {
+      name: t("settings.webhooks.webhooks"),
+      icon: <Webhook size={18} />,
+      path: "/settings/webhooks",
+    },
+    {
+      name: t("settings.rules.rules"),
+      icon: <GitBranch size={18} />,
+      path: "/settings/rules",
+    },
+    {
+      name: t("settings.manage_assets.manage_assets"),
+      icon: <Image size={18} />,
+      path: "/settings/assets",
+    },
+  ];
+};
 
 export default async function SettingsLayout({
   children,

apps/web/app/settings/subscription/page.tsx

diff --git a/apps/web/app/settings/subscription/page.tsx b/apps/web/app/settings/subscription/page.tsx
new file mode 100644
index 00000000..e8c46460
--- /dev/null
+++ b/apps/web/app/settings/subscription/page.tsx
@@ -0,0 +1,18 @@
+import { redirect } from "next/navigation";
+import SubscriptionSettings from "@/components/settings/SubscriptionSettings";
+import { QuotaProgress } from "@/components/subscription/QuotaProgress";
+
+import serverConfig from "@karakeep/shared/config";
+
+export default async function SubscriptionPage() {
+  if (!serverConfig.stripe.isConfigured) {
+    redirect("/settings");
+  }
+
+  return (
+    <div className="flex flex-col gap-4">
+      <SubscriptionSettings />
+      <QuotaProgress />
+    </div>
+  );
+}

apps/web/components/settings/SubscriptionSettings.tsx

diff --git a/apps/web/components/settings/SubscriptionSettings.tsx b/apps/web/components/settings/SubscriptionSettings.tsx
new file mode 100644
index 00000000..53f1caf4
--- /dev/null
+++ b/apps/web/components/settings/SubscriptionSettings.tsx
@@ -0,0 +1,233 @@
+"use client";
+
+import { useEffect } from "react";
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+import { CreditCard, Loader2 } from "lucide-react";
+
+import { Alert, AlertDescription } from "../ui/alert";
+import { Badge } from "../ui/badge";
+import { Button } from "../ui/button";
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from "../ui/card";
+import { Skeleton } from "../ui/skeleton";
+import { toast } from "../ui/use-toast";
+
+export default function SubscriptionSettings() {
+  const { t } = useTranslation();
+  const {
+    data: subscriptionStatus,
+    refetch,
+    isLoading: isQueryLoading,
+  } = api.subscriptions.getSubscriptionStatus.useQuery();
+
+  const { data: subscriptionPrice } =
+    api.subscriptions.getSubscriptionPrice.useQuery();
+
+  const { mutate: syncStripeState } =
+    api.subscriptions.syncWithStripe.useMutation({
+      onSuccess: () => {
+        refetch();
+      },
+    });
+  const createCheckoutSession =
+    api.subscriptions.createCheckoutSession.useMutation({
+      onSuccess: (resp) => {
+        if (resp.url) {
+          window.location.href = resp.url;
+        }
+      },
+      onError: () => {
+        toast({
+          description: t("common.something_went_wrong"),
+          variant: "destructive",
+        });
+      },
+    });
+  const createPortalSession = api.subscriptions.createPortalSession.useMutation(
+    {
+      onSuccess: (resp) => {
+        if (resp.url) {
+          window.location.href = resp.url;
+        }
+      },
+      onError: () => {
+        toast({
+          description: t("common.something_went_wrong"),
+          variant: "destructive",
+        });
+      },
+    },
+  );
+
+  const isLoading =
+    createCheckoutSession.isPending || createPortalSession.isPending;
+
+  useEffect(() => {
+    syncStripeState();
+  }, []);
+
+  const formatDate = (date: Date | null) => {
+    if (!date) return "N/A";
+    return new Intl.DateTimeFormat("en-US", {
+      year: "numeric",
+      month: "long",
+      day: "numeric",
+    }).format(date);
+  };
+
+  const getStatusBadge = (status: "free" | "paid") => {
+    switch (status) {
+      case "paid":
+        return (
+          <Badge variant="default" className="bg-green-500">
+            {t("settings.subscription.paid")}
+          </Badge>
+        );
+      case "free":
+        return (
+          <Badge variant="outline">{t("settings.subscription.free")}</Badge>
+        );
+    }
+  };
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle className="flex items-center gap-2">
+          <CreditCard className="h-5 w-5" />
+          {t("settings.subscription.subscription")}
+        </CardTitle>
+        <CardDescription>
+          {t("settings.subscription.manage_subscription")}
+        </CardDescription>
+      </CardHeader>
+      <CardContent className="space-y-6">
+        {isQueryLoading ? (
+          <div className="space-y-6">
+            <div className="grid gap-4 md:grid-cols-2">
+              <div className="space-y-2">
+                <Skeleton className="h-4 w-24" />
+                <div className="flex items-center gap-2">
+                  <Skeleton className="h-5 w-16" />
+                  <Skeleton className="h-5 w-12" />
+                </div>
+              </div>
+              <div className="space-y-2">
+                <Skeleton className="h-4 w-28" />
+                <Skeleton className="h-4 w-40" />
+              </div>
+            </div>
+            <div className="space-y-4">
+              <div className="rounded-lg border p-4">
+                <div className="flex items-center justify-between">
+                  <div className="space-y-2">
+                    <Skeleton className="h-6 w-24" />
+                    <Skeleton className="h-4 w-64" />
+                    <Skeleton className="h-6 w-20" />
+                  </div>
+                  <Skeleton className="h-10 w-32" />
+                </div>
+              </div>
+            </div>
+          </div>
+        ) : (
+          <>
+            <div className="grid gap-4 md:grid-cols-2">
+              <div className="space-y-2">
+                <label className="text-sm font-medium">
+                  {t("settings.subscription.current_plan")}
+                </label>
+                <div className="flex items-center gap-2">
+                  {subscriptionStatus?.tier &&
+                    getStatusBadge(subscriptionStatus.tier)}
+                </div>
+              </div>
+
+              {subscriptionStatus?.hasActiveSubscription && (
+                <>
+                  <div className="space-y-2">
+                    <label className="text-sm font-medium">
+                      {t("settings.subscription.billing_period")}
+                    </label>
+                    <div className="text-sm text-muted-foreground">
+                      {formatDate(subscriptionStatus.startDate)} -{" "}
+                      {formatDate(subscriptionStatus.endDate)}
+                    </div>
+                  </div>
+                </>
+              )}
+            </div>
+
+            <div className="space-y-4">
+              {!subscriptionStatus?.hasActiveSubscription ? (
+                <div className="space-y-4">
+                  <div className="rounded-lg border p-4">
+                    <div className="flex items-center justify-between">
+                      <div>
+                        <h3 className="flex items-center gap-2 font-semibold">
+                          {t("settings.subscription.paid_plan")}
+                        </h3>
+                        <p className="text-sm text-muted-foreground">
+                          {t("settings.subscription.unlock_bigger_quota")}
+                        </p>
+                        {subscriptionPrice && subscriptionPrice.amount ? (
+                          <p className="mt-2 text-lg font-bold uppercase">
+                            {subscriptionPrice.amount / 100}{" "}
+                            {subscriptionPrice.currency}
+                          </p>
+                        ) : (
+                          <Skeleton className="h-4 w-24" />
+                        )}
+                      </div>
+                      <Button
+                        onClick={() => createCheckoutSession.mutate()}
+                        disabled={isLoading}
+                        className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700"
+                      >
+                        {isLoading && (
+                          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                        )}
+                        {t("settings.subscription.subscribe_now")}
+                      </Button>
+                    </div>
+                  </div>
+                </div>
+              ) : (
+                <div className="space-y-4">
+                  <div className="flex gap-2">
+                    <Button
+                      onClick={() => createPortalSession.mutate()}
+                      disabled={isLoading}
+                      variant="outline"
+                    >
+                      {isLoading && (
+                        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                      )}
+                      {t("settings.subscription.manage_billing")}
+                    </Button>
+                  </div>
+
+                  {subscriptionStatus.cancelAtPeriodEnd && (
+                    <Alert>
+                      <AlertDescription>
+                        {t("settings.subscription.subscription_canceled", {
+                          date: formatDate(subscriptionStatus.endDate),
+                        })}
+                      </AlertDescription>
+                    </Alert>
+                  )}
+                </div>
+              )}
+            </div>
+          </>
+        )}
+      </CardContent>
+    </Card>
+  );
+}

apps/web/components/subscription/QuotaProgress.tsx

diff --git a/apps/web/components/subscription/QuotaProgress.tsx b/apps/web/components/subscription/QuotaProgress.tsx
new file mode 100644
index 00000000..525eae8f
--- /dev/null
+++ b/apps/web/components/subscription/QuotaProgress.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+import { Database, HardDrive } from "lucide-react";
+
+import {
+  Card,
+  CardContent,
+  CardDescription,
+  CardHeader,
+  CardTitle,
+} from "../ui/card";
+import { Progress } from "../ui/progress";
+
+function formatBytes(bytes: number): string {
+  if (bytes === 0) return "0 B";
+  const k = 1024;
+  const sizes = ["B", "KB", "MB", "GB"];
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
+}
+
+function formatNumber(num: number): string {
+  if (num >= 1000000) {
+    return (num / 1000000).toFixed(1) + "M";
+  }
+  if (num >= 1000) {
+    return (num / 1000).toFixed(1) + "K";
+  }
+  return num.toString();
+}
+
+interface QuotaProgressItemProps {
+  title: string;
+  icon: React.ReactNode;
+  used: number;
+  quota: number | null;
+  unlimited: boolean;
+  formatter: (value: number) => string;
+  description: string;
+}
+
+function QuotaProgressItem({
+  title,
+  icon,
+  used,
+  quota,
+  unlimited,
+  formatter,
+  description,
+}: QuotaProgressItemProps) {
+  const { t } = useTranslation();
+  const percentage =
+    unlimited || !quota ? 0 : Math.min((used / quota) * 100, 100);
+  const isNearLimit = percentage > 80;
+  const isAtLimit = percentage >= 100;
+
+  return (
+    <div className="space-y-3">
+      <div className="flex items-center gap-2">
+        {icon}
+        <h4 className="font-medium">{title}</h4>
+      </div>
+
+      <div className="space-y-2">
+        <div className="flex justify-between text-sm">
+          <span className="text-muted-foreground">{description}</span>
+          <span className={isAtLimit ? "font-medium text-destructive" : ""}>
+            {formatter(used)}{" "}
+            {unlimited
+              ? ""
+              : `/ ${quota !== null && quota !== undefined ? formatter(quota) : "∞"}`}
+          </span>
+        </div>
+
+        {!unlimited && quota && (
+          <Progress
+            value={percentage}
+            className={`h-2 ${
+              isAtLimit
+                ? "[&>div]:bg-destructive"
+                : isNearLimit
+                  ? "[&>div]:bg-orange-500"
+                  : ""
+            }`}
+          />
+        )}
+
+        {unlimited && (
+          <div className="text-xs text-muted-foreground">
+            {t("settings.subscription.unlimited_usage")}
+          </div>
+        )}
+
+        {isAtLimit && (
+          <div className="text-xs text-destructive">
+            {t("settings.subscription.quota_limit_reached")}
+          </div>
+        )}
+
+        {isNearLimit && !isAtLimit && (
+          <div className="text-xs text-orange-600">
+            {t("settings.subscription.approaching_quota_limit")}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export function QuotaProgress() {
+  const { t } = useTranslation();
+  const { data: quotaUsage, isLoading } =
+    api.subscriptions.getQuotaUsage.useQuery();
+
+  if (isLoading) {
+    return (
+      <Card>
+        <CardHeader>
+          <CardTitle>{t("settings.subscription.usage_quotas")}</CardTitle>
+          <CardDescription>
+            {t("settings.subscription.loading_usage")}
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="space-y-4">
+            <div className="animate-pulse space-y-2">
+              <div className="h-4 w-1/3 rounded bg-muted"></div>
+              <div className="h-2 rounded bg-muted"></div>
+            </div>
+            <div className="animate-pulse space-y-2">
+              <div className="h-4 w-1/3 rounded bg-muted"></div>
+              <div className="h-2 rounded bg-muted"></div>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  if (!quotaUsage) {
+    return null;
+  }
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardTitle>{t("settings.subscription.usage_quotas")}</CardTitle>
+        <CardDescription>
+          {t("settings.subscription.track_usage")}
+        </CardDescription>
+      </CardHeader>
+      <CardContent className="space-y-6">
+        <QuotaProgressItem
+          title="Bookmarks"
+          icon={<Database className="h-4 w-4" />}
+          used={quotaUsage.bookmarks.used}
+          quota={quotaUsage.bookmarks.quota}
+          unlimited={quotaUsage.bookmarks.unlimited}
+          formatter={formatNumber}
+          description={t("settings.subscription.total_bookmarks_saved")}
+        />
+
+        <QuotaProgressItem
+          title="Storage"
+          icon={<HardDrive className="h-4 w-4" />}
+          used={quotaUsage.storage.used}
+          quota={quotaUsage.storage.quota}
+          unlimited={quotaUsage.storage.unlimited}
+          formatter={formatBytes}
+          description={t("settings.subscription.assets_file_storage")}
+        />
+      </CardContent>
+    </Card>
+  );
+}

apps/web/lib/i18n/locales/en/translation.json

diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 33f4952c..f9e1d493 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -37,7 +37,9 @@
       "text": "Text",
       "media": "Media"
     },
-    "quota": "Quota"
+    "quota": "Quota",
+    "bookmarks": "Bookmarks",
+    "storage": "Storage"
   },
   "layouts": {
     "masonry": "Masonry",
@@ -283,6 +285,27 @@
         "favourited": "A bookmark is favourited",
         "archived": "A bookmark is archived"
       }
+    },
+    "subscription": {
+      "subscription": "Subscription",
+      "manage_subscription": "Manage your subscription and billing information",
+      "current_plan": "Current Plan",
+      "billing_period": "Billing Period",
+      "paid_plan": "Paid Plan",
+      "unlock_bigger_quota": "Unlock bigger quota and support the project",
+      "subscribe_now": "Subscribe Now",
+      "manage_billing": "Manage Billing",
+      "subscription_canceled": "Your subscription has been canceled and will end on {{date}}. You can resubscribe at any time.",
+      "usage_quotas": "Usage & Quotas",
+      "track_usage": "Track your current usage against your plan limits",
+      "total_bookmarks_saved": "Total bookmarks saved",
+      "assets_file_storage": "Assets and file storage",
+      "unlimited_usage": "Unlimited usage",
+      "quota_limit_reached": "Quota limit reached",
+      "approaching_quota_limit": "Approaching quota limit",
+      "loading_usage": "Loading usage information...",
+      "free": "Free",
+      "paid": "Paid"
     }
   },
   "admin": {

packages/api/index.ts

diff --git a/packages/api/index.ts b/packages/api/index.ts
index 39075548..1e353f41 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -18,6 +18,7 @@ import rss from "./routes/rss";
 import tags from "./routes/tags";
 import trpc from "./routes/trpc";
 import users from "./routes/users";
+import webhooks from "./routes/webhooks";
 
 const v1 = new Hono<{
   Variables: {
@@ -62,6 +63,7 @@ const app = new Hono<{
   .route("/admin", admin)
   .route("/assets", assets)
   .route("/public", publicRoute)
-  .route("/metrics", metrics);
+  .route("/metrics", metrics)
+  .route("/webhooks", webhooks);
 
 export default app;

packages/api/routes/webhooks.ts

diff --git a/packages/api/routes/webhooks.ts b/packages/api/routes/webhooks.ts
new file mode 100644
index 00000000..66ce96d3
--- /dev/null
+++ b/packages/api/routes/webhooks.ts
@@ -0,0 +1,44 @@
+import { Hono } from "hono";
+
+import { Context, createCallerFactory } from "@karakeep/trpc";
+import { appRouter } from "@karakeep/trpc/routers/_app";
+
+const createCaller = createCallerFactory(appRouter);
+
+const app = new Hono<{
+  Variables: {
+    ctx: Context;
+  };
+}>().post("/stripe", async (c) => {
+  const body = await c.req.text();
+  const signature = c.req.header("stripe-signature");
+
+  if (!signature) {
+    return c.json({ error: "Missing stripe-signature header" }, 400);
+  }
+
+  try {
+    const api = createCaller(c.get("ctx"));
+    const result = await api.subscriptions.handleWebhook({
+      body,
+      signature,
+    });
+
+    return c.json(result);
+  } catch (error) {
+    console.error("Webhook processing failed:", error);
+
+    if (error instanceof Error) {
+      if (error.message.includes("Invalid signature")) {
+        return c.json({ error: "Invalid signature" }, 400);
+      }
+      if (error.message.includes("not configured")) {
+        return c.json({ error: "Stripe is not configured" }, 400);
+      }
+    }
+
+    return c.json({ error: "Internal server error" }, 500);
+  }
+});
+
+export default app;

packages/db/drizzle/0058_add_subscription.sql

diff --git a/packages/db/drizzle/0058_add_subscription.sql b/packages/db/drizzle/0058_add_subscription.sql
new file mode 100644
index 00000000..77260c58
--- /dev/null
+++ b/packages/db/drizzle/0058_add_subscription.sql
@@ -0,0 +1,19 @@
+CREATE TABLE `subscriptions` (
+	`id` text PRIMARY KEY NOT NULL,
+	`userId` text NOT NULL,
+	`stripeCustomerId` text NOT NULL,
+	`stripeSubscriptionId` text,
+	`status` text NOT NULL,
+	`tier` text DEFAULT 'free' NOT NULL,
+	`priceId` text,
+	`cancelAtPeriodEnd` integer DEFAULT false,
+	`startDate` integer,
+	`endDate` integer,
+	`createdAt` integer NOT NULL,
+	`modifiedAt` integer,
+	FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `subscriptions_userId_unique` ON `subscriptions` (`userId`);--> statement-breakpoint
+CREATE INDEX `subscriptions_userId_idx` ON `subscriptions` (`userId`);--> statement-breakpoint
+CREATE INDEX `subscriptions_stripeCustomerId_idx` ON `subscriptions` (`stripeCustomerId`);
\ No newline at end of file

packages/db/drizzle/meta/0058_snapshot.json

diff --git a/packages/db/drizzle/meta/0058_snapshot.json b/packages/db/drizzle/meta/0058_snapshot.json
new file mode 100644
index 00000000..dce44f02
--- /dev/null
+++ b/packages/db/drizzle/meta/0058_snapshot.json
@@ -0,0 +1,2338 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "67200406-29ea-4aed-9d67-87b4f3d308f7",
+  "prevId": "b5e79604-adc2-4ad2-b2e2-96a871ec8f01",
+  "tables": {
+    "account": {
+      "name": "account",
+      "columns": {
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "providerAccountId": {
+          "name": "providerAccountId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "refresh_token": {
+          "name": "refresh_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "access_token": {
+          "name": "access_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "token_type": {
+          "name": "token_type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "scope": {
+          "name": "scope",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "id_token": {
+          "name": "id_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "session_state": {
+          "name": "session_state",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "account_userId_user_id_fk": {
+          "name": "account_userId_user_id_fk",
+          "tableFrom": "account",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "account_provider_providerAccountId_pk": {
+          "columns": [
+            "provider",
+            "providerAccountId"
+          ],
+          "name": "account_provider_providerAccountId_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "apiKey": {
+      "name": "apiKey",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "keyId": {
+          "name": "keyId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "keyHash": {
+          "name": "keyHash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "apiKey_keyId_unique": {
+          "name": "apiKey_keyId_unique",
+          "columns": [
+            "keyId"
+          ],
+          "isUnique": true
+        },
+        "apiKey_name_userId_unique": {
+          "name": "apiKey_name_userId_unique",
+          "columns": [
+            "name",
+            "userId"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {
+        "apiKey_userId_user_id_fk": {
+          "name": "apiKey_userId_user_id_fk",
+          "tableFrom": "apiKey",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "assets": {
+      "name": "assets",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "assetType": {
+          "name": "assetType",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "contentType": {
+          "name": "contentType",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "fileName": {
+          "name": "fileName",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "bookmarkId": {
+          "name": "bookmarkId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "assets_bookmarkId_idx": {
+          "name": "assets_bookmarkId_idx",
+          "columns": [
+            "bookmarkId"
+          ],
+          "isUnique": false
+        },
+        "assets_assetType_idx": {
+          "name": "assets_assetType_idx",
+          "columns": [
+            "assetType"
+          ],
+          "isUnique": false
+        },
+        "assets_userId_idx": {
+          "name": "assets_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "assets_bookmarkId_bookmarks_id_fk": {
+          "name": "assets_bookmarkId_bookmarks_id_fk",
+          "tableFrom": "assets",
+          "tableTo": "bookmarks",
+          "columnsFrom": [
+            "bookmarkId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "assets_userId_user_id_fk": {
+          "name": "assets_userId_user_id_fk",
+          "tableFrom": "assets",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bookmarkAssets": {
+      "name": "bookmarkAssets",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "assetType": {
+          "name": "assetType",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "assetId": {
+          "name": "assetId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "content": {
+          "name": "content",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "metadata": {
+          "name": "metadata",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "fileName": {
+          "name": "fileName",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "sourceUrl": {
+          "name": "sourceUrl",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "bookmarkAssets_id_bookmarks_id_fk": {
+          "name": "bookmarkAssets_id_bookmarks_id_fk",
+          "tableFrom": "bookmarkAssets",
+          "tableTo": "bookmarks",
+          "columnsFrom": [
+            "id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bookmarkLinks": {
+      "name": "bookmarkLinks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "author": {
+          "name": "author",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "publisher": {
+          "name": "publisher",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "datePublished": {
+          "name": "datePublished",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "dateModified": {
+          "name": "dateModified",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "imageUrl": {
+          "name": "imageUrl",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "favicon": {
+          "name": "favicon",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "htmlContent": {
+          "name": "htmlContent",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "contentAssetId": {
+          "name": "contentAssetId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "crawledAt": {
+          "name": "crawledAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "crawlStatus": {
+          "name": "crawlStatus",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false,
+          "default": "'pending'"
+        },
+        "crawlStatusCode": {
+          "name": "crawlStatusCode",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false,
+          "default": 200
+        }
+      },
+      "indexes": {
+        "bookmarkLinks_url_idx": {
+          "name": "bookmarkLinks_url_idx",
+          "columns": [
+            "url"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "bookmarkLinks_id_bookmarks_id_fk": {
+          "name": "bookmarkLinks_id_bookmarks_id_fk",
+          "tableFrom": "bookmarkLinks",
+          "tableTo": "bookmarks",
+          "columnsFrom": [
+            "id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bookmarkLists": {
+      "name": "bookmarkLists",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "icon": {
+          "name": "icon",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "query": {
+          "name": "query",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "parentId": {
+          "name": "parentId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "rssToken": {
+          "name": "rssToken",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "public": {
+          "name": "public",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        }
+      },
+      "indexes": {
+        "bookmarkLists_userId_idx": {
+          "name": "bookmarkLists_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        },
+        "bookmarkLists_userId_id_idx": {
+          "name": "bookmarkLists_userId_id_idx",
+          "columns": [
+            "userId",
+            "id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {
+        "bookmarkLists_userId_user_id_fk": {
+          "name": "bookmarkLists_userId_user_id_fk",
+          "tableFrom": "bookmarkLists",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "bookmarkLists_parentId_bookmarkLists_id_fk": {
+          "name": "bookmarkLists_parentId_bookmarkLists_id_fk",
+          "tableFrom": "bookmarkLists",
+          "tableTo": "bookmarkLists",
+          "columnsFrom": [
+            "parentId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "set null",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bookmarkTags": {
+      "name": "bookmarkTags",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bookmarkTags_name_idx": {
+          "name": "bookmarkTags_name_idx",
+          "columns": [
+            "name"
+          ],
+          "isUnique": false
+        },
+        "bookmarkTags_userId_idx": {
+          "name": "bookmarkTags_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        },
+        "bookmarkTags_userId_name_unique": {
+          "name": "bookmarkTags_userId_name_unique",
+          "columns": [
+            "userId",
+            "name"
+          ],
+          "isUnique": true
+        },
+        "bookmarkTags_userId_id_idx": {
+          "name": "bookmarkTags_userId_id_idx",
+          "columns": [
+            "userId",
+            "id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {
+        "bookmarkTags_userId_user_id_fk": {
+          "name": "bookmarkTags_userId_user_id_fk",
+          "tableFrom": "bookmarkTags",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bookmarkTexts": {
+      "name": "bookmarkTexts",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "sourceUrl": {
+          "name": "sourceUrl",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "bookmarkTexts_id_bookmarks_id_fk": {
+          "name": "bookmarkTexts_id_bookmarks_id_fk",
+          "tableFrom": "bookmarkTexts",
+          "tableTo": "bookmarks",
+          "columnsFrom": [
+            "id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bookmarks": {
+      "name": "bookmarks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "modifiedAt": {
+          "name": "modifiedAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "favourited": {
+          "name": "favourited",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "taggingStatus": {
+          "name": "taggingStatus",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false,
+          "default": "'pending'"
+        },
+        "summarizationStatus": {
+          "name": "summarizationStatus",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false,
+          "default": "'pending'"
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bookmarks_userId_idx": {
+          "name": "bookmarks_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        },
+        "bookmarks_archived_idx": {
+          "name": "bookmarks_archived_idx",
+          "columns": [
+            "archived"
+          ],
+          "isUnique": false
+        },
+        "bookmarks_favourited_idx": {
+          "name": "bookmarks_favourited_idx",
+          "columns": [
+            "favourited"
+          ],
+          "isUnique": false
+        },
+        "bookmarks_createdAt_idx": {
+          "name": "bookmarks_createdAt_idx",
+          "columns": [
+            "createdAt"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "bookmarks_userId_user_id_fk": {
+          "name": "bookmarks_userId_user_id_fk",
+          "tableFrom": "bookmarks",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "bookmarksInLists": {
+      "name": "bookmarksInLists",
+      "columns": {
+        "bookmarkId": {
+          "name": "bookmarkId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "listId": {
+          "name": "listId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "addedAt": {
+          "name": "addedAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "bookmarksInLists_bookmarkId_idx": {
+          "name": "bookmarksInLists_bookmarkId_idx",
+          "columns": [
+            "bookmarkId"
+          ],
+          "isUnique": false
+        },
+        "bookmarksInLists_listId_idx": {
+          "name": "bookmarksInLists_listId_idx",
+          "columns": [
+            "listId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+          "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+          "tableFrom": "bookmarksInLists",
+          "tableTo": "bookmarks",
+          "columnsFrom": [
+            "bookmarkId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "bookmarksInLists_listId_bookmarkLists_id_fk": {
+          "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+          "tableFrom": "bookmarksInLists",
+          "tableTo": "bookmarkLists",
+          "columnsFrom": [
+            "listId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "bookmarksInLists_bookmarkId_listId_pk": {
+          "columns": [
+            "bookmarkId",
+            "listId"
+          ],
+          "name": "bookmarksInLists_bookmarkId_listId_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "config": {
+      "name": "config",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "customPrompts": {
+      "name": "customPrompts",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "appliesTo": {
+          "name": "appliesTo",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "customPrompts_userId_idx": {
+          "name": "customPrompts_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "customPrompts_userId_user_id_fk": {
+          "name": "customPrompts_userId_user_id_fk",
+          "tableFrom": "customPrompts",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "highlights": {
+      "name": "highlights",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bookmarkId": {
+          "name": "bookmarkId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "startOffset": {
+          "name": "startOffset",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "endOffset": {
+          "name": "endOffset",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "color": {
+          "name": "color",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'yellow'"
+        },
+        "text": {
+          "name": "text",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "note": {
+          "name": "note",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "highlights_bookmarkId_idx": {
+          "name": "highlights_bookmarkId_idx",
+          "columns": [
+            "bookmarkId"
+          ],
+          "isUnique": false
+        },
+        "highlights_userId_idx": {
+          "name": "highlights_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "highlights_bookmarkId_bookmarks_id_fk": {
+          "name": "highlights_bookmarkId_bookmarks_id_fk",
+          "tableFrom": "highlights",
+          "tableTo": "bookmarks",
+          "columnsFrom": [
+            "bookmarkId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "highlights_userId_user_id_fk": {
+          "name": "highlights_userId_user_id_fk",
+          "tableFrom": "highlights",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "invites": {
+      "name": "invites",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "token": {
+          "name": "token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "expiresAt": {
+          "name": "expiresAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "usedAt": {
+          "name": "usedAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "invitedBy": {
+          "name": "invitedBy",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "invites_token_unique": {
+          "name": "invites_token_unique",
+          "columns": [
+            "token"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {
+        "invites_invitedBy_user_id_fk": {
+          "name": "invites_invitedBy_user_id_fk",
+          "tableFrom": "invites",
+          "tableTo": "user",
+          "columnsFrom": [
+            "invitedBy"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "passwordResetToken": {
+      "name": "passwordResetToken",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "token": {
+          "name": "token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "expires": {
+          "name": "expires",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "passwordResetToken_token_unique": {
+          "name": "passwordResetToken_token_unique",
+          "columns": [
+            "token"
+          ],
+          "isUnique": true
+        },
+        "passwordResetTokens_userId_idx": {
+          "name": "passwordResetTokens_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "passwordResetToken_userId_user_id_fk": {
+          "name": "passwordResetToken_userId_user_id_fk",
+          "tableFrom": "passwordResetToken",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "rssFeedImports": {
+      "name": "rssFeedImports",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "entryId": {
+          "name": "entryId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rssFeedId": {
+          "name": "rssFeedId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bookmarkId": {
+          "name": "bookmarkId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "rssFeedImports_feedIdIdx_idx": {
+          "name": "rssFeedImports_feedIdIdx_idx",
+          "columns": [
+            "rssFeedId"
+          ],
+          "isUnique": false
+        },
+        "rssFeedImports_entryIdIdx_idx": {
+          "name": "rssFeedImports_entryIdIdx_idx",
+          "columns": [
+            "entryId"
+          ],
+          "isUnique": false
+        },
+        "rssFeedImports_rssFeedId_entryId_unique": {
+          "name": "rssFeedImports_rssFeedId_entryId_unique",
+          "columns": [
+            "rssFeedId",
+            "entryId"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {
+        "rssFeedImports_rssFeedId_rssFeeds_id_fk": {
+          "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk",
+          "tableFrom": "rssFeedImports",
+          "tableTo": "rssFeeds",
+          "columnsFrom": [
+            "rssFeedId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "rssFeedImports_bookmarkId_bookmarks_id_fk": {
+          "name": "rssFeedImports_bookmarkId_bookmarks_id_fk",
+          "tableFrom": "rssFeedImports",
+          "tableTo": "bookmarks",
+          "columnsFrom": [
+            "bookmarkId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "set null",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "rssFeeds": {
+      "name": "rssFeeds",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "lastFetchedAt": {
+          "name": "lastFetchedAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "lastFetchedStatus": {
+          "name": "lastFetchedStatus",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false,
+          "default": "'pending'"
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "rssFeeds_userId_idx": {
+          "name": "rssFeeds_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "rssFeeds_userId_user_id_fk": {
+          "name": "rssFeeds_userId_user_id_fk",
+          "tableFrom": "rssFeeds",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "ruleEngineActions": {
+      "name": "ruleEngineActions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ruleId": {
+          "name": "ruleId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "action": {
+          "name": "action",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "listId": {
+          "name": "listId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "tagId": {
+          "name": "tagId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "ruleEngineActions_userId_idx": {
+          "name": "ruleEngineActions_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        },
+        "ruleEngineActions_ruleId_idx": {
+          "name": "ruleEngineActions_ruleId_idx",
+          "columns": [
+            "ruleId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "ruleEngineActions_userId_user_id_fk": {
+          "name": "ruleEngineActions_userId_user_id_fk",
+          "tableFrom": "ruleEngineActions",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "ruleEngineActions_ruleId_ruleEngineRules_id_fk": {
+          "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk",
+          "tableFrom": "ruleEngineActions",
+          "tableTo": "ruleEngineRules",
+          "columnsFrom": [
+            "ruleId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "ruleEngineActions_userId_tagId_fk": {
+          "name": "ruleEngineActions_userId_tagId_fk",
+          "tableFrom": "ruleEngineActions",
+          "tableTo": "bookmarkTags",
+          "columnsFrom": [
+            "userId",
+            "tagId"
+          ],
+          "columnsTo": [
+            "userId",
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "ruleEngineActions_userId_listId_fk": {
+          "name": "ruleEngineActions_userId_listId_fk",
+          "tableFrom": "ruleEngineActions",
+          "tableTo": "bookmarkLists",
+          "columnsFrom": [
+            "userId",
+            "listId"
+          ],
+          "columnsTo": [
+            "userId",
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "ruleEngineRules": {
+      "name": "ruleEngineRules",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "event": {
+          "name": "event",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "condition": {
+          "name": "condition",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "listId": {
+          "name": "listId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "tagId": {
+          "name": "tagId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "ruleEngine_userId_idx": {
+          "name": "ruleEngine_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "ruleEngineRules_userId_user_id_fk": {
+          "name": "ruleEngineRules_userId_user_id_fk",
+          "tableFrom": "ruleEngineRules",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "ruleEngineRules_userId_tagId_fk": {
+          "name": "ruleEngineRules_userId_tagId_fk",
+          "tableFrom": "ruleEngineRules",
+          "tableTo": "bookmarkTags",
+          "columnsFrom": [
+            "userId",
+            "tagId"
+          ],
+          "columnsTo": [
+            "userId",
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "ruleEngineRules_userId_listId_fk": {
+          "name": "ruleEngineRules_userId_listId_fk",
+          "tableFrom": "ruleEngineRules",
+          "tableTo": "bookmarkLists",
+          "columnsFrom": [
+            "userId",
+            "listId"
+          ],
+          "columnsTo": [
+            "userId",
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "session": {
+      "name": "session",
+      "columns": {
+        "sessionToken": {
+          "name": "sessionToken",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "expires": {
+          "name": "expires",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "session_userId_user_id_fk": {
+          "name": "session_userId_user_id_fk",
+          "tableFrom": "session",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "subscriptions": {
+      "name": "subscriptions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "stripeCustomerId": {
+          "name": "stripeCustomerId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "stripeSubscriptionId": {
+          "name": "stripeSubscriptionId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "tier": {
+          "name": "tier",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'free'"
+        },
+        "priceId": {
+          "name": "priceId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cancelAtPeriodEnd": {
+          "name": "cancelAtPeriodEnd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false,
+          "default": false
+        },
+        "startDate": {
+          "name": "startDate",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "endDate": {
+          "name": "endDate",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "modifiedAt": {
+          "name": "modifiedAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "subscriptions_userId_unique": {
+          "name": "subscriptions_userId_unique",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": true
+        },
+        "subscriptions_userId_idx": {
+          "name": "subscriptions_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        },
+        "subscriptions_stripeCustomerId_idx": {
+          "name": "subscriptions_stripeCustomerId_idx",
+          "columns": [
+            "stripeCustomerId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "subscriptions_userId_user_id_fk": {
+          "name": "subscriptions_userId_user_id_fk",
+          "tableFrom": "subscriptions",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "tagsOnBookmarks": {
+      "name": "tagsOnBookmarks",
+      "columns": {
+        "bookmarkId": {
+          "name": "bookmarkId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "tagId": {
+          "name": "tagId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "attachedAt": {
+          "name": "attachedAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "attachedBy": {
+          "name": "attachedBy",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "tagsOnBookmarks_tagId_idx": {
+          "name": "tagsOnBookmarks_tagId_idx",
+          "columns": [
+            "tagId"
+          ],
+          "isUnique": false
+        },
+        "tagsOnBookmarks_bookmarkId_idx": {
+          "name": "tagsOnBookmarks_bookmarkId_idx",
+          "columns": [
+            "bookmarkId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+          "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+          "tableFrom": "tagsOnBookmarks",
+          "tableTo": "bookmarks",
+          "columnsFrom": [
+            "bookmarkId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+          "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+          "tableFrom": "tagsOnBookmarks",
+          "tableTo": "bookmarkTags",
+          "columnsFrom": [
+            "tagId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "tagsOnBookmarks_bookmarkId_tagId_pk": {
+          "columns": [
+            "bookmarkId",
+            "tagId"
+          ],
+          "name": "tagsOnBookmarks_bookmarkId_tagId_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "userSettings": {
+      "name": "userSettings",
+      "columns": {
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "bookmarkClickAction": {
+          "name": "bookmarkClickAction",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'open_original_link'"
+        },
+        "archiveDisplayBehaviour": {
+          "name": "archiveDisplayBehaviour",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'show'"
+        },
+        "timezone": {
+          "name": "timezone",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false,
+          "default": "'UTC'"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "userSettings_userId_user_id_fk": {
+          "name": "userSettings_userId_user_id_fk",
+          "tableFrom": "userSettings",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "user": {
+      "name": "user",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "emailVerified": {
+          "name": "emailVerified",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "image": {
+          "name": "image",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "password": {
+          "name": "password",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "salt": {
+          "name": "salt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false,
+          "default": "'user'"
+        },
+        "bookmarkQuota": {
+          "name": "bookmarkQuota",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "storageQuota": {
+          "name": "storageQuota",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "user_email_unique": {
+          "name": "user_email_unique",
+          "columns": [
+            "email"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "verificationToken": {
+      "name": "verificationToken",
+      "columns": {
+        "identifier": {
+          "name": "identifier",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "token": {
+          "name": "token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "expires": {
+          "name": "expires",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "verificationToken_identifier_token_pk": {
+          "columns": [
+            "identifier",
+            "token"
+          ],
+          "name": "verificationToken_identifier_token_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "webhooks": {
+      "name": "webhooks",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "createdAt": {
+          "name": "createdAt",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "url": {
+          "name": "url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "userId": {
+          "name": "userId",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "events": {
+          "name": "events",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "token": {
+          "name": "token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "webhooks_userId_idx": {
+          "name": "webhooks_userId_idx",
+          "columns": [
+            "userId"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {
+        "webhooks_userId_user_id_fk": {
+          "name": "webhooks_userId_user_id_fk",
+          "tableFrom": "webhooks",
+          "tableTo": "user",
+          "columnsFrom": [
+            "userId"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}
\ No newline at end of file

packages/db/drizzle/meta/_journal.json

diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index d1fb1769..705fc1f4 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -407,6 +407,13 @@
       "when": 1752314617600,
       "tag": "0057_salty_carmella_unuscione",
       "breakpoints": true
+    },
+    {
+      "idx": 58,
+      "version": "6",
+      "when": 1752436258865,
+      "tag": "0058_add_subscription",
+      "breakpoints": true
     }
   ]
 }
\ No newline at end of file

packages/db/schema.ts

diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 79cf2def..6dacdec6 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -584,6 +584,51 @@ export const invites = sqliteTable("invites", {
     .references(() => users.id, { onDelete: "cascade" }),
 });
 
+export const subscriptions = sqliteTable(
+  "subscriptions",
+  {
+    id: text("id")
+      .notNull()
+      .primaryKey()
+      .$defaultFn(() => createId()),
+    userId: text("userId")
+      .notNull()
+      .references(() => users.id, { onDelete: "cascade" })
+      .unique(),
+    stripeCustomerId: text("stripeCustomerId").notNull(),
+    stripeSubscriptionId: text("stripeSubscriptionId"),
+    status: text("status", {
+      enum: [
+        "active",
+        "canceled",
+        "past_due",
+        "unpaid",
+        "incomplete",
+        "trialing",
+        "incomplete_expired",
+        "paused",
+      ],
+    }).notNull(),
+    tier: text("tier", {
+      enum: ["free", "paid"],
+    })
+      .notNull()
+      .default("free"),
+    priceId: text("priceId"),
+    cancelAtPeriodEnd: integer("cancelAtPeriodEnd", {
+      mode: "boolean",
+    }).default(false),
+    startDate: integer("startDate", { mode: "timestamp" }),
+    endDate: integer("endDate", { mode: "timestamp" }),
+    createdAt: createdAtField(),
+    modifiedAt: modifiedAtField(),
+  },
+  (s) => [
+    index("subscriptions_userId_idx").on(s.userId),
+    index("subscriptions_stripeCustomerId_idx").on(s.stripeCustomerId),
+  ],
+);
+
 // Relations
 
 export const userRelations = relations(users, ({ many, one }) => ({
@@ -596,6 +641,7 @@ export const userRelations = relations(users, ({ many, one }) => ({
     fields: [users.id],
     references: [userSettings.userId],
   }),
+  subscription: one(subscriptions),
 }));
 
 export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({
@@ -745,6 +791,13 @@ export const invitesRelations = relations(invites, ({ one }) => ({
   }),
 }));
 
+export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
+  user: one(users, {
+    fields: [subscriptions.userId],
+    references: [users.id],
+  }),
+}));
+
 export const passwordResetTokensRelations = relations(
   passwordResetTokens,
   ({ one }) => ({

packages/shared/config.ts

diff --git a/packages/shared/config.ts b/packages/shared/config.ts
index ed17bb90..634bf564 100644
--- a/packages/shared/config.ts
+++ b/packages/shared/config.ts
@@ -117,6 +117,17 @@ const allEnv = z.object({
   // Rate limiting configuration
   RATE_LIMITING_ENABLED: stringBool("false"),
 
+  // Stripe configuration
+  STRIPE_SECRET_KEY: z.string().optional(),
+  STRIPE_PUBLISHABLE_KEY: z.string().optional(),
+  STRIPE_WEBHOOK_SECRET: z.string().optional(),
+  STRIPE_PRICE_ID: z.string().optional(),
+
+  FREE_QUOTA_BOOKMARK_LIMIT: z.coerce.number().optional(),
+  FREE_QUOTA_ASSET_SIZE_BYTES: z.coerce.number().optional(),
+  PAID_QUOTA_BOOKMARK_LIMIT: z.coerce.number().optional(),
+  PAID_QUOTA_ASSET_SIZE_BYTES: z.coerce.number().optional(),
+
   // Proxy configuration
   HTTP_PROXY: z.string().optional(),
   HTTPS_PROXY: z.string().optional(),
@@ -267,6 +278,23 @@ const serverConfigSchema = allEnv
       rateLimiting: {
         enabled: val.RATE_LIMITING_ENABLED,
       },
+      stripe: {
+        secretKey: val.STRIPE_SECRET_KEY,
+        publishableKey: val.STRIPE_PUBLISHABLE_KEY,
+        webhookSecret: val.STRIPE_WEBHOOK_SECRET,
+        priceId: val.STRIPE_PRICE_ID,
+        isConfigured: !!val.STRIPE_SECRET_KEY && !!val.STRIPE_PUBLISHABLE_KEY,
+      },
+      quotas: {
+        free: {
+          bookmarkLimit: val.FREE_QUOTA_BOOKMARK_LIMIT,
+          assetSizeBytes: val.FREE_QUOTA_ASSET_SIZE_BYTES,
+        },
+        paid: {
+          bookmarkLimit: val.PAID_QUOTA_BOOKMARK_LIMIT,
+          assetSizeBytes: val.PAID_QUOTA_ASSET_SIZE_BYTES,
+        },
+      },
     };
   })
   .refine(

packages/trpc/package.json

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"

packages/trpc/routers/_app.ts

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;

packages/trpc/routers/subscriptions.test.ts

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)
+    });
+  });
+});

packages/trpc/routers/subscriptions.ts

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",
+        });
+      }
+    }),
+});

packages/trpc/routers/users.ts

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,

pnpm-lock.yaml

diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 796b8a2d..a8500335 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1219,6 +1219,9 @@ importers:
       prom-client:
         specifier: ^15.1.3
         version: 15.1.3
+      stripe:
+        specifier: ^18.3.0
+        version: 18.3.0(@types/node@22.15.30)
       superjson:
         specifier: ^2.2.1
         version: 2.2.1
@@ -13386,6 +13389,15 @@ packages:
   strip-literal@2.1.1:
     resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==}
 
+  stripe@18.3.0:
+    resolution: {integrity: sha512-FkxrTUUcWB4CVN2yzgsfF/YHD6WgYHduaa7VmokCy5TLCgl5UNJkwortxcedrxSavQ8Qfa4Ir4JxcbIYiBsyLg==}
+    engines: {node: '>=12.*'}
+    peerDependencies:
+      '@types/node': '>=12.x.x'
+    peerDependenciesMeta:
+      '@types/node':
+        optional: true
+
   strnum@1.1.2:
     resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
 
@@ -15543,7 +15555,7 @@ snapshots:
     dependencies:
       '@babel/template': 7.27.2
       '@babel/traverse': 7.27.4
-      '@babel/types': 7.27.6
+      '@babel/types': 7.28.1
     transitivePeerDependencies:
       - supports-color
 
@@ -30915,6 +30927,12 @@ snapshots:
     dependencies:
       js-tokens: 9.0.1
 
+  stripe@18.3.0(@types/node@22.15.30):
+    dependencies:
+      qs: 6.14.0
+    optionalDependencies:
+      '@types/node': 22.15.30
+
   strnum@1.1.2: {}
 
   structured-headers@0.4.1: {}