From dc184c460850e369f0feb762e59d3734b7ea191d Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 20 Apr 2026 13:25:55 +0200 Subject: [PATCH] feat(server): stripe billing + plan limits (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema: stripe_subscription_id, plan_status, plan_expires_at on users - lib/plans.ts: Free (2 GB · 7d · 1 dl) vs Pro (20 GB · 90d · 100 dl) - http/billing.ts: POST /api/billing/{checkout,portal} - http/webhook.ts: verified Stripe webhook → syncs plan lifecycle - http/transfers.ts: enforces per-user plan limits instead of hardcoded caps - http/me.ts: exposes plan + status + planExpiresAt to the client Ops prerequisite: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_MONTHLY, STRIPE_PRICE_YEARLY env vars in anydrop-app-secrets. Co-Authored-By: Claude Opus 4.7 --- pnpm-lock.yaml | 16 + server/package.json | 1 + .../migrations/0003_stripe_subscription.sql | 3 + .../src/db/migrations/meta/0003_snapshot.json | 577 ++++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + server/src/db/schema.ts | 3 + server/src/http/app.ts | 8 + server/src/http/billing.ts | 89 +++ server/src/http/me.ts | 10 +- server/src/http/transfers.ts | 17 +- server/src/http/webhook.ts | 89 +++ server/src/lib/plans.ts | 40 ++ server/src/lib/stripe.ts | 29 + 13 files changed, 881 insertions(+), 8 deletions(-) create mode 100644 server/src/db/migrations/0003_stripe_subscription.sql create mode 100644 server/src/db/migrations/meta/0003_snapshot.json create mode 100644 server/src/http/billing.ts create mode 100644 server/src/http/webhook.ts create mode 100644 server/src/lib/plans.ts create mode 100644 server/src/lib/stripe.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc83582..067280e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: postgres: specifier: ^3.4.5 version: 3.4.9 + stripe: + specifier: ^22.0.2 + version: 22.0.2(@types/node@22.19.17) web-push: specifier: ^3.6.7 version: 3.6.7 @@ -3290,6 +3293,15 @@ packages: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} + stripe@22.0.2: + resolution: {integrity: sha512-2/BLrQ3oB1zlNfeL/LfHFjTGx6EQn0j+ztrrTJHuDjV5VVIpk92oSDaxyKLUr3pG3dnee2LZqhFUv2Bf0G1/3g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + strnum@2.2.3: resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} @@ -7276,6 +7288,10 @@ snapshots: strip-comments@2.0.1: {} + stripe@22.0.2(@types/node@22.19.17): + optionalDependencies: + '@types/node': 22.19.17 + strnum@2.2.3: {} sucrase@3.35.1: diff --git a/server/package.json b/server/package.json index 415d05b..d3db239 100644 --- a/server/package.json +++ b/server/package.json @@ -23,6 +23,7 @@ "nanoid": "^5.1.5", "nodemailer": "^6.9.16", "postgres": "^3.4.5", + "stripe": "^22.0.2", "web-push": "^3.6.7", "ws": "^8.18.1" }, diff --git a/server/src/db/migrations/0003_stripe_subscription.sql b/server/src/db/migrations/0003_stripe_subscription.sql new file mode 100644 index 0000000..5caaffc --- /dev/null +++ b/server/src/db/migrations/0003_stripe_subscription.sql @@ -0,0 +1,3 @@ +ALTER TABLE "users" ADD COLUMN "stripe_subscription_id" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "plan_status" text;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "plan_expires_at" timestamp with time zone; \ No newline at end of file diff --git a/server/src/db/migrations/meta/0003_snapshot.json b/server/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..a147712 --- /dev/null +++ b/server/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,577 @@ +{ + "id": "715a57a4-85a8-4321-82bb-45b7dadd863e", + "prevId": "006000af-0a3b-4b14-8391-1adbd1aba3c4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.magic_links": { + "name": "magic_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "magic_links_token_hash_unique": { + "name": "magic_links_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "magic_links_email_idx": { + "name": "magic_links_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "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 + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transfers": { + "name": "transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_device_id": { + "name": "sender_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipient_email_hash": { + "name": "recipient_email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_metadata": { + "name": "encrypted_metadata", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "max_downloads": { + "name": "max_downloads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "first_download_at": { + "name": "first_download_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "transfers_sender_idx": { + "name": "transfers_sender_idx", + "columns": [ + { + "expression": "sender_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transfers_recipient_idx": { + "name": "transfers_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "transfers_expires_idx": { + "name": "transfers_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transfers_sender_user_id_users_id_fk": { + "name": "transfers_sender_user_id_users_id_fk", + "tableFrom": "transfers", + "tableTo": "users", + "columnsFrom": [ + "sender_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "transfers_recipient_user_id_users_id_fk": { + "name": "transfers_recipient_user_id_users_id_fk", + "tableFrom": "transfers", + "tableTo": "users", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_devices": { + "name": "user_devices", + "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 + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_devices_user_device_unique": { + "name": "user_devices_user_device_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_devices_user_id_users_id_fk": { + "name": "user_devices_user_id_users_id_fk", + "tableFrom": "user_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_status": { + "name": "plan_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_expires_at": { + "name": "plan_expires_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index a688a6e..1f8ccf0 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1776677642835, "tag": "0002_typical_northstar", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1776684213404, + "tag": "0003_stripe_subscription", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 7547b64..e11bea6 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -7,6 +7,9 @@ export const users = pgTable( email: text("email").notNull(), plan: text("plan").notNull().default("free"), stripeCustomerId: text("stripe_customer_id"), + stripeSubscriptionId: text("stripe_subscription_id"), + planStatus: text("plan_status"), + planExpiresAt: timestamp("plan_expires_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/server/src/http/app.ts b/server/src/http/app.ts index 35537dd..088a3ab 100644 --- a/server/src/http/app.ts +++ b/server/src/http/app.ts @@ -3,10 +3,17 @@ import { cors } from "hono/cors"; import { authRoutes } from "./auth.js"; import { meRoutes } from "./me.js"; import { transferRoutes } from "./transfers.js"; +import { billingRoutes } from "./billing.js"; +import { webhookRoutes } from "./webhook.js"; export function buildApp() { const app = new Hono(); + // Stripe posts with its own signing — bypass CORS, which only applies to + // browser-origin requests anyway. Mount the webhook first so the broader + // /api/* CORS middleware below never sees it. + app.route("/api", webhookRoutes); + const corsOrigin = process.env.APP_URL ?? "http://localhost:5173"; app.use( "/api/*", @@ -23,6 +30,7 @@ export function buildApp() { app.route("/api/auth", authRoutes); app.route("/api", meRoutes); app.route("/api", transferRoutes); + app.route("/api", billingRoutes); app.notFound((c) => c.json({ error: "not_found" }, 404)); app.onError((err, c) => { diff --git a/server/src/http/billing.ts b/server/src/http/billing.ts new file mode 100644 index 0000000..200bab1 --- /dev/null +++ b/server/src/http/billing.ts @@ -0,0 +1,89 @@ +import { Hono } from "hono"; +import { eq } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { users } from "../db/schema.js"; +import { resolveSession } from "./session.js"; +import { rateLimit } from "./middleware.js"; +import { getStripe, isStripeConfigured, priceIdFor } from "../lib/stripe.js"; + +export const billingRoutes = new Hono(); + +billingRoutes.use("/billing/*", rateLimit(10)); + +function appUrl(): string { + return process.env.APP_URL ?? "http://localhost:5173"; +} + +/** + * Returns the Stripe customer ID for the user, creating one on the fly if + * missing. Keeps the DB in sync with the Stripe side. + */ +async function ensureCustomerId(userId: string, email: string, existing: string | null): Promise { + if (existing) return existing; + const stripe = getStripe(); + const customer = await stripe.customers.create({ + email, + metadata: { user_id: userId }, + }); + await db.update(users).set({ stripeCustomerId: customer.id }).where(eq(users.id, userId)); + return customer.id; +} + +/** + * POST /api/billing/checkout + * Body: { interval: "monthly" | "yearly" } + * Returns: { url } — the hosted Stripe Checkout URL the client should open. + */ +billingRoutes.post("/billing/checkout", async (c) => { + if (!isStripeConfigured()) return c.json({ error: "billing_not_configured" }, 503); + + const user = await resolveSession(c); + if (!user) return c.json({ error: "unauthenticated" }, 401); + + let body: { interval?: string }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "invalid_body" }, 400); + } + + const interval = body.interval === "yearly" ? "yearly" : "monthly"; + const price = priceIdFor(interval); + + const customerId = await ensureCustomerId(user.id, user.email, user.stripeCustomerId); + + const stripe = getStripe(); + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + customer: customerId, + client_reference_id: user.id, + line_items: [{ price, quantity: 1 }], + success_url: `${appUrl()}/settings?checkout=success`, + cancel_url: `${appUrl()}/pricing?checkout=canceled`, + allow_promotion_codes: true, + }); + + if (!session.url) return c.json({ error: "session_failed" }, 500); + return c.json({ url: session.url }); +}); + +/** + * POST /api/billing/portal + * Opens the Stripe Customer Portal so the user can manage payment method, + * cancel, or view invoices. + */ +billingRoutes.post("/billing/portal", async (c) => { + if (!isStripeConfigured()) return c.json({ error: "billing_not_configured" }, 503); + + const user = await resolveSession(c); + if (!user) return c.json({ error: "unauthenticated" }, 401); + if (!user.stripeCustomerId) return c.json({ error: "no_customer" }, 400); + + const stripe = getStripe(); + const session = await stripe.billingPortal.sessions.create({ + customer: user.stripeCustomerId, + return_url: `${appUrl()}/settings`, + }); + + return c.json({ url: session.url }); +}); diff --git a/server/src/http/me.ts b/server/src/http/me.ts index 853cc93..84ee495 100644 --- a/server/src/http/me.ts +++ b/server/src/http/me.ts @@ -22,7 +22,15 @@ meRoutes.get("/me", async (c) => { .orderBy(userDevices.lastSeenAt); return c.json({ - user: { id: user.id, email: user.email, plan: user.plan, createdAt: user.createdAt }, + user: { + id: user.id, + email: user.email, + plan: user.plan, + planStatus: user.planStatus, + planExpiresAt: user.planExpiresAt, + hasStripeCustomer: !!user.stripeCustomerId, + createdAt: user.createdAt, + }, devices: devices.map((d) => ({ id: d.id, deviceId: d.deviceId, diff --git a/server/src/http/transfers.ts b/server/src/http/transfers.ts index 5ef3a6f..d90131e 100644 --- a/server/src/http/transfers.ts +++ b/server/src/http/transfers.ts @@ -6,16 +6,14 @@ import { transfers, users } from "../db/schema.js"; import { resolveSession } from "./session.js"; import { rateLimit } from "./middleware.js"; import { deleteObject, presignDownload, presignUpload } from "../storage/s3.js"; +import { limitsFor } from "../lib/plans.js"; export const transferRoutes = new Hono(); const UPLOAD_TTL_SECONDS = 15 * 60; const DOWNLOAD_TTL_SECONDS = 10 * 60; const DEFAULT_EXPIRY_DAYS = 7; -const MAX_EXPIRY_DAYS = 30; const MAX_METADATA_LEN = 8_192; -const MAX_SIZE_BYTES_FREE = 2 * 1024 * 1024 * 1024; -const MAX_MAX_DOWNLOADS = 100; transferRoutes.use("/transfers", rateLimit(30)); transferRoutes.use("/transfers/*", rateLimit(60)); @@ -73,10 +71,15 @@ transferRoutes.post("/transfers", async (c) => { return c.json({ error: "invalid_body" }, 400); } + const limits = limitsFor(user); + const sizeBytes = Number(body.sizeBytes); - if (!Number.isInteger(sizeBytes) || sizeBytes <= 0 || sizeBytes > MAX_SIZE_BYTES_FREE) { + if (!Number.isInteger(sizeBytes) || sizeBytes <= 0) { return c.json({ error: "invalid_size" }, 400); } + if (sizeBytes > limits.maxSizeBytes) { + return c.json({ error: "plan_limit_size", limit: limits.maxSizeBytes }, 413); + } const encryptedMetadata = typeof body.encryptedMetadata === "string" ? body.encryptedMetadata : ""; if (!encryptedMetadata || encryptedMetadata.length > MAX_METADATA_LEN) { @@ -84,12 +87,12 @@ transferRoutes.post("/transfers", async (c) => { } const maxDownloads = Number.isInteger(body.maxDownloads) - ? Math.max(1, Math.min(MAX_MAX_DOWNLOADS, body.maxDownloads)) + ? Math.max(1, Math.min(limits.maxDownloads, body.maxDownloads)) : 1; const expiresInDays = Number.isInteger(body.expiresInDays) - ? Math.max(1, Math.min(MAX_EXPIRY_DAYS, body.expiresInDays)) - : DEFAULT_EXPIRY_DAYS; + ? Math.max(1, Math.min(limits.maxExpiryDays, body.expiresInDays)) + : Math.min(DEFAULT_EXPIRY_DAYS, limits.maxExpiryDays); const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); let recipientUserId: string | null = null; diff --git a/server/src/http/webhook.ts b/server/src/http/webhook.ts new file mode 100644 index 0000000..785c8b5 --- /dev/null +++ b/server/src/http/webhook.ts @@ -0,0 +1,89 @@ +import { Hono } from "hono"; +import { eq } from "drizzle-orm"; +import type Stripe from "stripe"; +import { db } from "../db/client.js"; +import { users } from "../db/schema.js"; +import { getStripe, isStripeConfigured, webhookSecret } from "../lib/stripe.js"; + +export const webhookRoutes = new Hono(); + +/** + * POST /api/stripe/webhook + * Verified via the `stripe-signature` header. Keeps the `users` row in sync + * with the subscription lifecycle so plan enforcement stays truthful without + * extra round-trips to Stripe on every request. + */ +webhookRoutes.post("/stripe/webhook", async (c) => { + if (!isStripeConfigured()) return c.json({ error: "not_configured" }, 503); + + const sig = c.req.header("stripe-signature"); + if (!sig) return c.json({ error: "missing_signature" }, 400); + + const raw = await c.req.text(); + let event: Stripe.Event; + try { + event = getStripe().webhooks.constructEvent(raw, sig, webhookSecret()); + } catch (err) { + console.error("[stripe] webhook signature verification failed:", err); + return c.json({ error: "invalid_signature" }, 400); + } + + try { + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object as Stripe.Checkout.Session; + const userId = session.client_reference_id; + const subscriptionId = typeof session.subscription === "string" ? session.subscription : null; + if (!userId || !subscriptionId) break; + const subscription = await getStripe().subscriptions.retrieve(subscriptionId); + await applySubscription(userId, subscription); + break; + } + case "customer.subscription.updated": + case "customer.subscription.deleted": { + const subscription = event.data.object as Stripe.Subscription; + const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id; + const [row] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.stripeCustomerId, customerId)) + .limit(1); + if (!row) break; + await applySubscription(row.id, subscription); + break; + } + default: + // Other events (invoice.paid, payment_method.attached, etc.) are + // acknowledged but not acted on here. The subscription.* events + // above are the source of truth for plan state. + break; + } + } catch (err) { + console.error("[stripe] webhook handler failed:", event.type, err); + return c.json({ error: "handler_failed" }, 500); + } + + return c.json({ received: true }); +}); + +async function applySubscription(userId: string, sub: Stripe.Subscription): Promise { + const active = sub.status === "active" || sub.status === "trialing"; + const plan = active ? "pro" : "free"; + // current_period_end lives on the subscription item in the 2026-03-25 API. + // Fall back to the subscription's own timestamp on older shapes just in case. + const periodEnd = + sub.items.data[0]?.current_period_end ?? + (sub as unknown as { current_period_end?: number }).current_period_end ?? + 0; + const planExpiresAt = periodEnd ? new Date(periodEnd * 1000) : null; + await db + .update(users) + .set({ + plan, + planStatus: sub.status, + planExpiresAt, + stripeSubscriptionId: sub.id, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)); +} diff --git a/server/src/lib/plans.ts b/server/src/lib/plans.ts new file mode 100644 index 0000000..9a60d8a --- /dev/null +++ b/server/src/lib/plans.ts @@ -0,0 +1,40 @@ +import type { User } from "../db/schema.js"; + +export type PlanId = "free" | "pro"; + +export interface PlanLimits { + maxSizeBytes: number; + maxExpiryDays: number; + maxDownloads: number; +} + +const GB = 1024 * 1024 * 1024; + +export const PLAN_LIMITS: Record = { + free: { + maxSizeBytes: 2 * GB, + maxExpiryDays: 7, + maxDownloads: 1, + }, + pro: { + maxSizeBytes: 20 * GB, + maxExpiryDays: 90, + maxDownloads: 100, + }, +}; + +/** + * Returns the plan the user currently has active. A paid subscription is + * honored until `planExpiresAt`, even if `plan_status` has flipped to + * "canceled" — that's the grace window until the billing period ends. + */ +export function getEffectivePlan(user: Pick | null | undefined): PlanId { + if (!user) return "free"; + if (user.plan !== "pro") return "free"; + if (user.planExpiresAt && user.planExpiresAt.getTime() < Date.now()) return "free"; + return "pro"; +} + +export function limitsFor(user: Pick | null | undefined): PlanLimits { + return PLAN_LIMITS[getEffectivePlan(user)]; +} diff --git a/server/src/lib/stripe.ts b/server/src/lib/stripe.ts new file mode 100644 index 0000000..60862be --- /dev/null +++ b/server/src/lib/stripe.ts @@ -0,0 +1,29 @@ +import Stripe from "stripe"; + +let client: Stripe | null = null; + +export function getStripe(): Stripe { + if (client) return client; + const key = process.env.STRIPE_SECRET_KEY; + if (!key) throw new Error("STRIPE_SECRET_KEY is not set"); + client = new Stripe(key, { apiVersion: "2026-03-25.dahlia" }); + return client; +} + +export function isStripeConfigured(): boolean { + return !!process.env.STRIPE_SECRET_KEY; +} + +export function priceIdFor(interval: "monthly" | "yearly"): string { + const id = interval === "monthly" + ? process.env.STRIPE_PRICE_MONTHLY + : process.env.STRIPE_PRICE_YEARLY; + if (!id) throw new Error(`Missing STRIPE_PRICE_${interval.toUpperCase()} env var`); + return id; +} + +export function webhookSecret(): string { + const s = process.env.STRIPE_WEBHOOK_SECRET; + if (!s) throw new Error("STRIPE_WEBHOOK_SECRET is not set"); + return s; +}