← back to NationalPaperHangers

lib/stripe.js

293 lines

// Stripe subscription helpers.
// Pricing tiers map to STRIPE_PRICE_* env vars (set via secrets-manager).
// Until real keys are loaded, all functions return mocked responses so the
// rest of the app can be developed end-to-end.

const Stripe = require('stripe');

const TIERS = {
  pro:        { name: 'Pro',        month: 'STRIPE_PRICE_PRO_MONTH',        year: 'STRIPE_PRICE_PRO_YEAR' },
  signature:  { name: 'Signature',  month: 'STRIPE_PRICE_SIGNATURE_MONTH',  year: 'STRIPE_PRICE_SIGNATURE_YEAR' },
  enterprise: { name: 'Enterprise', month: 'STRIPE_PRICE_ENTERPRISE_MONTH', year: null }
};

const PRICE_TABLE = [
  { tier: 'pro',        cadence: 'month', amount: 39,   label: '$39 / month' },
  { tier: 'pro',        cadence: 'year',  amount: 399,  label: '$399 / year' },
  { tier: 'signature',  cadence: 'month', amount: 149,  label: '$149 / month' },
  { tier: 'signature',  cadence: 'year',  amount: 1500, label: '$1,500 / year' },
  { tier: 'enterprise', cadence: 'month', amount: 399,  label: '$399 / month and up' }
];

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, cadence) {
  const t = TIERS[tier];
  if (!t) return null;
  const envKey = cadence === 'year' ? t.year : t.month;
  if (!envKey) return null;
  return process.env[envKey] || null;
}

// Reverse lookup: given a Stripe price.id, derive { tier, cadence }. Webhook
// events for `customer.subscription.updated` don't carry our metadata.tier, so
// the price ID is the only safe source of truth for the tier transition.
function tierFromPriceId(priceId) {
  if (!priceId) return null;
  for (const [tier, cfg] of Object.entries(TIERS)) {
    for (const cadence of ['month', 'year']) {
      const envKey = cfg[cadence];
      if (envKey && process.env[envKey] && process.env[envKey] === priceId) {
        return { tier, cadence };
      }
    }
  }
  return null;
}

async function ensureCustomer(installer) {
  const c = client();
  if (!c) {
    return { mocked: true, id: 'cus_mock_' + installer.id };
  }
  if (installer.stripe_customer_id) return { id: installer.stripe_customer_id };
  const created = await c.customers.create({
    email: installer.email,
    name: installer.business_name,
    metadata: { installer_id: String(installer.id) }
  });
  return { id: created.id };
}

async function createCheckoutSession({ installer, tier, cadence, successUrl, cancelUrl }) {
  const c = client();
  const priceId = priceIdFor(tier, cadence);
  if (!c) {
    return {
      mocked: true,
      url: `${successUrl}?mock=1&tier=${tier}&cadence=${cadence}`,
      tier, cadence
    };
  }
  if (!priceId) throw new Error('price_not_configured');
  const cust = await ensureCustomer(installer);
  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: { installer_id: String(installer.id), tier, cadence },
    allow_promotion_codes: true
  });
  return { url: session.url, id: session.id, customer_id: cust.id };
}

async function createPortalSession({ installer, returnUrl }) {
  const c = client();
  if (!c) return { mocked: true, url: returnUrl };
  if (!installer.stripe_customer_id) throw new Error('no_customer');
  const session = await c.billingPortal.sessions.create({
    customer: installer.stripe_customer_id,
    return_url: returnUrl
  });
  return { url: session.url };
}

function constructWebhookEvent(rawBody, signature) {
  const c = client();
  // Each project has its own webhook secret — secrets-manager fans them under
  // distinct env names so a multi-project server doesn't cross-validate. Prefer
  // the NPH-specific name; fall back to generic for older deployments.
  const secret = process.env.STRIPE_NPH_WEBHOOK_SECRET || process.env.STRIPE_WEBHOOK_SECRET;
  if (!c || !secret) return null;
  return c.webhooks.constructEvent(rawBody, signature, secret);
}

// =======================================================================
// MARKETPLACE — booking deposits + Stripe Connect Express
// =======================================================================
// Every booking carries a deposit. NPH retains `application_fee_amount` from
// each charge as the platform cut; the rest transfers to the installer's
// Connect account. When the installer has no account yet (unclaimed or
// onboarding incomplete), the deposit holds in NPH's platform balance and is
// flagged for manual transfer at claim-time via metadata.

const DEFAULT_DEPOSIT_CENTS = parseInt(process.env.NPH_DEFAULT_DEPOSIT_CENTS || '9900', 10);
// 2500 bps = 25%. Set 2026-05-06 per Steve. Was 1000 (10%); the 25% rate
// positions NPH as a referral/lead service rather than a marketplace cut —
// see the lead-coordination legal framing in the v0.7 conversation.
const DEFAULT_PLATFORM_FEE_BPS = parseInt(process.env.NPH_PLATFORM_FEE_BPS || '2500', 10);

