← back to Costa Rica

routes/webhooks.js

157 lines

'use strict';
// Public webhooks — payment processors + WhatsApp. NO basic-auth (providers
// POST unauthenticated; we verify by signature). Raw body needed for HMAC.
const express = require('express');
const { pool } = require('../lib/db');
const { getProvider } = require('../lib/payments');
const wa = require('../lib/whatsapp');
const { confirmBooking } = require('./app');

const router = express.Router();
const raw = express.raw({ type: '*/*', limit: '2mb' });

// Idempotency: record (source, external_id); return false if already seen.
async function firstTime(source, externalId, eventType, payload) {
  if (!externalId) return true; // no id -> best effort, still process
  try {
    const { rowCount } = await pool.query(
      `INSERT INTO webhook_events (source, external_id, event_type, payload)
       VALUES ($1,$2,$3,$4) ON CONFLICT (source, external_id) DO NOTHING`,
      [source, String(externalId), eventType || null, payload ? JSON.stringify(payload) : null]);
    return rowCount === 1;
  } catch { return true; }
}

// ---- payment webhook (Tilopay / ONVO share the shape via the adapter) ----
async function paymentWebhook(providerName, req, res) {
  const provider = getProvider(providerName);
  const { ok, event } = provider.verifyWebhook(req.headers, req.body); // req.body is a Buffer (raw)
  if (!ok) return res.status(401).send('bad signature');
  // R3 — a signed-but-unparseable body yields ok:true, event:null. Mirror the
  // WhatsApp malformed-body 400 and STOP: never fall through to firstTime() with
  // a null id (which would `return true` and let a phantom event "pass" processing).
  if (!event) return res.status(400).send('bad body');
  const evType = event?.type || event?.status;
  // Idempotency key MUST be per-EVENT, not per-CHARGE. A distinct notification id
  // is preferred, but some provider shapes carry only `paymentId` (the charge id) —
  // e.g. Tilopay's sandbox payloads are `{paymentId, status}` with no event id. If
  // we keyed on the charge id, a 'succeeded' event and a LATER, genuinely distinct
  // 'refunded' event for the SAME charge would collide: the second INSERT hits ON
  // CONFLICT DO NOTHING, is treated as a 'dup', and the refund is NEVER processed
  // (booking stays confirmed forever, silently). Fall back to a `paymentId:type`
  // composite so lifecycle events for one charge get DISTINCT keys while true
  // replays (same charge+type) still dedupe. (Cody webhook audit, cycle 29.)
  const chargeId = event?.paymentId || event?.id || event?.data?.id;
  const evId = event?.id || event?.event_id || (chargeId ? `${chargeId}:${evType}` : null);
  // A signed event with no resolvable id is suspicious — do NOT silently pass the
  // idempotency gate (which best-efforts to firstTime===true on a missing id).
  if (!evId) return res.status(400).send('missing event id');
  if (!(await firstTime(providerName, evId, evType, event))) return res.status(200).send('dup');

  // Once we've CLAIMED the event (marked it seen above), a failure in the
  // processing below must NOT leave it marked — otherwise the provider's retry
  // (triggered by our 500) hits the idempotency gate, returns 'dup', and the
  // confirmation is lost forever (payment succeeded, booking never confirmed). On
  // any processing error, RELEASE the marker so the retry re-processes, then
  // surface the 500. The dedupe still blocks true duplicates (which succeed and
  // keep the marker) and concurrent double-delivery (ON CONFLICT DO NOTHING lets
  // only one insert win). (Cody gate, cycle 12, TK-10346.)
  try {
    // The charge id the adapter reported (same value derived above for the key).
    const ref = chargeId;
    if (ref) {
      const latest = await provider.getCharge(ref).catch(() => null);
      const status = latest?.status || (/(succe|approved|paid)/i.test(String(evType)) ? 'succeeded' : 'processing');
      const { rows } = await pool.query(
        `UPDATE payments SET status=$1, raw=$2, updated_at=NOW()
          WHERE provider=$3 AND provider_ref=$4 RETURNING id, booking_id`,
        [status, JSON.stringify(event || {}), providerName, ref]);
      if (rows[0] && status === 'succeeded') await confirmBooking(rows[0].booking_id);
      // Set updated_at (every other status mutation does — a reconcile keyed off it
      // would otherwise miss refunds) and guard status<>'refunded' so a replayed
      // refund event is idempotent. NOTE (latent, Cody cycle-29): this has no
      // clawback link to the payouts row — moot while the completion/payout pipeline
      // is unwired, but a refund on an already-paid-out completed booking would need
      // one. Tracked with the refund-after-payout deferred item.
      if (rows[0] && status === 'refunded') await pool.query(`UPDATE bookings SET status='refunded', updated_at=NOW() WHERE id=$1 AND status<>'refunded'`, [rows[0].booking_id]);
    }
  } catch (e) {
    // Best-effort release. If it ALSO fails, log distinctly — we've silently
    // regressed to the original bug (this event's retry will be deduped as 'dup'
    // forever), and this line is the only trace of which event stayed broken.
    await pool.query(`DELETE FROM webhook_events WHERE source=$1 AND external_id=$2`, [providerName, String(evId)])
      .catch((de) => console.error('[payment webhook] marker release FAILED', providerName, evId, de && de.message));
    throw e; // -> whFail -> 500 (retryable); the released marker lets the retry re-process
  }
  res.status(200).send('ok');
}

