From 103caf9d3db5327399fa21af6ed2b6573e97daf5 Mon Sep 17 00:00:00 2001 From: Fredrik Jensen Date: Thu, 12 Feb 2026 20:05:29 +0100 Subject: [PATCH] init :tada: --- .DS_Store | Bin 0 -> 8196 bytes .claude/settings.local.json | 8 +- .env.example | 4 +- drizzle/0007_married_invaders.sql | 39 ++ drizzle/0008_seat_based_billing.sql | 27 + drizzle/meta/0006_snapshot.json | 4 +- drizzle/meta/0007_snapshot.json | 860 ++++++++++++++++++++++++++++ drizzle/meta/0008_snapshot.json | 805 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 14 + package.json | 1 + pnpm-lock.yaml | 11 + src/config.ts | 3 + src/db/schema.ts | 47 +- src/lib/polar.ts | 141 +++++ src/routes/billing.ts | 256 +++++++++ src/routes/index.ts | 4 + src/routes/packages.ts | 33 +- src/routes/webhooks.ts | 31 + src/routes/wms.ts | 209 +++++-- 19 files changed, 2435 insertions(+), 62 deletions(-) create mode 100644 .DS_Store create mode 100644 drizzle/0007_married_invaders.sql create mode 100644 drizzle/0008_seat_based_billing.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/lib/polar.ts create mode 100644 src/routes/billing.ts create mode 100644 src/routes/webhooks.ts diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..159b964fc3fc3b28a52e00d7b1069ef6fb47f7e3 GIT binary patch literal 8196 zcmeHMJ#Q015S{ghd?+a*Dno%52?154ln5am;Ua!OK%xOm?C3zUab(97RM)3~mO!K+ z3VuRr8fd5xqHuv|fP#XOmN&a=XD)Y+QdvmMM!WB{Gqdyj?tGJ76OmaRbykR)L=>Sj zE-hdvX>8|SXq7Nyfd=tJBkIs*H(gn8_Y2-Sp$e!1s(>n>3aA4Af&!@7+@cwE-&=K5 z1yq6mQUQKHSailXVCK=iIxx5`0I-H>*Kmz}fQzvKPBKFN+U@scK9a}U_}aZsFCCA$z--yj!)$|^I4?FxIZ{yqWjcyS0m;{L;0LU`+wdZ=PNm0J%8ip zfq!mht}WS~#9#7W*m1n8xOW3;OO|BL{sPNjk(|DKfA>3lpQhHsC%I^q`KZ#uqEx4C zRs~~D8KS*Qd8^F(_0E+ucm4em@$%D`d*t%F;_NCgt}Jr_#sM>r&Ij|pfNT9{RkW!B z^H-qiQ#N`3kJ>ux{D1z$G@~k@3d~IbQ%ly9RiyK!xtNzrwMB2Cb7Q~EqYJ^{syWbY f$AQxy3~_DY$}w@k%p+#d^dA8ygAS^|Tow2WQHT@; literal 0 HcmV?d00001 diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ac43c9d..04e2c59 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,13 @@ "Bash(pnpm db:generate:*)", "Bash(DATABASE_URL=\"postgresql://fedjens@localhost:5432/eyrun_test\" JWT_SECRET=\"test-secret-that-is-at-least-32-characters-long\" pnpm db:migrate:*)", "Bash(ls:*)", - "Bash(curl:*)" + "Bash(curl:*)", + "WebFetch(domain:polar.sh)", + "Bash(printf:*)", + "Bash(kill:*)", + "Bash(node -e:*)", + "Bash(timeout 5 pnpm dev:*)", + "Bash(gtimeout:*)" ] } } diff --git a/.env.example b/.env.example index 59af9d8..75ddd97 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,6 @@ DATABASE_URL=postgresql://user:password@localhost:5432/eyrun JWT_SECRET=change-me-to-a-random-string-at-least-32-chars PORT=3000 HOST=0.0.0.0 -RESEND_API_KEY=your-resend-api-key-here \ No newline at end of file +RESEND_API_KEY=your-resend-api-key-here +POLAR_ACCESS_TOKEN= +POLAR_WEBHOOK_SECRET= diff --git a/drizzle/0007_married_invaders.sql b/drizzle/0007_married_invaders.sql new file mode 100644 index 0000000..975b90d --- /dev/null +++ b/drizzle/0007_married_invaders.sql @@ -0,0 +1,39 @@ +CREATE TYPE "public"."checkout_status" AS ENUM('pending', 'completed', 'expired');--> statement-breakpoint +CREATE TYPE "public"."subscription_status" AS ENUM('active', 'canceled', 'revoked', 'past_due');--> statement-breakpoint +CREATE TABLE "checkouts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "project_id" uuid NOT NULL, + "polar_checkout_id" text NOT NULL, + "wm_name" text NOT NULL, + "package_id" uuid NOT NULL, + "vcpu" real NOT NULL, + "ram" integer NOT NULL, + "disk" integer NOT NULL, + "status" "checkout_status" DEFAULT 'pending' NOT NULL, + "wm_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "checkouts_polar_checkout_id_unique" UNIQUE("polar_checkout_id") +); +--> statement-breakpoint +CREATE TABLE "subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL, + "wm_id" uuid, + "polar_subscription_id" text NOT NULL, + "status" "subscription_status" DEFAULT 'active' NOT NULL, + "current_period_start" timestamp with time zone, + "current_period_end" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "subscriptions_polar_subscription_id_unique" UNIQUE("polar_subscription_id") +); +--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "polar_customer_id" text;--> statement-breakpoint +ALTER TABLE "wms" ADD COLUMN "polar_subscription_id" text;--> statement-breakpoint +ALTER TABLE "checkouts" ADD CONSTRAINT "checkouts_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "checkouts" ADD CONSTRAINT "checkouts_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "checkouts" ADD CONSTRAINT "checkouts_package_id_packages_id_fk" FOREIGN KEY ("package_id") REFERENCES "public"."packages"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "checkouts" ADD CONSTRAINT "checkouts_wm_id_wms_id_fk" FOREIGN KEY ("wm_id") REFERENCES "public"."wms"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_wm_id_wms_id_fk" FOREIGN KEY ("wm_id") REFERENCES "public"."wms"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0008_seat_based_billing.sql b/drizzle/0008_seat_based_billing.sql new file mode 100644 index 0000000..f04f7d7 --- /dev/null +++ b/drizzle/0008_seat_based_billing.sql @@ -0,0 +1,27 @@ +-- Subscriptions: add package_id (nullable first for backfill), add seats, drop wm_id +ALTER TABLE "subscriptions" ADD COLUMN "package_id" uuid;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD COLUMN "seats" integer DEFAULT 1 NOT NULL;--> statement-breakpoint + +-- Backfill package_id from wms via wm_id join +UPDATE "subscriptions" s +SET "package_id" = w."package_id" +FROM "wms" w +WHERE s."wm_id" = w."id" AND w."package_id" IS NOT NULL;--> statement-breakpoint + +-- Make package_id NOT NULL and add FK +ALTER TABLE "subscriptions" ALTER COLUMN "package_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_package_id_packages_id_fk" FOREIGN KEY ("package_id") REFERENCES "public"."packages"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint + +-- Drop wm_id FK and column from subscriptions +ALTER TABLE "subscriptions" DROP CONSTRAINT IF EXISTS "subscriptions_wm_id_wms_id_fk";--> statement-breakpoint +ALTER TABLE "subscriptions" DROP COLUMN IF EXISTS "wm_id";--> statement-breakpoint + +-- Checkouts: drop columns no longer needed +ALTER TABLE "checkouts" DROP CONSTRAINT IF EXISTS "checkouts_project_id_projects_id_fk";--> statement-breakpoint +ALTER TABLE "checkouts" DROP CONSTRAINT IF EXISTS "checkouts_wm_id_wms_id_fk";--> statement-breakpoint +ALTER TABLE "checkouts" DROP COLUMN IF EXISTS "project_id";--> statement-breakpoint +ALTER TABLE "checkouts" DROP COLUMN IF EXISTS "wm_name";--> statement-breakpoint +ALTER TABLE "checkouts" DROP COLUMN IF EXISTS "vcpu";--> statement-breakpoint +ALTER TABLE "checkouts" DROP COLUMN IF EXISTS "ram";--> statement-breakpoint +ALTER TABLE "checkouts" DROP COLUMN IF EXISTS "disk";--> statement-breakpoint +ALTER TABLE "checkouts" DROP COLUMN IF EXISTS "wm_id"; diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json index 60e8c64..5a6eac1 100644 --- a/drizzle/meta/0006_snapshot.json +++ b/drizzle/meta/0006_snapshot.json @@ -1,6 +1,6 @@ { - "id": "08d293e6-8a0d-4abf-bbe5-600133a8d287", - "prevId": "3fe6ec31-b399-41bd-88d8-b1b855d5b3ad", + "id": "f51ba4f1-5d34-487c-9eea-2af8588007ed", + "prevId": "08d293e6-8a0d-4abf-bbe5-600133a8d287", "version": "7", "dialect": "postgresql", "tables": { diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..af4ee6d --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,860 @@ +{ + "id": "0370af60-4753-4a03-891e-4018d0a36fda", + "prevId": "f51ba4f1-5d34-487c-9eea-2af8588007ed", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "polar_checkout_id": { + "name": "polar_checkout_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wm_name": { + "name": "wm_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "vcpu": { + "name": "vcpu", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "checkout_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "wm_id": { + "name": "wm_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "checkouts_account_id_accounts_id_fk": { + "name": "checkouts_account_id_accounts_id_fk", + "tableFrom": "checkouts", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkouts_project_id_projects_id_fk": { + "name": "checkouts_project_id_projects_id_fk", + "tableFrom": "checkouts", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkouts_package_id_packages_id_fk": { + "name": "checkouts_package_id_packages_id_fk", + "tableFrom": "checkouts", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkouts_wm_id_wms_id_fk": { + "name": "checkouts_wm_id_wms_id_fk", + "tableFrom": "checkouts", + "tableTo": "wms", + "columnsFrom": [ + "wm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "checkouts_polar_checkout_id_unique": { + "name": "checkouts_polar_checkout_id_unique", + "nullsNotDistinct": false, + "columns": [ + "polar_checkout_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memberships_user_id_account_id_unique": { + "name": "memberships_user_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packages": { + "name": "packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vcpu": { + "name": "vcpu", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_monthly": { + "name": "price_monthly", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_slug_unique": { + "name": "packages_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_account_id_accounts_id_fk": { + "name": "projects_account_id_accounts_id_fk", + "tableFrom": "projects", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "wm_id": { + "name": "wm_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "polar_subscription_id": { + "name": "polar_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_account_id_accounts_id_fk": { + "name": "subscriptions_account_id_accounts_id_fk", + "tableFrom": "subscriptions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "subscriptions_wm_id_wms_id_fk": { + "name": "subscriptions_wm_id_wms_id_fk", + "tableFrom": "subscriptions", + "tableTo": "wms", + "columnsFrom": [ + "wm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_polar_subscription_id_unique": { + "name": "subscriptions_polar_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "polar_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wms": { + "name": "wms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "vcpu": { + "name": "vcpu", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "polar_subscription_id": { + "name": "polar_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wms_project_id_projects_id_fk": { + "name": "wms_project_id_projects_id_fk", + "tableFrom": "wms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wms_package_id_packages_id_fk": { + "name": "wms_package_id_packages_id_fk", + "tableFrom": "wms", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.checkout_status": { + "name": "checkout_status", + "schema": "public", + "values": [ + "pending", + "completed", + "expired" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "canceled", + "revoked", + "past_due" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..2278ee4 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,805 @@ +{ + "id": "5fae9fd8-74d1-4bd4-b2de-01c2912c3dad", + "prevId": "0370af60-4753-4a03-891e-4018d0a36fda", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkouts": { + "name": "checkouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "polar_checkout_id": { + "name": "polar_checkout_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "checkout_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "checkouts_account_id_accounts_id_fk": { + "name": "checkouts_account_id_accounts_id_fk", + "tableFrom": "checkouts", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkouts_package_id_packages_id_fk": { + "name": "checkouts_package_id_packages_id_fk", + "tableFrom": "checkouts", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "checkouts_polar_checkout_id_unique": { + "name": "checkouts_polar_checkout_id_unique", + "nullsNotDistinct": false, + "columns": [ + "polar_checkout_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "memberships_user_id_account_id_unique": { + "name": "memberships_user_id_account_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "account_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packages": { + "name": "packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vcpu": { + "name": "vcpu", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price_monthly": { + "name": "price_monthly", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "packages_slug_unique": { + "name": "packages_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_account_id_accounts_id_fk": { + "name": "projects_account_id_accounts_id_fk", + "tableFrom": "projects", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "polar_subscription_id": { + "name": "polar_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_account_id_accounts_id_fk": { + "name": "subscriptions_account_id_accounts_id_fk", + "tableFrom": "subscriptions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "subscriptions_package_id_packages_id_fk": { + "name": "subscriptions_package_id_packages_id_fk", + "tableFrom": "subscriptions", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_polar_subscription_id_unique": { + "name": "subscriptions_polar_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "polar_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wms": { + "name": "wms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "vcpu": { + "name": "vcpu", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "disk": { + "name": "disk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "polar_subscription_id": { + "name": "polar_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wms_project_id_projects_id_fk": { + "name": "wms_project_id_projects_id_fk", + "tableFrom": "wms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wms_package_id_packages_id_fk": { + "name": "wms_package_id_packages_id_fk", + "tableFrom": "wms", + "tableTo": "packages", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.checkout_status": { + "name": "checkout_status", + "schema": "public", + "values": [ + "pending", + "completed", + "expired" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "canceled", + "revoked", + "past_due" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 686573a..5bf2dff 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,20 @@ "when": 1770547300000, "tag": "0006_seed_packages", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1770565316469, + "tag": "0007_married_invaders", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1770652800000, + "tag": "0008_seat_based_billing", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 3045756..cdb5d41 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "packageManager": "pnpm@10.18.3", "dependencies": { "@fastify/swagger": "^9.7.0", + "@polar-sh/sdk": "^0.42.5", "dotenv": "^17.2.4", "drizzle-orm": "^0.45.1", "fastify": "^5.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dac27b..9e57c54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@fastify/swagger': specifier: ^9.7.0 version: 9.7.0 + '@polar-sh/sdk': + specifier: ^0.42.5 + version: 0.42.5 dotenv: specifier: ^17.2.4 version: 17.2.4 @@ -573,6 +576,9 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@polar-sh/sdk@0.42.5': + resolution: {integrity: sha512-GzC3/ElCtMO55+KeXwFTANlydZzw5qI3DU/F9vAFIsUKuegSmh+Xu03KCL+ct9/imJOvLUQucYhUSsNKqo2j2Q==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -1784,6 +1790,11 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@polar-sh/sdk@0.42.5': + dependencies: + standardwebhooks: 1.0.0 + zod: 4.3.6 + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true diff --git a/src/config.ts b/src/config.ts index 3ac9b58..7b0863d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,9 @@ const envSchema = z.object({ HOST: z.string().default("0.0.0.0"), API_URL: z.url().optional(), RESEND_API_KEY: z.string().min(1), + POLAR_ACCESS_TOKEN: z.string().min(1).optional(), + POLAR_WEBHOOK_SECRET: z.string().min(1).optional(), + POLAR_SERVER: z.enum(["sandbox", "production"]).default("sandbox"), }); const parsed = envSchema.safeParse(process.env); diff --git a/src/db/schema.ts b/src/db/schema.ts index 3d0fc99..3da060b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,7 @@ -import { boolean, integer, numeric, pgEnum, pgTable, real, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; +import { boolean, integer, pgEnum, pgTable, real, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; + +export const checkoutStatusEnum = pgEnum("checkout_status", ["pending", "completed", "expired"]); +export const subscriptionStatusEnum = pgEnum("subscription_status", ["active", "canceled", "revoked", "past_due"]); export const membershipRoleEnum = pgEnum("membership_role", ["owner", "admin", "member"]); @@ -23,6 +26,7 @@ export const otpCodes = pgTable("otp_codes", { export const accounts = pgTable("accounts", { id: uuid().defaultRandom().primaryKey(), name: text().notNull(), + polarCustomerId: text("polar_customer_id"), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); @@ -53,28 +57,43 @@ export const projects = pgTable("projects", { updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); -export const packages = pgTable("packages", { - id: uuid().defaultRandom().primaryKey(), - slug: text().notNull().unique(), - name: text().notNull(), - vcpu: real().notNull(), - ram: integer().notNull(), - disk: integer().notNull(), - priceMonthly: numeric("price_monthly", { precision: 10, scale: 2 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}); - export const wms = pgTable("wms", { id: uuid().defaultRandom().primaryKey(), projectId: uuid("project_id") .notNull() .references(() => projects.id), name: text().notNull(), - packageId: uuid("package_id").references(() => packages.id), + polarProductId: text("polar_product_id"), vcpu: real().notNull(), ram: integer().notNull(), disk: integer().notNull(), + polarSubscriptionId: text("polar_subscription_id"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const checkouts = pgTable("checkouts", { + id: uuid().defaultRandom().primaryKey(), + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id), + polarCheckoutId: text("polar_checkout_id").notNull().unique(), + polarProductId: text("polar_product_id").notNull(), + status: checkoutStatusEnum().notNull().default("pending"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export const subscriptions = pgTable("subscriptions", { + id: uuid().defaultRandom().primaryKey(), + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id), + polarProductId: text("polar_product_id").notNull(), + seats: integer().notNull().default(1), + polarSubscriptionId: text("polar_subscription_id").notNull().unique(), + status: subscriptionStatusEnum().notNull().default("active"), + currentPeriodStart: timestamp("current_period_start", { withTimezone: true }), + currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); diff --git a/src/lib/polar.ts b/src/lib/polar.ts new file mode 100644 index 0000000..fdface7 --- /dev/null +++ b/src/lib/polar.ts @@ -0,0 +1,141 @@ +import { Polar } from "@polar-sh/sdk"; +import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks"; +import { eq } from "drizzle-orm"; +import { config } from "../config.js"; +import { db } from "../db/index.js"; +import { accounts, checkouts, subscriptions } from "../db/schema.js"; + +export const polar = new Polar({ + accessToken: config.POLAR_ACCESS_TOKEN, + server: config.POLAR_SERVER, +}); + +export { WebhookVerificationError }; + +export function parseWebhookEvent(body: string | Buffer, headers: Record) { + if (!config.POLAR_WEBHOOK_SECRET) { + throw new Error("POLAR_WEBHOOK_SECRET is not configured"); + } + return validateEvent(body, headers, config.POLAR_WEBHOOK_SECRET); +} + +export async function handleWebhookEvent(event: { type: string; data: Record }) { + switch (event.type) { + case "checkout.updated": + await handleCheckoutUpdated(event.data); + break; + case "subscription.updated": + await handleSubscriptionUpdated(event.data); + break; + case "subscription.canceled": + await handleSubscriptionCanceled(event.data); + break; + case "subscription.revoked": + await handleSubscriptionRevoked(event.data); + break; + case "customer.created": + await handleCustomerCreated(event.data); + break; + } +} + +async function handleCheckoutUpdated(data: Record) { + if (data.status !== "succeeded") return; + + const polarCheckoutId = data.id as string; + + const [checkout] = await db + .select() + .from(checkouts) + .where(eq(checkouts.polarCheckoutId, polarCheckoutId)); + + if (!checkout || checkout.status !== "pending") return; + + // Create local subscription record (no WM creation — that happens via POST /wms) + if (data.subscriptionId) { + await db.insert(subscriptions).values({ + accountId: checkout.accountId, + packageId: checkout.packageId, + polarSubscriptionId: data.subscriptionId as string, + seats: 1, + status: "active", + }); + } + + // Mark checkout as completed + await db + .update(checkouts) + .set({ status: "completed" }) + .where(eq(checkouts.id, checkout.id)); +} + +async function handleSubscriptionUpdated(data: Record) { + const polarSubId = data.id as string; + const status = data.status as string; + + const mappedStatus = mapSubscriptionStatus(status); + if (!mappedStatus) return; + + const seats = typeof data.seats === "number" ? data.seats : undefined; + + await db + .update(subscriptions) + .set({ + status: mappedStatus, + ...(seats !== undefined ? { seats } : {}), + currentPeriodStart: data.currentPeriodStart + ? new Date(data.currentPeriodStart as string) + : undefined, + currentPeriodEnd: data.currentPeriodEnd + ? new Date(data.currentPeriodEnd as string) + : undefined, + updatedAt: new Date(), + }) + .where(eq(subscriptions.polarSubscriptionId, polarSubId)); +} + +async function handleSubscriptionCanceled(data: Record) { + const polarSubId = data.id as string; + await db + .update(subscriptions) + .set({ status: "canceled", updatedAt: new Date() }) + .where(eq(subscriptions.polarSubscriptionId, polarSubId)); +} + +async function handleSubscriptionRevoked(data: Record) { + const polarSubId = data.id as string; + await db + .update(subscriptions) + .set({ status: "revoked", updatedAt: new Date() }) + .where(eq(subscriptions.polarSubscriptionId, polarSubId)); +} + +async function handleCustomerCreated(data: Record) { + const polarCustomerId = data.id as string; + const metadata = data.metadata as Record | undefined; + const accountId = metadata?.accountId as string | undefined; + + if (!accountId) return; + + await db + .update(accounts) + .set({ polarCustomerId, updatedAt: new Date() }) + .where(eq(accounts.id, accountId)); +} + +function mapSubscriptionStatus( + status: string, +): "active" | "canceled" | "revoked" | "past_due" | null { + switch (status) { + case "active": + return "active"; + case "canceled": + return "canceled"; + case "revoked": + return "revoked"; + case "past_due": + return "past_due"; + default: + return null; + } +} diff --git a/src/routes/billing.ts b/src/routes/billing.ts new file mode 100644 index 0000000..4dad760 --- /dev/null +++ b/src/routes/billing.ts @@ -0,0 +1,256 @@ +import type { FastifyInstance } from "fastify"; +import { eq, and } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { accounts, checkouts, subscriptions } from "../db/schema.js"; +import { polar } from "../lib/polar.js"; + +export async function billingRoutes(app: FastifyInstance) { + // POST /billing/checkout — create a checkout for a package subscription + app.post<{ + Params: { accountId: string }; + Body: { productId: string }; + }>( + "/billing/checkout", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "Create a Polar checkout for a package subscription", + tags: ["Billing"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + accountId: { type: "string", format: "uuid" }, + }, + }, + body: { + type: "object", + required: ["productId"], + properties: { + productId: { type: "string", minLength: 1 }, + }, + }, + response: { + 201: { + description: "Checkout created", + type: "object", + properties: { + checkoutId: { type: "string" }, + checkoutUrl: { type: "string" }, + }, + }, + 400: { + type: "object", + properties: { error: { type: "string" } }, + }, + 409: { + type: "object", + properties: { error: { type: "string" } }, + }, + }, + }, + }, + async (request, reply) => { + const { productId } = request.body; + + // Check for existing active subscription for this product + const [existingSub] = await db + .select() + .from(subscriptions) + .where( + and( + eq(subscriptions.accountId, request.accountId), + eq(subscriptions.polarProductId, productId), + eq(subscriptions.status, "active"), + ), + ); + + if (existingSub) { + return reply + .status(409) + .send({ error: "Active subscription already exists for this product" }); + } + + // Look up account for polarCustomerId + const [account] = await db + .select() + .from(accounts) + .where(eq(accounts.id, request.accountId)); + + const checkout = await polar.checkouts.create({ + products: [productId], + metadata: { + accountId: request.accountId, + productId, + }, + customerMetadata: { + accountId: request.accountId, + }, + ...(account?.polarCustomerId ? { customerId: account.polarCustomerId } : {}), + }); + + // Store checkout record + const [localCheckout] = await db + .insert(checkouts) + .values({ + accountId: request.accountId, + polarCheckoutId: checkout.id, + polarProductId: productId, + }) + .returning(); + + return reply.status(201).send({ + checkoutId: localCheckout.id, + checkoutUrl: checkout.url, + }); + }, + ); + + // GET /billing/checkout/:checkoutId — poll checkout status + app.get<{ + Params: { accountId: string; checkoutId: string }; + }>( + "/billing/checkout/:checkoutId", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "Poll checkout status (for CLI)", + tags: ["Billing"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + accountId: { type: "string", format: "uuid" }, + checkoutId: { type: "string", format: "uuid" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + status: { type: "string", enum: ["pending", "completed", "expired"] }, + }, + }, + 404: { + type: "object", + properties: { error: { type: "string" } }, + }, + }, + }, + }, + async (request, reply) => { + const [checkout] = await db + .select() + .from(checkouts) + .where( + and( + eq(checkouts.id, request.params.checkoutId), + eq(checkouts.accountId, request.accountId), + ), + ); + + if (!checkout) { + return reply.status(404).send({ error: "Checkout not found" }); + } + + return { status: checkout.status }; + }, + ); + + // GET /billing/portal — get customer portal URL + app.get<{ + Params: { accountId: string }; + }>( + "/billing/portal", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "Get Polar customer portal URL", + tags: ["Billing"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + accountId: { type: "string", format: "uuid" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + portalUrl: { type: "string" }, + }, + }, + 404: { + type: "object", + properties: { error: { type: "string" } }, + }, + }, + }, + }, + async (request, reply) => { + const [account] = await db + .select() + .from(accounts) + .where(eq(accounts.id, request.accountId)); + + if (!account?.polarCustomerId) { + return reply + .status(404) + .send({ error: "No billing account found. Complete a checkout first." }); + } + + const session = await polar.customerSessions.create({ + customerId: account.polarCustomerId, + }); + + return { portalUrl: session.customerPortalUrl }; + }, + ); + + // GET /billing/subscriptions — list active subscriptions + app.get<{ + Params: { accountId: string }; + }>( + "/billing/subscriptions", + { + preHandler: [app.authenticate, app.requireAccount], + schema: { + description: "List subscriptions for the account", + tags: ["Billing"], + security: [{ bearerAuth: [] }], + params: { + type: "object", + properties: { + accountId: { type: "string", format: "uuid" }, + }, + }, + response: { + 200: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + polarProductId: { type: "string" }, + seats: { type: "integer" }, + polarSubscriptionId: { type: "string" }, + status: { type: "string" }, + currentPeriodStart: { type: "string", format: "date-time", nullable: true }, + currentPeriodEnd: { type: "string", format: "date-time", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + }, + async (request) => { + return db + .select() + .from(subscriptions) + .where(eq(subscriptions.accountId, request.accountId)); + }, + ); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 1b0a047..528bd42 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,6 +8,8 @@ import { accountRoutes } from "./accounts.js"; import { projectRoutes } from "./projects.js"; import { packageRoutes } from "./packages.js"; import { wmRoutes } from "./wms.js"; +import { billingRoutes } from "./billing.js"; +import { webhookRoutes } from "./webhooks.js"; export async function registerRoutes(app: FastifyInstance) { app.register(healthRoutes); @@ -19,4 +21,6 @@ export async function registerRoutes(app: FastifyInstance) { app.register(projectRoutes, { prefix: "/accounts/:accountId" }); app.register(packageRoutes); app.register(wmRoutes, { prefix: "/accounts/:accountId" }); + app.register(billingRoutes, { prefix: "/accounts/:accountId" }); + app.register(webhookRoutes); } diff --git a/src/routes/packages.ts b/src/routes/packages.ts index a99bc5c..0e610a8 100644 --- a/src/routes/packages.ts +++ b/src/routes/packages.ts @@ -1,9 +1,8 @@ import type { FastifyInstance } from "fastify"; -import { db } from "../db/index.js"; -import { packages } from "../db/schema.js"; +import { polar } from "../lib/polar.js"; export async function packageRoutes(app: FastifyInstance) { - // GET /packages — list available packages + // GET /packages — list available packages from Polar app.get( "/packages", { @@ -18,12 +17,12 @@ export async function packageRoutes(app: FastifyInstance) { type: "object", properties: { id: { type: "string" }, - slug: { type: "string" }, name: { type: "string" }, vcpu: { type: "number" }, - ram: { type: "integer" }, - disk: { type: "integer" }, - priceMonthly: { type: "string" }, + ram: { type: "number" }, + disk: { type: "number" }, + priceMonthly: { type: "integer", description: "Price in cents per seat per month" }, + isFree: { type: "boolean" }, }, }, }, @@ -31,7 +30,25 @@ export async function packageRoutes(app: FastifyInstance) { }, }, async () => { - return db.select().from(packages); + const result = await polar.products.list({ isArchived: false }); + + console.log("Fetched products from Polar:", result.result.items); + + return result.result.items.map((product) => { + const meta = product.metadata ?? {}; + const price = product.prices[0]; + const isFree = price && "amountType" in price && price.amountType === "free"; + + return { + id: product.id, + name: product.name, + vcpu: Number(meta.vcpu) || 0, + ram: Number(meta.ram) || 0, + disk: Number(meta.disk) || 0, + priceMonthly: isFree ? 0 : (price && "priceAmount" in price ? price.priceAmount : 0), + isFree, + }; + }); }, ); } diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts new file mode 100644 index 0000000..dd52508 --- /dev/null +++ b/src/routes/webhooks.ts @@ -0,0 +1,31 @@ +import type { FastifyInstance } from "fastify"; +import { handleWebhookEvent, parseWebhookEvent, WebhookVerificationError } from "../lib/polar.js"; + +export async function webhookRoutes(app: FastifyInstance) { + // Capture raw body for signature verification — scoped to this plugin only + app.addContentTypeParser( + "application/json", + { parseAs: "buffer" }, + (_req, body, done) => { + done(null, body); + }, + ); + + app.post("/webhooks/polar", async (request, reply) => { + const rawBody = request.body as Buffer; + + let event; + try { + event = parseWebhookEvent(rawBody, request.headers as Record); + } catch (err) { + if (err instanceof WebhookVerificationError) { + return reply.status(400).send({ error: "Invalid webhook signature" }); + } + throw err; + } + + await handleWebhookEvent(event as { type: string; data: Record }); + + return reply.status(200).send({ received: true }); + }); +} diff --git a/src/routes/wms.ts b/src/routes/wms.ts index 204c71d..e90c781 100644 --- a/src/routes/wms.ts +++ b/src/routes/wms.ts @@ -1,7 +1,8 @@ import type { FastifyInstance } from "fastify"; -import { eq, and } from "drizzle-orm"; +import { eq, and, count } from "drizzle-orm"; import { db } from "../db/index.js"; -import { wms, packages, projects } from "../db/schema.js"; +import { wms, projects, subscriptions } from "../db/schema.js"; +import { polar } from "../lib/polar.js"; export async function wmRoutes(app: FastifyInstance) { // GET /projects/:projectId/wms — list WMs for a project @@ -30,7 +31,7 @@ export async function wmRoutes(app: FastifyInstance) { id: { type: "string" }, projectId: { type: "string" }, name: { type: "string" }, - packageId: { type: "string", nullable: true }, + polarProductId: { type: "string", nullable: true }, vcpu: { type: "number" }, ram: { type: "integer" }, disk: { type: "integer" }, @@ -67,14 +68,14 @@ export async function wmRoutes(app: FastifyInstance) { // POST /projects/:projectId/wms — create a WM app.post<{ Params: { projectId: string }; - Body: { name: string; packageId?: string; vcpu?: number; ram?: number; disk?: number }; + Body: { name: string; productId: string }; }>( "/projects/:projectId/wms", { preHandler: [app.authenticate, app.requireAccount], schema: { description: - "Create a WM from a package or with custom settings. Provide packageId to use a package, or vcpu/ram/disk for custom settings.", + "Create a WM from a Polar product. Free products create directly; paid products require an active subscription.", tags: ["WMs"], security: [{ bearerAuth: [] }], params: { @@ -86,13 +87,10 @@ export async function wmRoutes(app: FastifyInstance) { }, body: { type: "object", - required: ["name"], + required: ["name", "productId"], properties: { name: { type: "string", minLength: 1 }, - packageId: { type: "string", format: "uuid" }, - vcpu: { type: "number", minimum: 0.25 }, - ram: { type: "integer", minimum: 1 }, - disk: { type: "integer", minimum: 1 }, + productId: { type: "string", minLength: 1 }, }, }, response: { @@ -103,7 +101,7 @@ export async function wmRoutes(app: FastifyInstance) { id: { type: "string" }, projectId: { type: "string" }, name: { type: "string" }, - packageId: { type: "string", nullable: true }, + polarProductId: { type: "string", nullable: true }, vcpu: { type: "number" }, ram: { type: "integer" }, disk: { type: "integer" }, @@ -116,8 +114,13 @@ export async function wmRoutes(app: FastifyInstance) { type: "object", properties: { error: { type: "string" } }, }, + 402: { + description: "Subscription required or payment failed", + type: "object", + properties: { error: { type: "string" } }, + }, 404: { - description: "Project or package not found", + description: "Project or product not found", type: "object", properties: { error: { type: "string" } }, }, @@ -125,7 +128,7 @@ export async function wmRoutes(app: FastifyInstance) { }, }, async (request, reply) => { - const { name, packageId, vcpu, ram, disk } = request.body; + const { name, productId } = request.body; // Verify project belongs to the account const [project] = await db @@ -139,28 +142,100 @@ export async function wmRoutes(app: FastifyInstance) { return reply.status(404).send({ error: "Project not found" }); } - let settings: { vcpu: number; ram: number; disk: number; packageId: string | null }; - - if (packageId) { - const [pkg] = await db.select().from(packages).where(eq(packages.id, packageId)); - if (!pkg) { - return reply.status(404).send({ error: "Package not found" }); - } - settings = { vcpu: pkg.vcpu, ram: pkg.ram, disk: pkg.disk, packageId: pkg.id }; - } else if (vcpu !== undefined && ram !== undefined && disk !== undefined) { - settings = { vcpu, ram, disk, packageId: null }; - } else { - return reply - .status(400) - .send({ error: "Provide either packageId or all of vcpu, ram, and disk" }); + // Fetch product from Polar for specs + let product; + try { + product = await polar.products.get({ id: productId }); + } catch { + return reply.status(404).send({ error: "Product not found" }); } + const meta = product.metadata ?? {}; + const vcpu = Number(meta.vcpu) || 0; + const ram = Number(meta.ram) || 0; + const disk = Number(meta.disk) || 0; + + if (!vcpu || !ram || !disk) { + return reply.status(400).send({ error: "Product is missing required metadata (vcpu, ram, disk)" }); + } + + // Determine if product is free + const price = product.prices[0]; + const isFree = price && "amountType" in price && price.amountType === "free"; + + if (isFree) { + // Free product — create directly without subscription + const [wm] = await db + .insert(wms) + .values({ + projectId: request.params.projectId, + name, + polarProductId: productId, + vcpu, + ram, + disk, + }) + .returning(); + + return reply.status(201).send(wm); + } + + // Paid product — find active subscription + const [sub] = await db + .select() + .from(subscriptions) + .where( + and( + eq(subscriptions.accountId, request.accountId), + eq(subscriptions.polarProductId, productId), + eq(subscriptions.status, "active"), + ), + ); + + if (!sub) { + return reply + .status(402) + .send({ error: "Subscribe first via POST /accounts/:accountId/billing/checkout" }); + } + + // Count WMs currently using this subscription + const [{ wmCount }] = await db + .select({ wmCount: count() }) + .from(wms) + .where(eq(wms.polarSubscriptionId, sub.polarSubscriptionId)); + + if (wmCount >= sub.seats) { + // Need to increment seats + try { + await polar.subscriptions.update({ + id: sub.polarSubscriptionId, + subscriptionUpdate: { seats: sub.seats + 1 }, + }); + + // Update local seat count + await db + .update(subscriptions) + .set({ seats: sub.seats + 1, updatedAt: new Date() }) + .where(eq(subscriptions.id, sub.id)); + } catch (err) { + request.log.warn({ err }, "Failed to increment seats on Polar subscription"); + return reply + .status(402) + .send({ error: "Failed to add seat — payment may have been declined" }); + } + } + + // Create the WM with the subscription link const [wm] = await db .insert(wms) .values({ projectId: request.params.projectId, name, - ...settings, + polarProductId: productId, + vcpu, + ram, + disk, + polarSubscriptionId: sub.polarSubscriptionId, }) .returning(); @@ -177,7 +252,7 @@ export async function wmRoutes(app: FastifyInstance) { { preHandler: [app.authenticate, app.requireAccount], schema: { - description: "Update a WM's name or settings", + description: "Update a WM's name or settings (paid WMs can only change name)", tags: ["WMs"], security: [{ bearerAuth: [] }], params: { @@ -205,7 +280,7 @@ export async function wmRoutes(app: FastifyInstance) { id: { type: "string" }, projectId: { type: "string" }, name: { type: "string" }, - packageId: { type: "string", nullable: true }, + polarProductId: { type: "string", nullable: true }, vcpu: { type: "number" }, ram: { type: "integer" }, disk: { type: "integer" }, @@ -213,6 +288,10 @@ export async function wmRoutes(app: FastifyInstance) { updatedAt: { type: "string", format: "date-time" }, }, }, + 400: { + type: "object", + properties: { error: { type: "string" } }, + }, 404: { description: "WM not found", type: "object", @@ -235,6 +314,21 @@ export async function wmRoutes(app: FastifyInstance) { } const { name, vcpu, ram, disk } = request.body; + + // If trying to change specs, check if it's a paid WM + if (vcpu !== undefined || ram !== undefined || disk !== undefined) { + const [existing] = await db + .select() + .from(wms) + .where(and(eq(wms.id, request.params.id), eq(wms.projectId, request.params.projectId))); + + if (existing?.polarSubscriptionId) { + return reply + .status(400) + .send({ error: "Cannot change specs on a paid WM — delete and recreate with a different product" }); + } + } + const updates: Record = { updatedAt: new Date() }; if (name !== undefined) updates.name = name; if (vcpu !== undefined) updates.vcpu = vcpu; @@ -261,7 +355,7 @@ export async function wmRoutes(app: FastifyInstance) { { preHandler: [app.authenticate, app.requireAccount], schema: { - description: "Delete a WM", + description: "Delete a WM (adjusts seat count or revokes subscription)", tags: ["WMs"], security: [{ bearerAuth: [] }], params: { @@ -297,15 +391,58 @@ export async function wmRoutes(app: FastifyInstance) { return reply.status(404).send({ error: "Project not found" }); } - const [deleted] = await db - .delete(wms) - .where(and(eq(wms.id, request.params.id), eq(wms.projectId, request.params.projectId))) - .returning(); + // Find the WM + const [wm] = await db + .select() + .from(wms) + .where(and(eq(wms.id, request.params.id), eq(wms.projectId, request.params.projectId))); - if (!deleted) { + if (!wm) { return reply.status(404).send({ error: "WM not found" }); } + // Handle subscription seat management + if (wm.polarSubscriptionId) { + // Count WMs on this subscription (including the one being deleted) + const [{ remaining }] = await db + .select({ remaining: count() }) + .from(wms) + .where(eq(wms.polarSubscriptionId, wm.polarSubscriptionId)); + + const remainingAfterDelete = remaining - 1; + + if (remainingAfterDelete <= 0) { + // No more WMs — revoke the subscription + try { + await polar.subscriptions.revoke({ id: wm.polarSubscriptionId }); + } catch (err) { + request.log.warn({ err, subscriptionId: wm.polarSubscriptionId }, "Failed to revoke Polar subscription"); + } + + await db + .update(subscriptions) + .set({ status: "revoked", updatedAt: new Date() }) + .where(eq(subscriptions.polarSubscriptionId, wm.polarSubscriptionId)); + } else { + // Decrement seats + try { + await polar.subscriptions.update({ + id: wm.polarSubscriptionId, + subscriptionUpdate: { seats: remainingAfterDelete }, + }); + + await db + .update(subscriptions) + .set({ seats: remainingAfterDelete, updatedAt: new Date() }) + .where(eq(subscriptions.polarSubscriptionId, wm.polarSubscriptionId)); + } catch (err) { + request.log.warn({ err, subscriptionId: wm.polarSubscriptionId }, "Failed to decrement seats on Polar subscription"); + } + } + } + + await db.delete(wms).where(eq(wms.id, wm.id)); + return reply.status(204).send(); }, );