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