// M2/R4 — log the full error server-side; return a generic message (no DB/driver leak).
const whFail = (res) => (e) => { console.error('[payment webhook]', e && e.message, e && e.stack); res.status(500).send('internal error'); };
router.post('/tilopay', raw, (req, res) => paymentWebhook('tilopay', req, res).catch(whFail(res)));
router.post('/onvo',    raw, (req, res) => paymentWebhook('onvo', req, res).catch(whFail(res)));

// ---- WhatsApp webhook ----
router.get('/whatsapp', (req, res) => {
  const v = wa.verifyChallenge(req.query);
  if (v.ok) return res.status(200).send(v.challenge);
  res.sendStatus(403);
});
router.post('/whatsapp', raw, async (req, res) => {
  if (!wa.verifySignature(req.headers, req.body)) return res.sendStatus(401);
  let body; try { body = JSON.parse(req.body.toString()); } catch { return res.sendStatus(400); }
  const evId = body.entry?.[0]?.id + ':' + (body.entry?.[0]?.changes?.[0]?.value?.messages?.[0]?.id || Date.now());
  if (!(await firstTime('whatsapp', evId, 'inbound', null))) return res.sendStatus(200);
  let events = [];
  try { events = await wa.handleInbound(body); }
  catch (e) {
    // handleInbound (the PERSISTENCE step) failed AFTER we claimed the idempotency
    // marker above. Leaving the marker + 200'ing would SILENTLY drop the inbound
    // message forever — Meta never retries a 200. Release the marker + 500 so Meta's
    // retry re-processes (handleInbound is idempotent: logMessage ON CONFLICT DO
    // NOTHING, contactByWaId upsert, status UPDATE). Mirrors the payment webhook's
    // marker-release. Auto-reply send failures below are separate + best-effort — they
    // must NOT 500 (that would re-deliver the whole batch). (Cody wa audit, cycle 30.)
    console.error('[wa webhook] handleInbound failed, releasing marker for retry', e.message);
    await pool.query(`DELETE FROM webhook_events WHERE source='whatsapp' AND external_id=$1`, [evId]).catch(() => {});
    return res.sendStatus(500);
  }
  // Auto-reply hook: a simple keyword router (extend as needed). PER-EVENT isolation
  // (Cody cycle-13 finding): a payload can carry multiple inbound messages: the old
  // code wrapped the WHOLE loop in one try/catch, so a slow/failing sendButtons for
  // message N (now bounded to ~15s by fetchT, but still real) threw straight to the
  // outer catch and messages N+1.. got NO auto-reply in that batch. Each event's send
  // is now independently try/caught — a failure on one is logged and does not skip
  // its siblings. The route always 200s to Meta either way (no retry storm).
  for (const ev of events) {
    const t = (ev.text || '').toLowerCase();
    if (!/^(hola|hi|hello|menu|ayuda|help)/.test(t)) continue;
    // The COST GUARD + the send are BOTH best-effort and BOTH wrapped: an uncaught
    // throw here (a DB blip on the cooldown UPDATE, or the send) would escape the
    // loop and 500 AFTER the idempotency marker is already committed -> Meta's retry
    // dedupes to a 200 -> the auto-reply is SILENTLY, permanently lost for this AND
    // every remaining event in the batch (regressing the cycle-13 per-event
    // isolation). The inbound message is already durably persisted, so a lost reply
    // is a minor UX miss, not data loss: log + continue. (Cody gate, cycle 30.)
    try {
      // COST GUARD: each auto-reply is a Meta-BILLED send. Without a cap, anyone who
      // can WhatsApp us drives unbounded billed sends by flooding keywords. This
      // conditional UPDATE atomically checks+claims a per-contact cooldown slot (no
      // TOCTOU, durable across restarts): rowCount 0 = within the window -> skip the
      // billed send. A cooldown-UPDATE error is caught below and fails CLOSED (no send).
      const { rowCount } = await pool.query(
        `UPDATE whatsapp_contacts SET last_auto_reply_at=NOW()
          WHERE id=$1 AND (last_auto_reply_at IS NULL OR last_auto_reply_at < NOW() - interval '60 seconds')`,
        [ev.contact.id]);
      if (rowCount === 0) continue;
      await wa.sendButtons(ev.contact.wa_id,
        '¡Hola! ¿En qué te ayudamos? / How can we help?',
        [{ id: 'browse', title: 'Ver listados' }, { id: 'mybookings', title: 'Mis reservas' }, { id: 'support', title: 'Soporte' }],
        { header: 'Costa Rica' });
    } catch (e) { console.warn('[wa webhook] auto-reply', ev.contact?.wa_id, e.message); }
  }
  res.sendStatus(200);
});

module.exports = router;