← back to NationalPaperHangers

routes/webhooks.js

224 lines

const express = require('express');
const db = require('../lib/db');
const stripe = require('../lib/stripe');
const router = express.Router();

// Stripe's webhook-create URL validator probes the endpoint with HEAD/GET to
// confirm reachability before accepting it. Express has no implicit GET when
// only POST is mounted (404), so add explicit acks. Don't leak any signal —
// just "yes the URL exists".
router.get('/', (req, res) => res.status(200).type('text/plain').send('ok'));
router.head('/', (req, res) => res.status(200).end());

const IS_PROD = process.env.NODE_ENV === 'production';
// Explicit dev escape hatch — only honored when NODE_ENV !== 'production'.
const ACCEPT_UNSIGNED_DEV = process.env.STRIPE_DEV_ACCEPT_UNSIGNED === '1' && !IS_PROD;

router.post('/', async (req, res) => {
  const sig = req.headers['stripe-signature'];

  let event;
  try {
    event = stripe.constructWebhookEvent(req.body, sig);
  } catch (err) {
    console.warn('[webhook] sig fail', err.message);
    return res.status(400).send(`bad signature: ${err.message}`);
  }

  if (!event) {
    // Stripe SDK not configured. Two states:
    //   (a) STRIPE_WEBHOOK_SECRET is unset and STRIPE_BOOTSTRAP_ACCEPT_PROBES=1
    //       → return 2xx so Stripe's URL probe accepts the endpoint at create-time.
    //       Logs 'bootstrap_probe_accepted' so it's auditable in the window.
    //   (b) Otherwise → fail closed with 400 (still publicly accessible per Stripe).
    if (process.env.STRIPE_BOOTSTRAP_ACCEPT_PROBES === '1') {
      console.warn('[webhook] bootstrap_probe_accepted', { isProd: IS_PROD });
      return res.json({ received: true, bootstrap: true });
    }
    if (!ACCEPT_UNSIGNED_DEV) {
      console.warn('[webhook] not_configured', { isProd: IS_PROD });
      return res.status(400).json({ error: 'webhook_not_configured' });
    }
    return res.json({ received: true, mocked: true });
  }

  // Single transaction wrapping the audit-log insert + installer update.
  // The audit-log UNIQUE on stripe_event_id acts as the idempotency lock:
  // if the INSERT conflicts, we know we've already processed this event and
  // skip the side-effects.
  const client = await db.pool.connect();
  try {
    await client.query('BEGIN');

    const audit = await client.query(
      `INSERT INTO subscription_events (stripe_event_id, event_type, payload)
       VALUES ($1, $2, $3) ON CONFLICT (stripe_event_id) DO NOTHING
       RETURNING id`,
      [event.id, event.type, event]
    );

    if (audit.rowCount === 0) {
      // Already processed — short-circuit cleanly. Stripe will see 200 and stop retrying.
      await client.query('COMMIT');
      client.release();
      return res.json({ received: true, idempotent: true });
    }

    const obj = event.data && event.data.object;
    const meta = (obj && obj.metadata) || {};
    const installerId = meta.installer_id ? parseInt(meta.installer_id, 10) : null;

    switch (event.type) {
      case 'checkout.session.completed': {
        if (installerId && obj.subscription) {
          // Tier from metadata only on first checkout — subsequent
          // subscription.updated events derive from price.id (see below).
          const tier = meta.tier || 'pro';
          await client.query(
            `UPDATE installers
                SET stripe_customer_id=$2, stripe_subscription_id=$3,
                    subscription_status='active', tier=$4
              WHERE id=$1`,
            [installerId, obj.customer, obj.subscription, tier]
          );
        }
        break;
      }

      case 'customer.subscription.created':
      case 'customer.subscription.updated': {
        const sub = obj;
        const status = sub.status || 'unknown';
        const periodEnd = sub.current_period_end ? new Date(sub.current_period_end * 1000) : null;

        // Derive tier from the price ID on the subscription's first item.
        // Subscription.updated events don't carry our metadata, so the price
        // is the only canonical signal.
        let tier = null;
        try {
          const priceId = sub.items && sub.items.data && sub.items.data[0] && sub.items.data[0].price && sub.items.data[0].price.id;
          const m = stripe.tierFromPriceId(priceId);
          if (m) tier = m.tier;
        } catch (e) { /* fall through with tier=null */ }

        if (sub.customer) {
          if (tier) {
            await client.query(
              `UPDATE installers
                  SET subscription_status=$2, current_period_end=$3, tier=$4
                WHERE stripe_customer_id=$1`,
              [sub.customer, status, periodEnd, tier]
            );
          } else {
            // Couldn't map price → tier (price ID not in env). Update status
            // but don't blindly flip the tier.
            await client.query(
              `UPDATE installers
                  SET subscription_status=$2, current_period_end=$3
                WHERE stripe_customer_id=$1`,
              [sub.customer, status, periodEnd]
            );
            console.warn('[webhook] no tier match for price', { event: event.id, customer: sub.customer });
          }
        }
        break;
      }

      case 'customer.subscription.deleted': {
        const sub = obj;
        if (sub.customer) {
          await client.query(
            `UPDATE installers
                SET subscription_status='canceled', tier='basic'
              WHERE stripe_customer_id=$1`,
            [sub.customer]
          );
        }
        break;
      }

      // ---------- Booking deposit lifecycle ----------
      case 'payment_intent.succeeded':
      case 'payment_intent.payment_failed':
      case 'payment_intent.canceled': {
        const pi = obj;
        const bookingUuid = (pi.metadata && pi.metadata.booking_uuid) || null;
        const piInstallerId = (pi.metadata && pi.metadata.installer_id)
          ? parseInt(pi.metadata.installer_id, 10) : null;

        // Mirror to payment_events audit table (separate from subscription_events).
        await client.query(
          `INSERT INTO payment_events
             (stripe_event_id, event_type, payment_intent_id, booking_uuid,
              installer_id, amount_cents, application_fee_cents, payload)
           VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
           ON CONFLICT (stripe_event_id) DO NOTHING`,
          [
            event.id, event.type, pi.id, bookingUuid, piInstallerId,
            pi.amount || null, pi.application_fee_amount || null, event
          ]
        );

        if (bookingUuid) {
          if (event.type === 'payment_intent.succeeded') {
            await client.query(
              `UPDATE bookings
                  SET deposit_status = 'paid',
                      status = CASE WHEN status='pending' THEN 'confirmed' ELSE status END,
                      confirmed_at = COALESCE(confirmed_at, now())
                WHERE uuid = $1`,
              [bookingUuid]
            );
          } else if (event.type === 'payment_intent.payment_failed') {
            await client.query(
              `UPDATE bookings SET deposit_status = 'failed' WHERE uuid = $1`,
              [bookingUuid]
            );
          } else if (event.type === 'payment_intent.canceled') {
            await client.query(
              `UPDATE bookings SET deposit_status = 'canceled' WHERE uuid = $1`,
              [bookingUuid]
            );
          }
        }
        break;
      }

      // ---------- Connect account status mirror ----------
      case 'account.updated': {
        const acct = obj;
        if (acct.id) {
          await client.query(
            `UPDATE installers
                SET stripe_account_charges_enabled = $2,
                    stripe_account_payouts_enabled = $3,
                    stripe_account_onboarded_at = CASE
                      WHEN $2 = true AND stripe_account_onboarded_at IS NULL THEN now()
                      ELSE stripe_account_onboarded_at
                    END
              WHERE stripe_account_id = $1`,
            [acct.id, !!acct.charges_enabled, !!acct.payouts_enabled]
          );
        }
        break;
      }

      default:
        break;
    }

    await client.query('COMMIT');
    client.release();
    return res.json({ received: true });

  } catch (err) {
    try { await client.query('ROLLBACK'); } catch {}
    client.release();
    // Return 500 so Stripe retries — fail-open on idempotency, not on errors.
    console.error('[webhook] handler error', err);
    return res.status(500).json({ error: 'handler_failed' });
  }
});

module.exports = router;