← back to Commercialrealestate
scripts/crcp-billing.js
71 lines
// crcp-billing.js — P1.5 Stripe subscription tiers (docs/TOOL-SPEC.md), TEST-mode scaffold.
// HARD RAIL: TEST keys only. Live keys, real charges, and turning billing on are Steve-gated — this
// module never uses a live key and degrades to {configured:false} until STRIPE_TEST_SECRET is set.
// Mount from serve.js AFTER crcp-accounts: require('./crcp-billing')(app, ROOT, acct.userOf);
'use strict';
const fs = require('fs');
const path = require('path');
const TIERS = {
free: { id: 'free', label: 'Free', price: 0, blurb: 'Browse the board, basic filters, last 30 days' },
pro: { id: 'pro', label: 'Pro', price: 79, price_id_env: 'STRIPE_PRICE_PRO',
blurb: 'Full corpus + history, saved searches, daily alerts, watchlist, assumable-loan leads' },
team: { id: 'team', label: 'Team / API', price: 299, price_id_env: 'STRIPE_PRICE_TEAM',
blurb: 'Everything + federal-data export/API (SEC+FHA, resell-clean), multi-seat' },
};
module.exports = function mountBilling(app, ROOT, userOf) {
const ACC = path.join(ROOT, 'data', 'crcp-accounts.json');
const loadAcc = () => { try { return JSON.parse(fs.readFileSync(ACC, 'utf8')); } catch { return null; } };
const saveAcc = (db) => { const t = ACC + '.tmp'; fs.writeFileSync(t, JSON.stringify(db, null, 2)); fs.renameSync(t, ACC); };
const setTier = (email, tier) => { const db = loadAcc(); if (!db || !db.users || !db.users[email]) return false; db.users[email].tier = tier; saveAcc(db); return true; };
// Lazy, graceful: only "configured" when the TEST secret is present AND the stripe lib is installed.
function stripe() {
const key = process.env.STRIPE_TEST_SECRET || '';
if (!key || !/^sk_test_/.test(key)) return null; // TEST keys ONLY — refuse a live key
try { return require('stripe')(key); } catch { return null; }
}
const configured = () => !!stripe();
// Catalog — always available (drives the pricing UI).
app.get('/api/billing/tiers', (req, res) => res.json({ tiers: TIERS, configured: configured(), mode: configured() ? 'test' : 'unconfigured' }));
// Start a TEST checkout for the signed-in user. 503 until Stripe TEST is wired.
app.post('/api/billing/checkout', async (req, res) => {
const u = userOf && userOf(req); if (!u || !u.email) return res.status(401).json({ error: 'sign in' });
const tierId = String((req.body || {}).tier || '');
const tier = TIERS[tierId]; if (!tier || tier.id === 'free') return res.status(400).json({ error: 'pick a paid tier' });
const s = stripe();
if (!s) return res.status(503).json({ configured: false, hint: 'set STRIPE_TEST_SECRET (sk_test_…) + STRIPE_PRICE_* and `npm i stripe` to enable — TEST mode, Steve-gated for live' });
const priceId = process.env[tier.price_id_env];
if (!priceId) return res.status(503).json({ configured: false, hint: `set ${tier.price_id_env} to the Stripe TEST price id` });
try {
const base = process.env.CRCP_BASE || `${req.protocol}://${req.get('host')}`;
const session = await s.checkout.sessions.create({
mode: 'subscription', customer_email: u.email,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${base}/deals-flow.html?upgraded=1`, cancel_url: `${base}/deals-flow.html`,
metadata: { crcp_email: u.email, crcp_tier: tier.id },
});
res.json({ url: session.url, mode: 'test' });
} catch (e) { res.status(502).json({ error: 'stripe_error', detail: String(e.message).slice(0, 200) }); }
});
// Webhook — sets the user's tier on a completed TEST checkout. NOTE (go-live gap): real signature
// verification needs express.raw on THIS route (global express.json consumes the body first). For the
// TEST scaffold we accept a shared-secret header; wiring Stripe-Signature verify is a go-live task.
app.post('/api/billing/webhook', (req, res) => {
if ((req.headers['x-crcp-webhook'] || '') !== (process.env.CRCP_WEBHOOK_TOKEN || '