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