← back to Costa Rica
lib/reconcile.js
119 lines
'use strict';
// Stale-payment reconciler (Cody cold-audit follow-up, cycle 20, TK-10346).
//
// A payment leaves 'processing' only when a provider webhook lands or a traveler
// re-opens GET /payments/:id. A dropped/missed webhook + an abandoned app session
// therefore strands a payment 'processing' forever — and since cycle-19's
// one-in-flight-per-booking index, that ALSO makes the booking permanently
// un-payable. This reconciler polls the provider for stale in-flight payments and
// resolves whatever the provider has actually resolved (catching dropped webhooks),
// AND confirms bookings orphaned by a succeeded-payment-but-confirmBooking-failed.
//
// SAFETY — the reconciler NEVER fails a payment on missing information:
// - getCharge() timeout/error -> untouched (a blip must never turn a
// possibly-succeeded charge into 'failed').
// - provider says succeeded/failed/refunded -> apply it (guarded, idempotent).
// - provider STILL says 'processing' -> left alone by DEFAULT. `failAfterMinutes`
// force-fail is OPT-IN ONLY (default null):
// it mutates money-adjacent state WITHOUT a provider terminal confirmation, and
// failing a still-'processing' charge frees the in-flight slot -> the booking is
// payable again -> if the traveler re-pays AND the original later lands, they're
// charged twice. So it defaults OFF (a stuck payment is surfaced via the return
// counts, not auto-failed); when a caller explicitly passes a finite
// failAfterMinutes it requires an EXACT 'processing' status and LOGS every hit for
// ops to verify no charge actually landed. (Cody gate, cycle 20.)
//
// Every UPDATE is guarded `WHERE ... status=<expected>`, so it is idempotent and
// cannot clobber a concurrent webhook that already resolved the row.
const { pool } = require('./db');
const { getProvider } = require('./payments');
async function reconcileStalePayments({ olderThanMinutes = 15, failAfterMinutes = null, limit = 200 } = {}) {
// Lazy require: confirmBooking lives in routes/app.js; requiring it lazily keeps
// lib/ from eagerly pulling the whole router at module load. (routes/app does not
// require this module, so there's no cycle.)
const { confirmBooking } = require('../routes/app');
const r = { checked: 0, succeeded: 0, failed: 0, refunded: 0, stillProcessing: 0, forceFailed: 0, unreachable: 0, orphanConfirmed: 0 };
// --- Pass A: resolve stale in-flight ('processing') payments against the provider.
const { rows: stale } = await pool.query(
`SELECT id, booking_id, provider, provider_ref, created_at
FROM payments
WHERE status='processing' AND provider_ref IS NOT NULL
AND created_at < NOW() - make_interval(mins => $1)
ORDER BY created_at ASC
LIMIT $2`, [olderThanMinutes, limit]);
for (const p of stale) {
r.checked++;
let latest;
try {
latest = await getProvider(p.provider).getCharge(p.provider_ref); // fetchT-bounded
} catch (e) {
r.unreachable++; // provider unreachable — do NOT touch the payment
continue;
}
const st = latest && latest.status;
const raw = JSON.stringify((latest && latest.raw) || {});
if (st === 'succeeded') {
const { rowCount } = await pool.query(
`UPDATE payments SET status='succeeded', raw=$1, updated_at=NOW() WHERE id=$2 AND status='processing'`, [raw, p.id]);
if (rowCount) {
r.succeeded++;
try { await confirmBooking(p.booking_id); }
catch (e) { console.error('[reconcile] confirmBooking', p.booking_id, e.message); }
}
} else if (st === 'failed') {
const { rowCount } = await pool.query(
`UPDATE payments SET status='failed', raw=$1, updated_at=NOW() WHERE id=$2 AND status='processing'`, [raw, p.id]);
if (rowCount) r.failed++;
} else if (st === 'refunded') {
const { rowCount } = await pool.query(
`UPDATE payments SET status='refunded', raw=$1, updated_at=NOW() WHERE id=$2 AND status='processing'`, [raw, p.id]);
// Guard the booking status so a refund can't clobber a 'completed'/'cancelled' booking.
if (rowCount) { r.refunded++; await pool.query(`UPDATE bookings SET status='refunded' WHERE id=$1 AND status IN ('confirmed','pending')`, [p.booking_id]); }
} else {
// Provider still reports 'processing' (or an unrecognized non-terminal status
// the adapter mapped to 'processing').
r.stillProcessing++;
// OPT-IN force-fail only: requires a finite failAfterMinutes AND an EXACT
// 'processing' status (never a mapped-unknown), and logs each hit for ops.
if (Number.isFinite(failAfterMinutes) && st === 'processing') {
const ageMin = (Date.now() - new Date(p.created_at).getTime()) / 60000;
if (ageMin >= failAfterMinutes) {
const { rowCount } = await pool.query(
`UPDATE payments SET status='failed', raw=$1, updated_at=NOW() WHERE id=$2 AND status='processing'`,
[JSON.stringify({ reason: `force-failed: provider still 'processing' >= ${failAfterMinutes}min`, last_raw: (latest && latest.raw) || {} }), p.id]);
if (rowCount) {
r.forceFailed++; r.stillProcessing--;
console.warn('[reconcile] FORCE-FAILED stuck payment', p.id, 'booking', p.booking_id,
'- provider still processing past TTL; booking is payable again — verify NO charge actually landed before the traveler re-pays');
}
}
}
}
}
// --- Pass B (Cody gate, cycle 20 hole #2): a payment can be 'succeeded' while its
// booking is still 'pending' — the webhook durably marked the payment succeeded,
// then confirmBooking threw and its retry never landed, and the abandoned session
// never polled GET /payments/:id. Pass A can't see it (it filters status='processing').
// This is the exact orphan the module promises to cover. Confirm those bookings.
const { rows: orphans } = await pool.query(
`SELECT DISTINCT p.booking_id
FROM payments p JOIN bookings b ON b.id = p.booking_id
WHERE p.status='succeeded' AND b.status='pending'
AND p.updated_at < NOW() - make_interval(mins => $1)
LIMIT $2`, [olderThanMinutes, limit]);
for (const o of orphans) {
try { await confirmBooking(o.booking_id); r.orphanConfirmed++; }
catch (e) { console.error('[reconcile] orphan confirm', o.booking_id, e.message); }
}
return r;
}
module.exports = { reconcileStalePayments };