← 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
};