Add Stripe billing integration + update free tier to 100 PDFs/mo

This commit is contained in:
DocFast Bot 2026-02-14 13:53:19 +00:00
parent facb8df8f4
commit c12c1176b0
7 changed files with 238 additions and 12 deletions

View file

@ -9,5 +9,9 @@ services:
- API_KEYS=${API_KEYS} - API_KEYS=${API_KEYS}
- PORT=3100 - PORT=3100
- NODE_ENV=production - NODE_ENV=production
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- BASE_URL=${BASE_URL:-https://docfast.dev}
- PRO_KEYS=${PRO_KEYS}
mem_limit: 512m mem_limit: 512m
cpus: 1.0 cpus: 1.0

20
package-lock.json generated
View file

@ -13,7 +13,8 @@
"helmet": "^8.0.0", "helmet": "^8.0.0",
"marked": "^15.0.0", "marked": "^15.0.0",
"nanoid": "^5.0.0", "nanoid": "^5.0.0",
"puppeteer": "^24.0.0" "puppeteer": "^24.0.0",
"stripe": "^20.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
@ -3377,6 +3378,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/stripe": {
"version": "20.3.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz",
"integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",

View file

@ -11,17 +11,18 @@
}, },
"dependencies": { "dependencies": {
"express": "^4.21.0", "express": "^4.21.0",
"marked": "^15.0.0", "express-rate-limit": "^7.5.0",
"puppeteer": "^24.0.0",
"nanoid": "^5.0.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"express-rate-limit": "^7.5.0" "marked": "^15.0.0",
"nanoid": "^5.0.0",
"puppeteer": "^24.0.0",
"stripe": "^20.3.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.0",
"tsx": "^4.19.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"type": "module" "type": "module"

View file

@ -171,7 +171,7 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
<li>All templates</li> <li>All templates</li>
<li>Community support</li> <li>Community support</li>
</ul> </ul>
<a href="#" class="btn btn-secondary" style="width:100%">Start Free</a> <a href="mailto:hello@docfast.dev?subject=Free API Key" class="btn btn-secondary" style="width:100%">Get Free Key</a>
</div> </div>
<div class="price-card featured"> <div class="price-card featured">
<h3>Pro</h3> <h3>Pro</h3>
@ -183,7 +183,7 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
<li>Priority support</li> <li>Priority support</li>
<li>Custom templates</li> <li>Custom templates</li>
</ul> </ul>
<a href="#" class="btn btn-primary" style="width:100%">Get Started</a> <a href="#" class="btn btn-primary" style="width:100%" onclick="checkout(event)">Get Started</a>
</div> </div>
</div> </div>
</div> </div>
@ -195,5 +195,18 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
</div> </div>
</footer> </footer>
<script>
async function checkout(e) {
e.preventDefault();
try {
const res = await fetch('/v1/billing/checkout', { method: 'POST' });
const data = await res.json();
if (data.url) window.location.href = data.url;
else alert('Something went wrong. Please try again.');
} catch (err) {
alert('Something went wrong. Please try again.');
}
}
</script>
</body> </body>
</html> </html>

View file

@ -10,11 +10,14 @@ import { authMiddleware } from "./middleware/auth.js";
import { usageMiddleware } from "./middleware/usage.js"; import { usageMiddleware } from "./middleware/usage.js";
import { getUsageStats } from "./middleware/usage.js"; import { getUsageStats } from "./middleware/usage.js";
import { initBrowser, closeBrowser } from "./services/browser.js"; import { initBrowser, closeBrowser } from "./services/browser.js";
import { billingRouter } from "./routes/billing.js";
const app = express(); const app = express();
const PORT = parseInt(process.env.PORT || "3100", 10); const PORT = parseInt(process.env.PORT || "3100", 10);
app.use(helmet()); app.use(helmet());
// Raw body for Stripe webhook signature verification
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
app.use(express.json({ limit: "2mb" })); app.use(express.json({ limit: "2mb" }));
app.use(express.text({ limit: "2mb", type: "text/*" })); app.use(express.text({ limit: "2mb", type: "text/*" }));
@ -34,6 +37,9 @@ app.use("/health", healthRouter);
app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter); app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
// Billing (public — Stripe handles auth)
app.use("/v1/billing", billingRouter);
// Admin: usage stats (protected by auth) // Admin: usage stats (protected by auth)
app.get("/v1/usage", authMiddleware, (_req, res) => { app.get("/v1/usage", authMiddleware, (_req, res) => {
res.json(getUsageStats()); res.json(getUsageStats());

View file

@ -8,7 +8,9 @@ interface UsageRecord {
// In-memory usage tracking (replace with Redis/DB for production) // In-memory usage tracking (replace with Redis/DB for production)
const usage = new Map<string, UsageRecord>(); const usage = new Map<string, UsageRecord>();
const FREE_TIER_LIMIT = 50; // 50 PDFs/month for free tier const FREE_TIER_LIMIT = 100; // 100 PDFs/month for free tier
import { isProKey as isRuntimeProKey } from "../routes/billing.js";
const PRO_KEYS = new Set( const PRO_KEYS = new Set(
(process.env.PRO_KEYS || "").split(",").map((k) => k.trim()).filter(Boolean) (process.env.PRO_KEYS || "").split(",").map((k) => k.trim()).filter(Boolean)
); );
@ -26,8 +28,8 @@ export function usageMiddleware(
const key = req.headers.authorization?.slice(7) || "unknown"; const key = req.headers.authorization?.slice(7) || "unknown";
const monthKey = getMonthKey(); const monthKey = getMonthKey();
// Pro keys have no limit // Pro keys have no limit (env-configured or runtime-provisioned via Stripe)
if (PRO_KEYS.has(key)) { if (PRO_KEYS.has(key) || isRuntimeProKey(key)) {
trackUsage(key, monthKey); trackUsage(key, monthKey);
next(); next();
return; return;

182
src/routes/billing.ts Normal file
View file

@ -0,0 +1,182 @@
import { Router, Request, Response } from "express";
import Stripe from "stripe";
import { nanoid } from "nanoid";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
apiVersion: "2025-01-27.acacia" as any,
});
const router = Router();
// In-memory store of customer → API key mappings
// In production, this would be a database
const customerKeys = new Map<string, string>();
// Create a Stripe Checkout session for Pro subscription
router.post("/checkout", async (_req: Request, res: Response) => {
try {
// Find or create the Pro plan product+price
const priceId = await getOrCreateProPrice();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.BASE_URL || "https://docfast.dev"}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/pricing`,
});
res.json({ url: session.url });
} catch (err: any) {
console.error("Checkout error:", err.message);
res.status(500).json({ error: "Failed to create checkout session" });
}
});
// Success page — retrieve API key after checkout
router.get("/success", async (req: Request, res: Response) => {
const sessionId = req.query.session_id as string;
if (!sessionId) {
res.status(400).json({ error: "Missing session_id" });
return;
}
try {
const session = await stripe.checkout.sessions.retrieve(sessionId);
const customerId = session.customer as string;
if (!customerId) {
res.status(400).json({ error: "No customer found" });
return;
}
// Generate or retrieve API key for this customer
let apiKey = customerKeys.get(customerId);
if (!apiKey) {
apiKey = `df_pro_${nanoid(32)}`;
customerKeys.set(customerId, apiKey);
// Add to PRO_KEYS runtime set
addProKey(apiKey);
}
res.json({
message: "Welcome to DocFast Pro! 🎉",
apiKey,
docs: "/api",
note: "Save this API key — it won't be shown again.",
});
} catch (err: any) {
console.error("Success page error:", err.message);
res.status(500).json({ error: "Failed to retrieve session" });
}
});
// Stripe webhook for subscription lifecycle events
router.post(
"/webhook",
// Raw body needed for signature verification
async (req: Request, res: Response) => {
const sig = req.headers["stripe-signature"] as string;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.Event;
if (webhookSecret && sig) {
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
webhookSecret
);
} catch (err: any) {
console.error("Webhook signature verification failed:", err.message);
res.status(400).json({ error: "Invalid signature" });
return;
}
} else {
// No webhook secret configured — accept all events (dev mode)
event = req.body as Stripe.Event;
}
switch (event.type) {
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
const key = customerKeys.get(customerId);
if (key) {
removeProKey(key);
customerKeys.delete(customerId);
console.log(`Subscription cancelled for ${customerId}, key revoked`);
}
break;
}
default:
// Ignore other events
break;
}
res.json({ received: true });
}
);
// --- Pro key management ---
// These integrate with the usage middleware's PRO_KEYS set
const runtimeProKeys = new Set<string>();
export function addProKey(key: string): void {
runtimeProKeys.add(key);
}
export function removeProKey(key: string): void {
runtimeProKeys.delete(key);
}
export function isProKey(key: string): boolean {
return runtimeProKeys.has(key);
}
// --- Price management ---
let cachedPriceId: string | null = null;
async function getOrCreateProPrice(): Promise<string> {
if (cachedPriceId) return cachedPriceId;
// Search for existing product
const products = await stripe.products.search({
query: "name:'DocFast Pro'",
});
let productId: string;
if (products.data.length > 0) {
productId = products.data[0].id;
// Find active price
const prices = await stripe.prices.list({
product: productId,
active: true,
limit: 1,
});
if (prices.data.length > 0) {
cachedPriceId = prices.data[0].id;
return cachedPriceId;
}
} else {
const product = await stripe.products.create({
name: "DocFast Pro",
description: "Unlimited PDF conversions via API. HTML, Markdown, and URL to PDF.",
});
productId = product.id;
}
const price = await stripe.prices.create({
product: productId,
unit_amount: 900, // $9.00
currency: "usd",
recurring: { interval: "month" },
});
cachedPriceId = price.id;
return cachedPriceId;
}
export { router as billingRouter };