← back to Ventura Claw Leads
lib/stripe.js
117 lines
// Ventura Claw Leads — Stripe subscription helpers (subscription-only, no
// Connect, no consumer payment). The same Stripe LIVE account powers NPH +
// lawyer + 3 home-history sites, so each project filters incoming webhook
// events by `metadata.project === 'ventura-claw-leads'`.
const Stripe = require('stripe');
const VCL_TIERS = {
starter: { name: 'Starter', envMonth: 'STRIPE_PRICE_VCL_STARTER_MONTH', cents: 4900, label: '$49 / month' },
standard: { name: 'Standard', envMonth: 'STRIPE_PRICE_VCL_STANDARD_MONTH', cents: 9900, label: '$99 / month' },
premier: { name: 'Premier', envMonth: 'STRIPE_PRICE_VCL_PREMIER_MONTH', cents: 19900, label: '$199 / month' }
};
function client() {
const key = process.env.STRIPE_SECRET_KEY;
if (!key || !key.startsWith('sk_')) return null;
return new Stripe(key, { apiVersion: '2024-10-28.acacia' });
}
function isLive() { return !!client(); }
function priceIdFor(tier) {
const t = VCL_TIERS[tier];
if (!t) return null;
return process.env[t.envMonth] || null;
}
function tierFromPriceId(priceId) {
if (!priceId) return null;
for (const [tier, cfg] of Object.entries(VCL_TIERS)) {
if (process.env[cfg.envMonth] === priceId) return tier;
}
return null;
}
async function ensureCustomer(business) {
const c = client();
if (!c) return { mocked: true, id: 'cus_mock_' + business.id };
if (business.stripe_customer_id) return { id: business.stripe_customer_id };
const created = await c.customers.create({
email: business.email || undefined,
name: business.business_name,
metadata: { business_id: String(business.id), business_slug: business.slug, project: 'ventura-claw-leads' }
});
return { id: created.id };
}
async function createSubscriptionCheckout({ business, tier, successUrl, cancelUrl }) {
const c = client();
const priceId = priceIdFor(tier);
if (!c) {
return { mocked: true, url: `${successUrl}?mock=1&tier=${tier}`, tier };
}
if (!priceId) throw new Error('price_not_configured');
const cust = await ensureCustomer(business);
const session = await c.checkout.sessions.create({
mode: 'subscription',
customer: cust.id,
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
metadata: { business_id: String(business.id), business_slug: business.slug, vcl_tier: tier, project: 'ventura-claw-leads' },
subscription_data: {
metadata: { business_id: String(business.id), business_slug: business.slug, vcl_tier: tier, project: 'ventura-claw-leads' }
},
allow_promotion_codes: true
});
return { url: session.url, id: session.id, customer_id: cust.id };
}
async function createPortalSession({ business, returnUrl }) {
const c = client();
if (!c) return { mocked: true, url: returnUrl };
if (!business.stripe_customer_id) throw new Error('no_customer');
const session = await c.billingPortal.sessions.create({
customer: business.stripe_customer_id,
return_url: returnUrl
});
return { url: session.url };
}
function constructWebhookEvent(rawBody, signature) {
const c = client();
const secret = process.env.STRIPE_VCL_WEBHOOK_SECRET;
if (!c || !secret) return null;
return c.webhooks.constructEvent(rawBody, signature, secret);
}
// Whether an event belongs to VCL — every webhook handler MUST call this
// before acting because the same Stripe account fires events to all
// subscribed endpoints (NPH, lawyer, home-history sites all share the SK).
function eventBelongsToVCL(event) {
if (!event || !event.data || !event.data.object) return false;
const obj = event.data.object;
// Direct project tag on the object's metadata.
if (obj.metadata && obj.metadata.project === 'ventura-claw-leads') return true;
// Subscription events carry the price; check if any item references one of our prices.
if (obj.items && Array.isArray(obj.items.data)) {
for (const item of obj.items.data) {
if (item.price && tierFromPriceId(item.price.id)) return true;
}
}
// Invoice events carry lines.
if (obj.lines && Array.isArray(obj.lines.data)) {
for (const line of obj.lines.data) {
if (line.price && tierFromPriceId(line.price.id)) return true;
}
}
return false;
}
module.exports = {
VCL_TIERS, isLive, priceIdFor, tierFromPriceId,
ensureCustomer, createSubscriptionCheckout, createPortalSession,
constructWebhookEvent, eventBelongsToVCL
};