feat(server): stripe billing + plan limits (Phase 3)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
35aca309c9
commit
dc184c4608
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
3
server/src/db/migrations/0003_stripe_subscription.sql
Normal file
3
server/src/db/migrations/0003_stripe_subscription.sql
Normal file
@ -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;
|
||||
577
server/src/db/migrations/meta/0003_snapshot.json
Normal file
577
server/src/db/migrations/meta/0003_snapshot.json
Normal file
@ -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": {}
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,13 @@
|
||||
"when": 1776677642835,
|
||||
"tag": "0002_typical_northstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1776684213404,
|
||||
"tag": "0003_stripe_subscription",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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(),
|
||||
},
|
||||
|
||||
@ -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) => {
|
||||
|
||||
89
server/src/http/billing.ts
Normal file
89
server/src/http/billing.ts
Normal file
@ -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<string> {
|
||||
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 });
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
89
server/src/http/webhook.ts
Normal file
89
server/src/http/webhook.ts
Normal file
@ -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<void> {
|
||||
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));
|
||||
}
|
||||
40
server/src/lib/plans.ts
Normal file
40
server/src/lib/plans.ts
Normal file
@ -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<PlanId, PlanLimits> = {
|
||||
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<User, "plan" | "planExpiresAt"> | 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<User, "plan" | "planExpiresAt"> | null | undefined): PlanLimits {
|
||||
return PLAN_LIMITS[getEffectivePlan(user)];
|
||||
}
|
||||
29
server/src/lib/stripe.ts
Normal file
29
server/src/lib/stripe.ts
Normal file
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user