← 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;