← back to Lawyer Directory Builder
src/server/billing.ts
190 lines
/**
* Stripe billing — Lawyer Pro $29/mo subscription.
*
* POST /api/billing/upgrade — auth → Stripe checkout session → 303 redirect
* GET /billing/return — Stripe success_url / cancel_url
* POST /webhooks/stripe-tier — subscription events → flip user.tier (separate from upgrade.ts /webhooks/stripe)
* GET /api/billing/portal — auth → Stripe customer portal session
*
* Env vars (from .env):
* STRIPE_SECRET_KEY live secret
* STRIPE_PRICE_LAWYER_PRO_MONTHLY price ID created by setup script
* STRIPE_WEBHOOK_SECRET whsec_… (set after `stripe listen` or webhook endpoint creation)
* PUBLIC_BASE_URL e.g. http://localhost:9701 — used for return URLs
*
* Slice-B note: webhook signature verification is currently SKIPPED with a
* console warning when STRIPE_WEBHOOK_SECRET is unset. DO NOT deploy publicly
* until that is set + verified.
*/
import express from 'express';
import crypto from 'node:crypto';
import { query } from '../db/pool.ts';
const router = express.Router();
const SK = process.env.STRIPE_SECRET_KEY || '';
const PRICE_ID = process.env.STRIPE_PRICE_LAWYER_PRO_MONTHLY || '';
// Separate from upgrade.ts's STRIPE_WEBHOOK_SECRET — this router must be
// configured as its OWN endpoint in the Stripe dashboard with its own secret.
// Sharing a single secret would mean one of the two webhooks silently fails verification.
const WEBHOOK_SECRET = process.env.STRIPE_TIER_WEBHOOK_SECRET || '';
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL || 'http://localhost:9701';
// Stripe webhook signature verification (HMAC-SHA256, fail-closed).
// Header format: stripe-signature: t=TIMESTAMP,v1=HEXSIG[,v1=HEXSIG2]
function verifyStripeSig(rawBody: Buffer, sigHeader: string | undefined, secret: string, toleranceSec = 300): boolean {
if (!sigHeader || !secret) return false;
const segments = sigHeader.split(',').map(p => p.trim());
const tSeg = segments.find(s => s.startsWith('t=')); const t = tSeg ? tSeg.slice(2) : '';
const v1s = segments.filter(s => s.startsWith('v1=')).map(s => s.slice(3));
if (!t || v1s.length === 0) return false;
const tsSec = parseInt(t, 10);
if (!Number.isFinite(tsSec)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - tsSec) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody.toString('utf8')}`).digest('hex');
const expBuf = Buffer.from(expected, 'hex');
return v1s.some(sig => {
let sigBuf: Buffer;
try { sigBuf = Buffer.from(sig, 'hex'); } catch { return false; }
return sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
});
}
async function stripe(path: string, body?: Record<string, string>): Promise<any> {
const init: RequestInit = {
method: body ? 'POST' : 'GET',
headers: { Authorization: 'Basic ' + Buffer.from(SK + ':').toString('base64') },
};
if (body) {
init.headers = { ...init.headers, 'Content-Type': 'application/x-www-form-urlencoded' };
init.body = new URLSearchParams(body).toString();
}
const r = await fetch('https://api.stripe.com/v1' + path, init);
return r.json();
}
router.post('/api/billing/upgrade', express.json(), async (req, res) => {
if (!req.user) return res.status(401).json({ error: 'login_required' });
if (!SK || !PRICE_ID) return res.status(500).json({ error: 'stripe_not_configured' });
// Ensure customer exists
const u = await query<{ stripe_customer_id: string | null; google_email: string | null; email: string | null }>(
`SELECT stripe_customer_id, google_email, email FROM app_users WHERE id = $1`, [req.user.id]);
let customerId = u.rows[0]?.stripe_customer_id || null;
if (!customerId) {
const cust = await stripe('/customers', {
email: u.rows[0]?.google_email || u.rows[0]?.email || `user-${req.user.id}@unknown.local`,
'metadata[user_id]': String(req.user.id),
});
if (cust.error) return res.status(500).json({ error: cust.error.message });
customerId = cust.id;
await query(`UPDATE app_users SET stripe_customer_id = $1 WHERE id = $2`, [customerId, req.user.id]);
}
// Create checkout session
const session = await stripe('/checkout/sessions', {
mode: 'subscription',
customer: customerId!,
'line_items[0][price]': PRICE_ID,
'line_items[0][quantity]': '1',
success_url: `${PUBLIC_BASE_URL}/billing/return?status=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${PUBLIC_BASE_URL}/billing/return?status=cancel`,
'metadata[user_id]': String(req.user.id),
'subscription_data[metadata][user_id]': String(req.user.id),
});
if (session.error) return res.status(500).json({ error: session.error.message });
res.json({ url: session.url, id: session.id });
});
router.get('/billing/return', async (req, res) => {
const status = String(req.query.status || '');
const esc = (s: any) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
const ok = status === 'success';
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<!doctype html><html><head><meta charset="utf-8"><title>${ok ? 'Welcome' : 'Checkout cancelled'}</title>
<style>body{font:14px/1.5 -apple-system,system-ui,sans-serif;background:#0a0a0c;color:#f4f1ea;display:flex;min-height:100vh;align-items:center;justify-content:center}
.box{max-width:520px;text-align:center;padding:36px;border:1px solid #2a2724;background:#131316;border-radius:6px}
h1{font-family:Cormorant Garamond,Georgia,serif;font-weight:400;font-size:32px;color:#b89968;margin:0 0 12px}
a{color:#b89968}</style></head>
<body><div class="box">
<h1>${ok ? 'Welcome to Lawyer Pro.' : 'No charge made.'}</h1>
<p>${ok ? 'Subscription is active. You can now respond to reviews, DM clients, and edit your firm profile.' : 'You closed the checkout — nothing was billed.'}</p>
<p style="margin-top:24px"><a href="/dashboard.html">→ Dashboard</a> · <a href="/community">→ Community</a></p>
</div></body></html>`);
});
// Stripe webhook — body must be raw for signature verification.
// We mount this router-level; the bodyParser is applied at the route only.
// Path is /webhooks/stripe-tier so it doesn't collide with upgrade.ts's /webhooks/stripe
// (which handles the original $99 upgrade_orders flow). Configure as a separate
// endpoint in the Stripe dashboard and subscribe to customer.subscription.* events.
router.post('/webhooks/stripe-tier',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'] as string | undefined;
const raw = req.body as Buffer;
// Fail-closed signature verification. Without STRIPE_TIER_WEBHOOK_SECRET
// OR a valid signature, return 503 — anyone could otherwise forge a
// customer.subscription.created event and flip themselves to Pro tier.
if (!WEBHOOK_SECRET) {
console.error('[stripe-tier-webhook] STRIPE_TIER_WEBHOOK_SECRET not configured — refusing to process');
return res.status(503).send('webhook not configured');
}
if (!verifyStripeSig(raw, sig, WEBHOOK_SECRET)) {
console.warn('[stripe-tier-webhook] signature mismatch or stale timestamp');
return res.status(400).send('invalid signature');
}
let event: any;
try { event = JSON.parse(raw.toString('utf8')); } catch { return res.status(400).send('bad json'); }
const type = event.type as string;
const obj = event.data?.object;
try {
if (type === 'customer.subscription.created' || type === 'customer.subscription.updated') {
const userId = obj?.metadata?.user_id ? Number(obj.metadata.user_id) : null;
const periodEnd = obj?.current_period_end ? new Date(obj.current_period_end * 1000) : null;
const status = obj?.status;
if (userId && periodEnd && (status === 'active' || status === 'trialing')) {
await query(`
UPDATE app_users
SET tier = CASE WHEN tier IN ('client') THEN 'lawyer' ELSE tier END,
paid_until = $2,
stripe_subscription_id = $3
WHERE id = $1
`, [userId, periodEnd, obj.id]);
console.log(`[stripe-webhook] user ${userId} → lawyer tier, paid_until=${periodEnd.toISOString()}`);
}
} else if (type === 'customer.subscription.deleted') {
const userId = obj?.metadata?.user_id ? Number(obj.metadata.user_id) : null;
if (userId) {
await query(`
UPDATE app_users SET tier = CASE WHEN tier='lawyer' THEN 'client' ELSE tier END,
paid_until = NOW(), stripe_subscription_id = NULL
WHERE id = $1
`, [userId]);
console.log(`[stripe-webhook] user ${userId} → client tier (subscription deleted)`);
}
}
} catch (e) {
console.error('[stripe-webhook] handler error:', (e as Error).message);
}
res.json({ received: true });
});
router.get('/api/billing/portal', async (req, res) => {
if (!req.user) return res.status(401).json({ error: 'login_required' });
const u = await query<{ stripe_customer_id: string | null }>(
`SELECT stripe_customer_id FROM app_users WHERE id = $1`, [req.user.id]);
const cid = u.rows[0]?.stripe_customer_id;
if (!cid) return res.status(400).json({ error: 'no_customer' });
const session = await stripe('/billing_portal/sessions', {
customer: cid,
return_url: `${PUBLIC_BASE_URL}/dashboard.html`,
});
if (session.error) return res.status(500).json({ error: session.error.message });
res.redirect(303, session.url);
});
export default router;