From d1d5263486f96db578aad918a59007045c3c077f Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 13 Jul 2025 09:28:24 +0000 Subject: feat: Add stripe based subscriptions --- packages/api/index.ts | 4 +- packages/api/routes/webhooks.ts | 44 + packages/db/drizzle/0058_add_subscription.sql | 19 + packages/db/drizzle/meta/0058_snapshot.json | 2338 +++++++++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/schema.ts | 53 + packages/shared/config.ts | 28 + packages/trpc/package.json | 1 + packages/trpc/routers/_app.ts | 2 + packages/trpc/routers/subscriptions.test.ts | 881 ++++++++++ packages/trpc/routers/subscriptions.ts | 427 +++++ packages/trpc/routers/users.ts | 2 + 12 files changed, 3805 insertions(+), 1 deletion(-) create mode 100644 packages/api/routes/webhooks.ts create mode 100644 packages/db/drizzle/0058_add_subscription.sql create mode 100644 packages/db/drizzle/meta/0058_snapshot.json create mode 100644 packages/trpc/routers/subscriptions.test.ts create mode 100644 packages/trpc/routers/subscriptions.ts (limited to 'packages') 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; 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; 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 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 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 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 }) => ({ 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( diff --git a/packages/trpc/package.json b/packages/trpc/package.json index aa438dbe..df359f5d 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -21,6 +21,7 @@ "drizzle-orm": "^0.44.2", "nodemailer": "^7.0.4", "prom-client": "^15.1.3", + "stripe": "^18.3.0", "superjson": "^2.2.1", "tiny-invariant": "^1.3.3", "zod": "^3.24.2" diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 54335da3..651c8d88 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -10,6 +10,7 @@ import { listsAppRouter } from "./lists"; import { promptsAppRouter } from "./prompts"; import { publicBookmarks } from "./publicBookmarks"; import { rulesAppRouter } from "./rules"; +import { subscriptionsRouter } from "./subscriptions"; import { tagsAppRouter } from "./tags"; import { usersAppRouter } from "./users"; import { webhooksAppRouter } from "./webhooks"; @@ -29,6 +30,7 @@ export const appRouter = router({ rules: rulesAppRouter, invites: invitesAppRouter, publicBookmarks: publicBookmarks, + subscriptions: subscriptionsRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/trpc/routers/subscriptions.test.ts b/packages/trpc/routers/subscriptions.test.ts new file mode 100644 index 00000000..b077c067 --- /dev/null +++ b/packages/trpc/routers/subscriptions.test.ts @@ -0,0 +1,881 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { assets, AssetTypes, subscriptions, users } from "@karakeep/db/schema"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach, getApiCaller } from "../testUtils"; + +// Mock Stripe using vi.hoisted to ensure it's available during module initialization +const mockStripeInstance = vi.hoisted(() => ({ + customers: { + create: vi.fn(), + }, + checkout: { + sessions: { + create: vi.fn(), + }, + }, + billingPortal: { + sessions: { + create: vi.fn(), + }, + }, + subscriptions: { + update: vi.fn(), + list: vi.fn(), + }, + webhooks: { + constructEvent: vi.fn(), + }, +})); + +vi.mock("stripe", () => { + return { + default: vi.fn(() => mockStripeInstance), + }; +}); + +// Mock server config with Stripe settings +vi.mock("@karakeep/shared/config", async (original) => { + const mod = (await original()) as typeof import("@karakeep/shared/config"); + return { + ...mod, + default: { + ...mod.default, + stripe: { + secretKey: "sk_test_123", + priceId: "price_123", + webhookSecret: "whsec_123", + isConfigured: true, + }, + publicUrl: "https://test.karakeep.com", + quotas: { + free: { + bookmarkLimit: 100, + assetSizeBytes: 1000000, // 1MB + }, + paid: { + bookmarkLimit: null, + assetSizeBytes: null, + }, + }, + }, + }; +}); + +beforeEach(defaultBeforeEach(false)); + +describe("Subscription Routes", () => { + let mockCustomersCreate: ReturnType; + let mockCheckoutSessionsCreate: ReturnType; + let mockBillingPortalSessionsCreate: ReturnType; + let mockWebhooksConstructEvent: ReturnType; + let mockSubscriptionsList: ReturnType; + + 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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("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("updates quotas to free limits on subscription cancellation", async ({ + db, + unauthedAPICaller, + }) => { + const user = await unauthedAPICaller.users.create({ + name: "Test User", + email: "test@test.com", + password: "pass1234", + confirmPassword: "pass1234", + }); + + // Set initial paid tier quotas (unlimited) + await db + .update(users) + .set({ + bookmarkQuota: null, + storageQuota: null, + }) + .where(eq(users.id, user.id)); + + // Create active subscription + await db.insert(subscriptions).values({ + userId: user.id, + stripeCustomerId: "cus_123", + stripeSubscriptionId: "sub_123", + status: "active", + tier: "paid", + }); + + const mockEvent = { + type: "customer.subscription.deleted", + data: { + object: { + id: "sub_123", + customer: "cus_123", + metadata: { + userId: user.id, + }, + }, + }, + }; + + // Mock the Stripe subscriptions.list response for deleted subscription (empty list) + mockSubscriptionsList.mockResolvedValue({ + data: [], + }); + + mockWebhooksConstructEvent.mockReturnValue(mockEvent); + + await unauthedAPICaller.subscriptions.handleWebhook({ + body: "webhook-body", + signature: "webhook-signature", + }); + + // Verify user quotas were updated to free limits + const updatedUser = await db.query.users.findFirst({ + where: eq(users.id, user.id), + columns: { + bookmarkQuota: true, + storageQuota: true, + }, + }); + + expect(updatedUser?.bookmarkQuota).toBe(100); // free tier limit + expect(updatedUser?.storageQuota).toBe(1000000); // free tier limit (1MB) + }); + }); +}); diff --git a/packages/trpc/routers/subscriptions.ts b/packages/trpc/routers/subscriptions.ts new file mode 100644 index 00000000..4915a225 --- /dev/null +++ b/packages/trpc/routers/subscriptions.ts @@ -0,0 +1,427 @@ +// Thanks to @t3dotgg for the recommendations (https://github.com/t3dotgg/stripe-recommendations)! + +import { TRPCError } from "@trpc/server"; +import { count, eq, sum } from "drizzle-orm"; +import Stripe from "stripe"; +import { z } from "zod"; + +import { assets, bookmarks, subscriptions, users } from "@karakeep/db/schema"; +import serverConfig from "@karakeep/shared/config"; + +import { authedProcedure, Context, publicProcedure, router } from "../index"; + +const stripe = serverConfig.stripe.secretKey + ? new Stripe(serverConfig.stripe.secretKey, { + apiVersion: "2025-06-30.basil", + }) + : null; + +function requireStripeConfig() { + if (!stripe || !serverConfig.stripe.priceId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Stripe is not configured. Please contact your administrator.", + }); + } + return { stripe, priceId: serverConfig.stripe.priceId }; +} + +// Taken from https://github.com/t3dotgg/stripe-recommendations + +const allowedEvents: Stripe.Event.Type[] = [ + "checkout.session.completed", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.subscription.paused", + "customer.subscription.resumed", + "customer.subscription.pending_update_applied", + "customer.subscription.pending_update_expired", + "customer.subscription.trial_will_end", + "invoice.paid", + "invoice.payment_failed", + "invoice.payment_action_required", + "invoice.upcoming", + "invoice.marked_uncollectible", + "invoice.payment_succeeded", + "payment_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.canceled", +]; + +async function syncStripeDataToDatabase(customerId: string, db: Context["db"]) { + if (!stripe) { + throw new Error("Stripe is not configured"); + } + + const existingSubscription = await db.query.subscriptions.findFirst({ + where: eq(subscriptions.stripeCustomerId, customerId), + }); + + if (!existingSubscription) { + console.error( + `ERROR: No subscription found for customer with this ID ${customerId}`, + ); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "No subscription found for customer with this ID", + }); + } + + try { + const subscriptionsList = await stripe.subscriptions.list({ + customer: customerId, + limit: 1, + status: "all", + }); + + if (subscriptionsList.data.length === 0) { + await db.transaction(async (trx) => { + await trx + .update(subscriptions) + .set({ + status: "canceled", + tier: "free", + stripeSubscriptionId: null, + priceId: null, + cancelAtPeriodEnd: false, + startDate: null, + endDate: null, + }) + .where(eq(subscriptions.stripeCustomerId, customerId)); + + // Update user quotas to free tier limits + await trx + .update(users) + .set({ + bookmarkQuota: serverConfig.quotas.free.bookmarkLimit, + storageQuota: serverConfig.quotas.free.assetSizeBytes, + }) + .where(eq(users.id, existingSubscription.userId)); + }); + return; + } + + const subscription = subscriptionsList.data[0]; + const subscriptionItem = subscription.items.data[0]; + + const subData = { + stripeSubscriptionId: subscription.id, + status: subscription.status, + tier: + subscription.status === "active" || subscription.status === "trialing" + ? ("paid" as const) + : ("free" as const), + priceId: subscription.items.data[0]?.price.id || null, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + startDate: subscriptionItem.current_period_start + ? new Date(subscriptionItem.current_period_start * 1000) + : null, + endDate: subscriptionItem.current_period_end + ? new Date(subscriptionItem.current_period_end * 1000) + : null, + }; + + await db.transaction(async (trx) => { + await trx + .update(subscriptions) + .set(subData) + .where(eq(subscriptions.stripeCustomerId, customerId)); + + if (subData.status === "active" || subData.status === "trialing") { + await trx + .update(users) + .set({ + bookmarkQuota: serverConfig.quotas.paid.bookmarkLimit, + storageQuota: serverConfig.quotas.paid.assetSizeBytes, + }) + .where(eq(users.id, existingSubscription.userId)); + } else { + await trx + .update(users) + .set({ + bookmarkQuota: serverConfig.quotas.free.bookmarkLimit, + storageQuota: serverConfig.quotas.free.assetSizeBytes, + }) + .where(eq(users.id, existingSubscription.userId)); + } + }); + + return subData; + } catch (error) { + console.error("Error syncing Stripe data:", error); + throw error; + } +} + +async function processEvent(event: Stripe.Event, db: Context["db"]) { + if (!allowedEvents.includes(event.type)) { + return; + } + + const { customer: customerId } = event.data.object as { + customer: string; + }; + + if (typeof customerId !== "string") { + throw new Error( + `[STRIPE HOOK] Customer ID isn't string. Event type: ${event.type}`, + ); + } + + return await syncStripeDataToDatabase(customerId, db); +} + +export const subscriptionsRouter = router({ + getSubscriptionStatus: authedProcedure.query(async ({ ctx }) => { + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, ctx.user.id), + }); + + if (!subscription) { + return { + tier: "free" as const, + status: null, + startDate: null, + endDate: null, + hasActiveSubscription: false, + cancelAtPeriodEnd: false, + }; + } + + return { + tier: subscription.tier, + status: subscription.status, + startDate: subscription.startDate, + endDate: subscription.endDate, + hasActiveSubscription: + subscription.status === "active" || subscription.status === "trialing", + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || false, + }; + }), + + getSubscriptionPrice: authedProcedure.query(async () => { + if (!stripe) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Stripe is not configured. Please contact your administrator.", + }); + } + + const { priceId } = requireStripeConfig(); + + const price = await stripe.prices.retrieve(priceId); + + return { + priceId: price.id, + currency: price.currency, + amount: price.unit_amount, + }; + }), + + createCheckoutSession: authedProcedure.mutation(async ({ ctx }) => { + const { stripe, priceId } = requireStripeConfig(); + + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.user.id), + columns: { + email: true, + }, + with: { + subscription: true, + }, + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const existingSubscription = user.subscription; + + if (existingSubscription?.status === "active") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "User already has an active subscription", + }); + } + + let customerId = existingSubscription?.stripeCustomerId; + + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { + userId: ctx.user.id, + }, + }); + customerId = customer.id; + + if (!existingSubscription) { + await ctx.db.insert(subscriptions).values({ + userId: ctx.user.id, + stripeCustomerId: customerId, + status: "unpaid", + }); + } else { + await ctx.db + .update(subscriptions) + .set({ stripeCustomerId: customerId }) + .where(eq(subscriptions.userId, ctx.user.id)); + } + } + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + payment_method_types: ["card"], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "subscription", + success_url: `${serverConfig.publicUrl}/settings/subscription?success=true`, + cancel_url: `${serverConfig.publicUrl}/settings/subscription?canceled=true`, + metadata: { + userId: ctx.user.id, + }, + automatic_tax: { + enabled: true, + }, + customer_update: { + address: "auto", + }, + }); + + return { + sessionId: session.id, + url: session.url, + }; + }), + + syncWithStripe: authedProcedure.mutation(async ({ ctx }) => { + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, ctx.user.id), + }); + + if (!subscription?.stripeCustomerId) { + // No Stripe customer found for user + return { success: true }; + } + + await syncStripeDataToDatabase(subscription.stripeCustomerId, ctx.db); + return { success: true }; + }), + + createPortalSession: authedProcedure.mutation(async ({ ctx }) => { + const { stripe } = requireStripeConfig(); + + const subscription = await ctx.db.query.subscriptions.findFirst({ + where: eq(subscriptions.userId, ctx.user.id), + }); + + if (!subscription?.stripeCustomerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No Stripe customer found", + }); + } + + const session = await stripe.billingPortal.sessions.create({ + customer: subscription.stripeCustomerId, + return_url: `${serverConfig.publicUrl}/settings/subscription`, + }); + + return { + url: session.url, + }; + }), + + getQuotaUsage: authedProcedure.query(async ({ ctx }) => { + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.user.id), + columns: { + bookmarkQuota: true, + storageQuota: true, + }, + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + // Get current bookmark count + const [{ bookmarkCount }] = await ctx.db + .select({ bookmarkCount: count() }) + .from(bookmarks) + .where(eq(bookmarks.userId, ctx.user.id)); + + // Get current storage usage + const [{ storageUsed }] = await ctx.db + .select({ storageUsed: sum(assets.size) }) + .from(assets) + .where(eq(assets.userId, ctx.user.id)); + + return { + bookmarks: { + used: bookmarkCount, + quota: user.bookmarkQuota, + unlimited: user.bookmarkQuota === null, + }, + storage: { + used: Number(storageUsed) || 0, + quota: user.storageQuota, + unlimited: user.storageQuota === null, + }, + }; + }), + + handleWebhook: publicProcedure + .input( + z.object({ + body: z.string(), + signature: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + if (!stripe || !serverConfig.stripe.webhookSecret) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Stripe is not configured", + }); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + input.body, + input.signature, + serverConfig.stripe.webhookSecret, + ); + } catch (err) { + console.error("Webhook signature verification failed:", err); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid signature", + }); + } + + try { + await processEvent(event, ctx.db); + return { received: true }; + } catch (error) { + console.error("Error processing webhook:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Internal server error", + }); + } + }), +}); diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index 4531875c..97784901 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -124,6 +124,8 @@ export async function createUserRaw( salt: input.salt, role: userRole, emailVerified: input.emailVerified, + bookmarkQuota: serverConfig.quotas.free.bookmarkLimit, + storageQuota: serverConfig.quotas.free.assetSizeBytes, }) .returning({ id: users.id, -- cgit v1.2.3-70-g09d2