import { Router } from "express"; import rateLimit from "express-rate-limit"; import Stripe from "stripe"; import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js"; import logger from "../services/logger.js"; function escapeHtml(s) { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } let _stripe = null; function getStripe() { if (!_stripe) { const key = process.env.STRIPE_SECRET_KEY; if (!key) throw new Error("STRIPE_SECRET_KEY not configured"); _stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" }); } return _stripe; } const router = Router(); // Track provisioned session IDs to prevent duplicate key creation const provisionedSessions = new Set(); const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE"; // Returns true if the given Stripe subscription contains a DocFast product. // Used to filter webhook events — this Stripe account is shared with other projects. async function isDocFastSubscription(subscriptionId) { try { const sub = await getStripe().subscriptions.retrieve(subscriptionId, { expand: ["items.data.price.product"], }); return sub.items.data.some((item) => { const price = item.price; const productId = typeof price?.product === "string" ? price.product : price?.product?.id; return productId === DOCFAST_PRODUCT_ID; }); } catch (err) { logger.error({ err, subscriptionId }, "isDocFastSubscription: failed to retrieve subscription"); return false; } } // Rate limit checkout: max 3 requests per IP per hour const checkoutLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 3, keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown", standardHeaders: true, legacyHeaders: false, message: { error: "Too many checkout requests. Please try again later." }, }); /** * @openapi * /v1/billing/checkout: * post: * tags: [Billing] * summary: Create a Stripe checkout session * description: | * Creates a Stripe Checkout session for a Pro subscription (€9/month). * Returns a URL to redirect the user to Stripe's hosted payment page. * Rate limited to 3 requests per hour per IP. * responses: * 200: * description: Checkout session created * content: * application/json: * schema: * type: object * properties: * url: * type: string * format: uri * description: Stripe Checkout URL to redirect the user to * 413: * description: Request body too large * 429: * description: Too many checkout requests * 500: * description: Failed to create checkout session */ router.post("/checkout", checkoutLimiter, async (req, res) => { // Reject suspiciously large request bodies (>1KB) const contentLength = parseInt(req.headers["content-length"] || "0", 10); if (contentLength > 1024) { res.status(413).json({ error: "Request body too large" }); return; } try { const priceId = await getOrCreateProPrice(); const session = await getStripe().checkout.sessions.create({ mode: "subscription", payment_method_types: ["card"], line_items: [{ price: priceId, quantity: 1 }], success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`, }); const clientIp = req.ip || req.socket.remoteAddress || "unknown"; logger.info({ clientIp, sessionId: session.id }, "Checkout session created"); res.json({ url: session.url }); } catch (err) { logger.error({ err }, "Checkout error"); res.status(500).json({ error: "Failed to create checkout session" }); } }); // Success page — provision Pro API key after checkout router.get("/success", async (req, res) => { const sessionId = req.query.session_id; if (!sessionId) { res.status(400).json({ error: "Missing session_id" }); return; } // Prevent duplicate provisioning from same session if (provisionedSessions.has(sessionId)) { res.status(409).json({ error: "This checkout session has already been used to provision a key. If you lost your key, use the key recovery feature." }); return; } try { const session = await getStripe().checkout.sessions.retrieve(sessionId); const customerId = session.customer; const email = session.customer_details?.email || "unknown@docfast.dev"; if (!customerId) { res.status(400).json({ error: "No customer found" }); return; } // Check DB for existing key (survives pod restarts, unlike provisionedSessions Set) const existingKey = await findKeyByCustomerId(customerId); if (existingKey) { provisionedSessions.add(session.id); res.send(`
A Pro API key has already been created for this purchase.
If you lost your key, use the key recovery feature.
Your API key:
Save this key! It won't be shown again.
5,000 PDFs/month • All endpoints • Priority support