diff --git a/docker-compose.yml b/docker-compose.yml index 319fa8c..a1ec12f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,5 +9,9 @@ services: - API_KEYS=${API_KEYS} - PORT=3100 - NODE_ENV=production + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} + - BASE_URL=${BASE_URL:-https://docfast.dev} + - PRO_KEYS=${PRO_KEYS} mem_limit: 512m cpus: 1.0 diff --git a/package-lock.json b/package-lock.json index 2335a3d..5f01fb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "helmet": "^8.0.0", "marked": "^15.0.0", "nanoid": "^5.0.0", - "puppeteer": "^24.0.0" + "puppeteer": "^24.0.0", + "stripe": "^20.3.1" }, "devDependencies": { "@types/express": "^5.0.0", @@ -3377,6 +3378,23 @@ "dev": true, "license": "MIT" }, + "node_modules/stripe": { + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz", + "integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/tar-fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", diff --git a/package.json b/package.json index 96edde8..2d6260a 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,18 @@ }, "dependencies": { "express": "^4.21.0", - "marked": "^15.0.0", - "puppeteer": "^24.0.0", - "nanoid": "^5.0.0", + "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", - "express-rate-limit": "^7.5.0" + "marked": "^15.0.0", + "nanoid": "^5.0.0", + "puppeteer": "^24.0.0", + "stripe": "^20.3.1" }, "devDependencies": { - "typescript": "^5.7.0", - "tsx": "^4.19.0", "@types/express": "^5.0.0", "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0", "vitest": "^3.0.0" }, "type": "module" diff --git a/public/index.html b/public/index.html index b990008..4d58cd4 100644 --- a/public/index.html +++ b/public/index.html @@ -171,7 +171,7 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
  • All templates
  • Community support
  • - Start Free + Get Free Key @@ -195,5 +195,18 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0. + diff --git a/src/index.ts b/src/index.ts index 98ea04c..492e2d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,11 +10,14 @@ import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware } from "./middleware/usage.js"; import { getUsageStats } from "./middleware/usage.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; +import { billingRouter } from "./routes/billing.js"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); app.use(helmet()); +// Raw body for Stripe webhook signature verification +app.use("/v1/billing/webhook", express.raw({ type: "application/json" })); app.use(express.json({ limit: "2mb" })); app.use(express.text({ limit: "2mb", type: "text/*" })); @@ -34,6 +37,9 @@ app.use("/health", healthRouter); app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter); app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); +// Billing (public — Stripe handles auth) +app.use("/v1/billing", billingRouter); + // Admin: usage stats (protected by auth) app.get("/v1/usage", authMiddleware, (_req, res) => { res.json(getUsageStats()); diff --git a/src/middleware/usage.ts b/src/middleware/usage.ts index 576c88c..d2e8076 100644 --- a/src/middleware/usage.ts +++ b/src/middleware/usage.ts @@ -8,7 +8,9 @@ interface UsageRecord { // In-memory usage tracking (replace with Redis/DB for production) const usage = new Map(); -const FREE_TIER_LIMIT = 50; // 50 PDFs/month for free tier +const FREE_TIER_LIMIT = 100; // 100 PDFs/month for free tier +import { isProKey as isRuntimeProKey } from "../routes/billing.js"; + const PRO_KEYS = new Set( (process.env.PRO_KEYS || "").split(",").map((k) => k.trim()).filter(Boolean) ); @@ -26,8 +28,8 @@ export function usageMiddleware( const key = req.headers.authorization?.slice(7) || "unknown"; const monthKey = getMonthKey(); - // Pro keys have no limit - if (PRO_KEYS.has(key)) { + // Pro keys have no limit (env-configured or runtime-provisioned via Stripe) + if (PRO_KEYS.has(key) || isRuntimeProKey(key)) { trackUsage(key, monthKey); next(); return; diff --git a/src/routes/billing.ts b/src/routes/billing.ts new file mode 100644 index 0000000..78cedf9 --- /dev/null +++ b/src/routes/billing.ts @@ -0,0 +1,182 @@ +import { Router, Request, Response } from "express"; +import Stripe from "stripe"; +import { nanoid } from "nanoid"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", { + apiVersion: "2025-01-27.acacia" as any, +}); + +const router = Router(); + +// In-memory store of customer → API key mappings +// In production, this would be a database +const customerKeys = new Map(); + +// Create a Stripe Checkout session for Pro subscription +router.post("/checkout", async (_req: Request, res: Response) => { + try { + // Find or create the Pro plan product+price + const priceId = await getOrCreateProPrice(); + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${process.env.BASE_URL || "https://docfast.dev"}/billing/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/pricing`, + }); + + res.json({ url: session.url }); + } catch (err: any) { + console.error("Checkout error:", err.message); + res.status(500).json({ error: "Failed to create checkout session" }); + } +}); + +// Success page — retrieve API key after checkout +router.get("/success", async (req: Request, res: Response) => { + const sessionId = req.query.session_id as string; + if (!sessionId) { + res.status(400).json({ error: "Missing session_id" }); + return; + } + + try { + const session = await stripe.checkout.sessions.retrieve(sessionId); + const customerId = session.customer as string; + + if (!customerId) { + res.status(400).json({ error: "No customer found" }); + return; + } + + // Generate or retrieve API key for this customer + let apiKey = customerKeys.get(customerId); + if (!apiKey) { + apiKey = `df_pro_${nanoid(32)}`; + customerKeys.set(customerId, apiKey); + // Add to PRO_KEYS runtime set + addProKey(apiKey); + } + + res.json({ + message: "Welcome to DocFast Pro! 🎉", + apiKey, + docs: "/api", + note: "Save this API key — it won't be shown again.", + }); + } catch (err: any) { + console.error("Success page error:", err.message); + res.status(500).json({ error: "Failed to retrieve session" }); + } +}); + +// Stripe webhook for subscription lifecycle events +router.post( + "/webhook", + // Raw body needed for signature verification + async (req: Request, res: Response) => { + const sig = req.headers["stripe-signature"] as string; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + + let event: Stripe.Event; + + if (webhookSecret && sig) { + try { + event = stripe.webhooks.constructEvent( + req.body, + sig, + webhookSecret + ); + } catch (err: any) { + console.error("Webhook signature verification failed:", err.message); + res.status(400).json({ error: "Invalid signature" }); + return; + } + } else { + // No webhook secret configured — accept all events (dev mode) + event = req.body as Stripe.Event; + } + + switch (event.type) { + case "customer.subscription.deleted": { + const sub = event.data.object as Stripe.Subscription; + const customerId = sub.customer as string; + const key = customerKeys.get(customerId); + if (key) { + removeProKey(key); + customerKeys.delete(customerId); + console.log(`Subscription cancelled for ${customerId}, key revoked`); + } + break; + } + default: + // Ignore other events + break; + } + + res.json({ received: true }); + } +); + +// --- Pro key management --- +// These integrate with the usage middleware's PRO_KEYS set +const runtimeProKeys = new Set(); + +export function addProKey(key: string): void { + runtimeProKeys.add(key); +} + +export function removeProKey(key: string): void { + runtimeProKeys.delete(key); +} + +export function isProKey(key: string): boolean { + return runtimeProKeys.has(key); +} + +// --- Price management --- +let cachedPriceId: string | null = null; + +async function getOrCreateProPrice(): Promise { + if (cachedPriceId) return cachedPriceId; + + // Search for existing product + const products = await stripe.products.search({ + query: "name:'DocFast Pro'", + }); + + let productId: string; + + if (products.data.length > 0) { + productId = products.data[0].id; + // Find active price + const prices = await stripe.prices.list({ + product: productId, + active: true, + limit: 1, + }); + if (prices.data.length > 0) { + cachedPriceId = prices.data[0].id; + return cachedPriceId; + } + } else { + const product = await stripe.products.create({ + name: "DocFast Pro", + description: "Unlimited PDF conversions via API. HTML, Markdown, and URL to PDF.", + }); + productId = product.id; + } + + const price = await stripe.prices.create({ + product: productId, + unit_amount: 900, // $9.00 + currency: "usd", + recurring: { interval: "month" }, + }); + + cachedPriceId = price.id; + return cachedPriceId; +} + +export { router as billingRouter };