← back to NationalPaperHangers

lib/services/subscriptions.js

130 lines

// lib/services/subscriptions.js
//
// Pure subscription service. Extracts the per-event handlers from
// routes/webhooks.js so they can be unit-tested without spinning up Express
// or replaying real Stripe-signed payloads.
//
// Phase 1: NOT yet imported from routes/webhooks.js. Available for Phase 2
// cutover. The Stripe-signature verification + idempotency-via-audit-log
// stays in the route handler — only the per-event side-effects move here.
//
// Each function takes a node-pg client (so the caller can keep the audit
// insert + the side-effect inside the same transaction) plus the
// already-constructed Stripe `event` object. When called from a service-
// only context (no transaction), pass db.pool — every call uses the same
// .query interface.

'use strict';

const stripe = require('../stripe');

// ─────────────────────────────────────────────────────────────────────────
// checkout.session.completed
// ─────────────────────────────────────────────────────────────────────────

async function applyCheckoutSession(client, event) {
  const obj = event.data && event.data.object;
  if (!obj) return { handled: false, reason: 'no_object' };

  const meta = obj.metadata || {};
  const installerId = meta.installer_id ? parseInt(meta.installer_id, 10) : null;
  if (!installerId || !obj.subscription) {
    return { handled: false, reason: 'missing_installer_or_subscription' };
  }

  // Tier from metadata only on first checkout — subsequent updates derive
  // from price.id (see applySubscriptionChanged).
  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]
  );
  return { handled: true, installerId, tier };
}

// ─────────────────────────────────────────────────────────────────────────
// customer.subscription.created / customer.subscription.updated
// ─────────────────────────────────────────────────────────────────────────

async function applySubscriptionChanged(client, event, sub) {
  const subscription = sub || (event.data && event.data.object);
  if (!subscription || !subscription.customer) {
    return { handled: false, reason: 'no_subscription' };
  }

  const status = subscription.status || 'unknown';
  const periodEnd = subscription.current_period_end
    ? new Date(subscription.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 price ID is
  // the only canonical signal.
  let tier = null;
  try {
    const priceId =
      subscription.items &&
      subscription.items.data &&
      subscription.items.data[0] &&
      subscription.items.data[0].price &&
      subscription.items.data[0].price.id;
    const m = stripe.tierFromPriceId(priceId);
    if (m) tier = m.tier;
  } catch (e) {
    // fall through with tier = null
  }

  if (tier) {
    await client.query(
      `UPDATE installers
          SET subscription_status = $2,
              current_period_end = $3,
              tier = $4
        WHERE stripe_customer_id = $1`,
      [subscription.customer, status, periodEnd, tier]
    );
    return { handled: true, customer: subscription.customer, tier, status };
  }

  // Couldn't map price → tier (env not loaded for that price). 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`,
    [subscription.customer, status, periodEnd]
  );
  return { handled: true, customer: subscription.customer, tier: null, status, warning: 'no_tier_match' };
}

// ─────────────────────────────────────────────────────────────────────────
// customer.subscription.deleted
// ─────────────────────────────────────────────────────────────────────────

async function applySubscriptionDeleted(client, event, sub) {
  const subscription = sub || (event.data && event.data.object);
  if (!subscription || !subscription.customer) {
    return { handled: false, reason: 'no_subscription' };
  }
  await client.query(
    `UPDATE installers
        SET subscription_status = 'canceled',
            tier = 'basic'
      WHERE stripe_customer_id = $1`,
    [subscription.customer]
  );
  return { handled: true, customer: subscription.customer };
}

module.exports = {
  applyCheckoutSession,
  applySubscriptionChanged,
  applySubscriptionDeleted
};