init 🎉

This commit is contained in:
Fredrik Jensen 2026-02-12 20:05:29 +01:00
parent 254d2bc5d5
commit 103caf9d3d
19 changed files with 2435 additions and 62 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -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:*)"
]
}
}

View File

@ -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
RESEND_API_KEY=your-resend-api-key-here
POLAR_ACCESS_TOKEN=
POLAR_WEBHOOK_SECRET=

View File

@ -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;

View File

@ -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";

View File

@ -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": {

View File

@ -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": {}
}
}

View File

@ -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": {}
}
}

View File

@ -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
}
]
}

View File

@ -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",

11
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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);

View File

@ -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(),
});

141
src/lib/polar.ts Normal file
View File

@ -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<string, string>) {
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<string, unknown> }) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
const polarCustomerId = data.id as string;
const metadata = data.metadata as Record<string, unknown> | 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;
}
}

256
src/routes/billing.ts Normal file
View File

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

View File

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

View File

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

31
src/routes/webhooks.ts Normal file
View File

@ -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<string, string>);
} 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<string, unknown> });
return reply.status(200).send({ received: true });
});
}

View File

@ -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<string, unknown> = { 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();
},
);