All checks were successful
Deploy to Production / Deploy to Server (push) Successful in 1m4s
Audit #18 - Rate limit store memory growth: - rateLimitStore already had cleanup via cleanupExpiredEntries() per-request + 60s interval - Added .unref() to the setInterval timer for clean graceful shutdown behaviour Audit #25 - Consistent error response shapes: - billing.ts: Fixed 409 plain-text response -> JSON { error: "..." } - index.ts: Simplified 404 from 4-field object to { error: "Not Found: METHOD path" } - signup.ts: Removed extra retryAfter field from rate-limit message object - pdfRateLimit.ts: Merged limit/tier/retryAfter into single error message string - usage.ts: Merged limit/used/upgrade fields into single error message string - convert.ts: Merged detail field into error message (3 occurrences) All error responses now consistently use {"error": "message"} shape.
189 lines
8.1 KiB
JavaScript
189 lines
8.1 KiB
JavaScript
import { Router } from "express";
|
|
import Stripe from "stripe";
|
|
import { createProKey, revokeByCustomer } from "../services/keys.js";
|
|
import logger from "../services/logger.js";
|
|
function escapeHtml(s) {
|
|
return s.replace(/&/g, "&").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();
|
|
// 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(`<!DOCTYPE html>
|
|
<html><head><title>Welcome to DocFast Pro!</title>
|
|
<style>
|
|
body { font-family: system-ui; background: #0a0a0a; color: #e8e8e8; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
.card { background: #141414; border: 1px solid #222; border-radius: 16px; padding: 48px; max-width: 500px; text-align: center; }
|
|
h1 { color: #4f9; margin-bottom: 8px; }
|
|
.key { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 16px; margin: 24px 0; font-family: monospace; font-size: 0.9rem; word-break: break-all; cursor: pointer; }
|
|
.key:hover { border-color: #4f9; }
|
|
p { color: #888; line-height: 1.6; }
|
|
a { color: #4f9; }
|
|
</style></head><body>
|
|
<div class="card">
|
|
<h1>🎉 Welcome to Pro!</h1>
|
|
<p>Your API key:</p>
|
|
<div class="key" style="position:relative" data-key="${escapeHtml(keyInfo.key)}">${escapeHtml(keyInfo.key)}<button onclick="navigator.clipboard.writeText(this.parentElement.dataset.key);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
|
|
<p><strong>Save this key!</strong> It won't be shown again.</p>
|
|
<p>5,000 PDFs/month • All endpoints • Priority support</p>
|
|
<p><a href="/docs">View API docs →</a></p>
|
|
</div></body></html>`);
|
|
}
|
|
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
|
|
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
|
|
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.deleted": {
|
|
const sub = event.data.object;
|
|
const customerId = sub.customer;
|
|
await revokeByCustomer(customerId);
|
|
logger.info({ customerId }, "Subscription cancelled, key revoked");
|
|
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 };
|