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