import { Router } from "express"; import Stripe from "stripe"; import { createProKey, downgradeByCustomer, updateEmailByCustomer } 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; } } // Create a Stripe Checkout session for Pro subscription router.post("/checkout", async (_req, res) => { 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`, }); 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; } const keyInfo = await createProKey(email, customerId); provisionedSessions.add(session.id); // Return a nice HTML page instead of raw JSON res.send(` Welcome to DocFast Pro!

🎉 Welcome to Pro!

Your API key:

${escapeHtml(keyInfo.key)}

Save this key! It won't be shown again.

5,000 PDFs/month • All endpoints • Priority support

View API docs →

`); } catch (err) { logger.error({ err }, "Success page error"); res.status(500).json({ error: "Failed to retrieve session" }); } }); // Stripe webhook for subscription lifecycle events router.post("/webhook", async (req, res) => { const sig = req.headers["stripe-signature"]; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; let event; if (!webhookSecret) { logger.error("STRIPE_WEBHOOK_SECRET is not configured — refusing to process unverified webhooks"); res.status(500).json({ error: "Webhook signature verification is not configured" }); return; } else if (!sig) { res.status(400).json({ error: "Missing stripe-signature header" }); return; } else { try { event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret); } catch (err) { logger.error({ err }, "Webhook signature verification failed"); res.status(400).json({ error: "Invalid signature" }); return; } } switch (event.type) { case "checkout.session.completed": { const session = event.data.object; const customerId = session.customer; const email = session.customer_details?.email; // Filter by product — this Stripe account is shared with other projects try { const fullSession = await getStripe().checkout.sessions.retrieve(session.id, { expand: ["line_items"], }); const lineItems = fullSession.line_items?.data || []; const hasDocfastProduct = lineItems.some((item) => { const price = item.price; const productId = typeof price?.product === "string" ? price.product : price?.product?.id; return productId === DOCFAST_PRODUCT_ID; }); if (!hasDocfastProduct) { logger.info({ sessionId: session.id }, "Ignoring event for different product"); break; } } catch (err) { logger.error({ err, sessionId: session.id }, "Failed to retrieve session line_items"); break; } if (!customerId || !email) { logger.warn("checkout.session.completed: missing customerId or email, skipping key provisioning"); break; } const keyInfo = await createProKey(email, customerId); provisionedSessions.add(session.id); logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key"); break; } case "customer.subscription.updated": { const sub = event.data.object; const customerId = sub.customer; const shouldDowngrade = sub.status === "canceled" || sub.status === "past_due" || sub.status === "unpaid" || sub.cancel_at_period_end === true; if (shouldDowngrade) { if (!(await isDocFastSubscription(sub.id))) { logger.info({ subscriptionId: sub.id }, "customer.subscription.updated: ignoring event for different product"); break; } await downgradeByCustomer(customerId); logger.info({ customerId, status: sub.status, cancelAtPeriodEnd: sub.cancel_at_period_end }, "customer.subscription.updated: downgraded key to free tier"); } break; } case "customer.subscription.deleted": { const sub = event.data.object; const customerId = sub.customer; if (!(await isDocFastSubscription(sub.id))) { logger.info({ subscriptionId: sub.id }, "customer.subscription.deleted: ignoring event for different product"); break; } await downgradeByCustomer(customerId); logger.info({ customerId }, "customer.subscription.deleted: downgraded key to free tier"); break; } case "customer.updated": { const customer = event.data.object; const customerId = customer.id; const newEmail = customer.email; if (customerId && newEmail) { const updated = await updateEmailByCustomer(customerId, newEmail); if (updated) { logger.info({ customerId, newEmail }, "Customer email synced from Stripe"); } } break; } default: break; } res.json({ received: true }); }); // --- Price management --- let cachedPriceId = null; async function getOrCreateProPrice() { if (cachedPriceId) return cachedPriceId; const products = await getStripe().products.search({ query: "name:'DocFast Pro'" }); let productId; if (products.data.length > 0) { productId = products.data[0].id; const prices = await getStripe().prices.list({ product: productId, active: true, limit: 1 }); if (prices.data.length > 0) { cachedPriceId = prices.data[0].id; return cachedPriceId; } } else { const product = await getStripe().products.create({ name: "DocFast Pro", description: "5,000 PDFs / month via API. HTML, Markdown, and URL to PDF.", }); productId = product.id; } const price = await getStripe().prices.create({ product: productId, unit_amount: 900, currency: "eur", recurring: { interval: "month" }, }); cachedPriceId = price.id; return cachedPriceId; } export { router as billingRouter };