function platformFeeCents(amountCents, feeBps) {
  return Math.floor((amountCents * feeBps) / 10000);
}

async function createBookingDepositIntent({ booking, installer, amountCents, platformFeeBps }) {
  const amount = amountCents || DEFAULT_DEPOSIT_CENTS;
  const feeBps = platformFeeBps || DEFAULT_PLATFORM_FEE_BPS;
  const fee = platformFeeCents(amount, feeBps);

  const c = client();
  if (!c) {
    return {
      mocked: true,
      client_secret: `pi_mock_${booking.uuid}_secret_mock`,
      intent_id: `pi_mock_${booking.uuid}`,
      amount_cents: amount,
      application_fee_cents: fee,
      destination: installer.stripe_account_id || null,
      mode: installer.stripe_account_id && installer.stripe_account_charges_enabled
        ? 'destination' : 'platform_hold'
    };
  }

  const useConnect = !!(installer.stripe_account_id && installer.stripe_account_charges_enabled);
  const params = {
    amount,
    currency: 'usd',
    capture_method: 'automatic',
    description: `NPH booking ${booking.uuid} · ${installer.business_name}`,
    receipt_email: booking.customer_email,
    metadata: {
      booking_uuid: booking.uuid,
      booking_id: String(booking.id),
      installer_id: String(installer.id),
      installer_slug: installer.slug,
      platform_fee_bps: String(feeBps),
      payout_mode: useConnect ? 'destination' : 'platform_hold'
    }
  };
  if (useConnect) {
    params.application_fee_amount = fee;
    params.transfer_data = { destination: installer.stripe_account_id };
  } else {
    params.metadata.pending_payout_to_installer_id = String(installer.id);
  }

  const intent = await c.paymentIntents.create(params);
  return {
    client_secret: intent.client_secret,
    intent_id: intent.id,
    amount_cents: amount,
    application_fee_cents: useConnect ? fee : 0,
    destination: installer.stripe_account_id || null,
    mode: useConnect ? 'destination' : 'platform_hold'
  };
}

async function createConnectAccount({ installer }) {
  const c = client();
  if (!c) {
    return {
      mocked: true,
      account_id: `acct_mock_${installer.id}`,
      charges_enabled: false,
      payouts_enabled: false
    };
  }
  // Stripe rejects placeholder/test URLs (example.com, localhost) with
  // url_invalid. Only pass `url` when it looks like a real public site.
  const validBusinessUrl = (() => {
    const u = (installer.website || '').trim();
    if (!u) return undefined;
    try {
      const parsed = new URL(u);
      if (!/^https?:$/.test(parsed.protocol)) return undefined;
      const host = parsed.hostname.toLowerCase();
      if (/^(localhost|127\.|0\.0\.0\.0)/.test(host)) return undefined;
      if (/(^|\.)example\.(com|org|net)$/.test(host)) return undefined;
      if (/(^|\.)test$/.test(host)) return undefined;
      return parsed.toString();
    } catch { return undefined; }
  })();

  const acct = await c.accounts.create({
    type: 'express',
    country: 'US',
    email: installer.email,
    capabilities: {
      transfers: { requested: true },
      card_payments: { requested: true }
    },
    business_type: 'company',
    business_profile: {
      name: installer.business_name,
      url: validBusinessUrl,
      mcc: '1771'
    },
    metadata: {
      installer_id: String(installer.id),
      installer_slug: installer.slug
    }
  });
  return {
    account_id: acct.id,
    charges_enabled: !!acct.charges_enabled,
    payouts_enabled: !!acct.payouts_enabled
  };
}

async function createConnectAccountLink({ accountId, returnUrl, refreshUrl }) {
  const c = client();
  if (!c) {
    return {
      mocked: true,
      url: `${returnUrl || '/admin'}?mock=connect&acct=${encodeURIComponent(accountId)}`
    };
  }
  const link = await c.accountLinks.create({
    account: accountId,
    refresh_url: refreshUrl,
    return_url: returnUrl,
    type: 'account_onboarding'
  });
  return { url: link.url };
}

async function getConnectAccount({ accountId }) {
  const c = client();
  if (!c) {
    return {
      mocked: true,
      account_id: accountId,
      charges_enabled: false,
      payouts_enabled: false,
      details_submitted: false
    };
  }
  const acct = await c.accounts.retrieve(accountId);
  return {
    account_id: acct.id,
    charges_enabled: !!acct.charges_enabled,
    payouts_enabled: !!acct.payouts_enabled,
    details_submitted: !!acct.details_submitted
  };
}

module.exports = {
  TIERS,
  PRICE_TABLE,
  isLive,
  priceIdFor,
  tierFromPriceId,
  ensureCustomer,
  createCheckoutSession,
  createPortalSession,
  constructWebhookEvent,
  // marketplace
  DEFAULT_DEPOSIT_CENTS,
  DEFAULT_PLATFORM_FEE_BPS,
  platformFeeCents,
  createBookingDepositIntent,
  createConnectAccount,
  createConnectAccountLink,
  getConnectAccount
};