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