Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
- Add reusable header components (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After) - Reference headers in 200 responses on all conversion and demo endpoints - Add Retry-After header to 429 responses - Update Rate Limits section in API description to mention response headers - Add comprehensive tests for header documentation (21 new tests) - All 809 tests passing
298 lines
12 KiB
JavaScript
298 lines
12 KiB
JavaScript
import { Router } from "express";
|
|
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
|
import Stripe from "stripe";
|
|
import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js";
|
|
import logger from "../services/logger.js";
|
|
import { renderSuccessPage, renderAlreadyProvisionedPage } from "../utils/billing-templates.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");
|
|
// @ts-expect-error Stripe SDK types lag behind API versions
|
|
_stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" });
|
|
}
|
|
return _stripe;
|
|
}
|
|
const router = Router();
|
|
// Track provisioned session IDs with TTL to prevent duplicate key creation and memory leaks
|
|
// Map<sessionId, timestamp> - entries older than 24h are periodically cleaned up
|
|
const provisionedSessions = new Map();
|
|
// TTL Configuration
|
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // Clean up every 1 hour
|
|
// Cleanup old provisioned session entries
|
|
function cleanupOldSessions() {
|
|
const now = Date.now();
|
|
const cutoff = now - SESSION_TTL_MS;
|
|
let cleanedCount = 0;
|
|
for (const [sessionId, timestamp] of provisionedSessions.entries()) {
|
|
if (timestamp < cutoff) {
|
|
provisionedSessions.delete(sessionId);
|
|
cleanedCount++;
|
|
}
|
|
}
|
|
if (cleanedCount > 0) {
|
|
logger.info({ cleanedCount, remainingCount: provisionedSessions.size }, "Cleaned up expired provisioned sessions");
|
|
}
|
|
}
|
|
// Start periodic cleanup
|
|
setInterval(cleanupOldSessions, CLEANUP_INTERVAL_MS);
|
|
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) => ipKeyGenerator(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 (browser redirect, not a public API)
|
|
router.get("/success", async (req, res) => {
|
|
const sessionId = req.query.session_id;
|
|
if (!sessionId) {
|
|
res.status(400).json({ error: "Missing session_id" });
|
|
return;
|
|
}
|
|
// Clean up old sessions before checking duplicates
|
|
cleanupOldSessions();
|
|
// 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 Map)
|
|
const existingKey = await findKeyByCustomerId(customerId);
|
|
if (existingKey) {
|
|
provisionedSessions.set(session.id, Date.now());
|
|
res.send(renderAlreadyProvisionedPage());
|
|
return;
|
|
}
|
|
const keyInfo = await createProKey(email, customerId);
|
|
provisionedSessions.set(session.id, Date.now());
|
|
res.send(renderSuccessPage(keyInfo.key));
|
|
}
|
|
catch (err) {
|
|
logger.error({ err }, "Success page error");
|
|
res.status(500).json({ error: "Failed to retrieve session" });
|
|
}
|
|
});
|
|
// Stripe webhook for subscription lifecycle events (internal, not in public API docs)
|
|
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.set(session.id, Date.now());
|
|
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 };
|