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";
import { escapeHtml } from "../utils/html.js";
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" });
}
});
/**
* @openapi
* /v1/billing/success:
* get:
* tags: [Billing]
* summary: Checkout success page
* description: |
* Provisions a Pro API key after successful Stripe checkout and displays it in an HTML page.
* Called by Stripe redirect after payment completion.
* parameters:
* - in: query
* name: session_id
* required: true
* schema:
* type: string
* description: Stripe Checkout session ID
* responses:
* 200:
* description: HTML page displaying the new API key
* content:
* text/html:
* schema:
* type: string
* 400:
* description: Missing session_id or no customer found
* 409:
* description: Checkout session already used
* 500:
* description: Failed to retrieve 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(`
DocFast Pro — Key Already Provisioned
`);
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)}Copy
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" });
}
});
/**
* @openapi
* /v1/billing/webhook:
* post:
* tags: [Billing]
* summary: Stripe webhook endpoint
* description: |
* Receives Stripe webhook events for subscription lifecycle management.
* Requires the raw request body and a valid Stripe-Signature header for verification.
* Handles checkout.session.completed, customer.subscription.updated,
* customer.subscription.deleted, and customer.updated events.
* parameters:
* - in: header
* name: Stripe-Signature
* required: true
* schema:
* type: string
* description: Stripe webhook signature for payload verification
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* description: Raw Stripe event payload
* responses:
* 200:
* description: Webhook received
* content:
* application/json:
* schema:
* type: object
* properties:
* received:
* type: boolean
* example: true
* 400:
* description: Missing Stripe-Signature header or invalid signature
* 500:
* description: Webhook secret not configured
*/
// 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 };