[object Object]

← back to Costa Rica

costa-rica: stale-payment reconciler (dropped-webhook rescue + orphan-confirm) — Cody-gated, cycle 20 — TK-10346

b86e7b68aa7e6a0976ebf502df50b796d2e195bf · 2026-09-24 01:50:24 -0700 · Steve

Closes the pre-existing gap Cody flagged in cycle 19: a payment leaves 'processing'
only via a provider webhook or a traveler's GET /payments/:id poll, so a dropped
webhook + an abandoned app session strands it 'processing' forever — and since
cycle-19's one-in-flight index, the booking becomes permanently un-payable.

lib/reconcile.js `reconcileStalePayments({ olderThanMinutes=15, failAfterMinutes=null,
limit=200 })` — exported, idempotent, safe to run repeatedly. NOT wired to a cron
(that's gated — drafted to pending-approval).
  Pass A: polls the provider (getCharge, fetchT-bounded) for stale in-flight payments
    and applies whatever it has RESOLVED — succeeded -> UPDATE + confirmBooking; failed
    -> UPDATE (frees the in-flight slot); refunded -> UPDATE + booking refunded. A
    getCharge timeout/error is left UNTOUCHED (never fail a possibly-succeeded charge).
  Pass B: confirms bookings orphaned by a succeeded-payment-but-confirmBooking-failed.

Cody gate — FIX FIRST, all applied:
- FORCE-FAIL is now OPT-IN (default null), was default-ON at 1440min. Cody: force-failing
  a still-'processing' charge frees the in-flight slot -> booking payable again -> if the
  traveler re-pays AND the original later lands (the webhook UPDATE has no status guard,
  so it flips 'failed'->'succeeded'), they're charged TWICE. So it defaults OFF (stuck
  payments are surfaced via the return counts, not auto-failed); when a caller passes a
  finite failAfterMinutes it requires an EXACT 'processing' status and logs each hit for
  ops to verify no charge landed.
- PASS B (Cody hole #2, real orphan gap the module existed for but couldn't see): the
  reconciler's SELECT filters status='processing', so a payment the webhook durably marked
  'succeeded' whose confirmBooking then threw (retry never landed, abandoned session never
  polled) left the booking 'pending' FOREVER, invisible to Pass A. Added Pass B: JOIN
  payments succeeded + bookings pending -> confirmBooking. Idempotent.
- refund's `UPDATE bookings SET status='refunded'` now guarded `WHERE status IN
  ('confirmed','pending')` so it can't clobber a 'completed'/'cancelled' booking.

Residual (documented): the adapters' mapStatus coerces an UNKNOWN provider status to
'processing', so an enabled force-fail could mislabel a disputed/under-review charge —
mitigated by force-fail being off-by-default + the per-hit ops log.

Tests (+4, suite 193 -> 197, real DB, self-cleaning, unique far-future date windows to
avoid the parallel-file EXCLUDE flake): resolves succeeded/failed/refunded + leaves
fresh/unreachable alone + opt-in force-fail; idempotent 2nd run; force-fail OFF by
default leaves a past-TTL payment 'processing'; Pass B confirms a succeeded-but-pending
booking.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic

Files touched

Diff

commit b86e7b68aa7e6a0976ebf502df50b796d2e195bf
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 24 01:50:24 2026 -0700

    costa-rica: stale-payment reconciler (dropped-webhook rescue + orphan-confirm) — Cody-gated, cycle 20 — TK-10346
    
    Closes the pre-existing gap Cody flagged in cycle 19: a payment leaves 'processing'
    only via a provider webhook or a traveler's GET /payments/:id poll, so a dropped
    webhook + an abandoned app session strands it 'processing' forever — and since
    cycle-19's one-in-flight index, the booking becomes permanently un-payable.
    
    lib/reconcile.js `reconcileStalePayments({ olderThanMinutes=15, failAfterMinutes=null,
    limit=200 })` — exported, idempotent, safe to run repeatedly. NOT wired to a cron
    (that's gated — drafted to pending-approval).
      Pass A: polls the provider (getCharge, fetchT-bounded) for stale in-flight payments
        and applies whatever it has RESOLVED — succeeded -> UPDATE + confirmBooking; failed
        -> UPDATE (frees the in-flight slot); refunded -> UPDATE + booking refunded. A
        getCharge timeout/error is left UNTOUCHED (never fail a possibly-succeeded charge).
      Pass B: confirms bookings orphaned by a succeeded-payment-but-confirmBooking-failed.
    
    Cody gate — FIX FIRST, all applied:
    - FORCE-FAIL is now OPT-IN (default null), was default-ON at 1440min. Cody: force-failing
      a still-'processing' charge frees the in-flight slot -> booking payable again -> if the
      traveler re-pays AND the original later lands (the webhook UPDATE has no status guard,
      so it flips 'failed'->'succeeded'), they're charged TWICE. So it defaults OFF (stuck
      payments are surfaced via the return counts, not auto-failed); when a caller passes a
      finite failAfterMinutes it requires an EXACT 'processing' status and logs each hit for
      ops to verify no charge landed.
    - PASS B (Cody hole #2, real orphan gap the module existed for but couldn't see): the
      reconciler's SELECT filters status='processing', so a payment the webhook durably marked
      'succeeded' whose confirmBooking then threw (retry never landed, abandoned session never
      polled) left the booking 'pending' FOREVER, invisible to Pass A. Added Pass B: JOIN
      payments succeeded + bookings pending -> confirmBooking. Idempotent.
    - refund's `UPDATE bookings SET status='refunded'` now guarded `WHERE status IN
      ('confirmed','pending')` so it can't clobber a 'completed'/'cancelled' booking.
    
    Residual (documented): the adapters' mapStatus coerces an UNKNOWN provider status to
    'processing', so an enabled force-fail could mislabel a disputed/under-review charge —
    mitigated by force-fail being off-by-default + the per-hit ops log.
    
    Tests (+4, suite 193 -> 197, real DB, self-cleaning, unique far-future date windows to
    avoid the parallel-file EXCLUDE flake): resolves succeeded/failed/refunded + leaves
    fresh/unreachable alone + opt-in force-fail; idempotent 2nd run; force-fail OFF by
    default leaves a past-TTL payment 'processing'; Pass B confirms a succeeded-but-pending
    booking.
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
 lib/reconcile.js       | 118 +++++++++++++++++++++++++++++++++++++
 test/reconcile.test.js | 157 +++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 275 insertions(+)

diff --git a/lib/reconcile.js b/lib/reconcile.js
new file mode 100644
index 0000000..8ef2f3a
--- /dev/null
+++ b/lib/reconcile.js
@@ -0,0 +1,118 @@
+'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 };
diff --git a/test/reconcile.test.js b/test/reconcile.test.js
new file mode 100644
index 0000000..8a3941c
--- /dev/null
+++ b/test/reconcile.test.js
@@ -0,0 +1,157 @@
+'use strict';
+// Stale-payment reconciler (cycle 20, TK-10346). Real dev DB, self-cleaning. Each
+// stale 'processing' payment needs its OWN booking (the cycle-19 one-in-flight index
+// allows one processing payment per booking), on a unique far-future date window (to
+// dodge the bookings_no_overlap_stay EXCLUDE under parallel test files). getCharge is
+// stubbed by a provider_ref marker so one reconcile run exercises every fate.
+require('dotenv').config();
+const { test, after } = require('node:test');
+const assert = require('node:assert');
+const { pool } = require('../lib/db');
+const tilopay = require('../lib/payments/tilopay');
+const { reconcileStalePayments } = require('../lib/reconcile');
+
+const SENT = `YOLOTEST-recon-${Date.now()}`;
+const origGetCharge = tilopay.getCharge;
+after(() => { tilopay.getCharge = origGetCharge; return pool.end(); });
+// Deterministic getCharge keyed on the provider_ref marker.
+tilopay.getCharge = async (ref) => {
+  if (/SUCCEED/.test(ref)) return { status: 'succeeded', raw: { ok: 1 } };
+  if (/FAIL/.test(ref)) return { status: 'failed', raw: { declined: 1 } };
+  if (/PROC/.test(ref)) return { status: 'processing', raw: {} };
+  if (/THROW/.test(ref)) { const e = new Error('provider timeout'); e.code = 'PROVIDER_TIMEOUT'; throw e; }
+  return { status: 'processing', raw: {} };
+};
+
+const created = { users: [], bookings: [], payments: [] };
+let winOff = 0;
+async function seed({ ageMin, marker }) {
+  const off = 7000 + (winOff++) * 3;                 // unique far-future window per booking
+  const ref = `${SENT}-${marker}-${off}`;            // unique provider_ref (UNIQUE(provider,provider_ref))
+  const { rows: [u] } = await pool.query(`INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-u-${off}`]);
+  created.users.push(u.id);
+  const { rows: [b] } = await pool.query(
+    `INSERT INTO bookings (traveler_id, place_id, code, status, currency, subtotal, total, platform_fee, host_payout, check_in, check_out)
+     VALUES ($1,1,$2,'pending','USD',10000,12000,1200,10800,CURRENT_DATE + $3::int, CURRENT_DATE + ($3::int + 1)) RETURNING id`,
+    [u.id, `${SENT}-bk-${off}`, off]);
+  created.bookings.push(b.id);
+  const { rows: [p] } = await pool.query(
+    `INSERT INTO payments (booking_id, provider, method, currency, amount, status, live_mode, provider_ref, created_at)
+     VALUES ($1,'tilopay','card','USD',12000,'processing',false,$2, NOW() - make_interval(mins => $3)) RETURNING id`,
+    [b.id, ref, ageMin]);
+  created.payments.push(p.id);
+  return { bookingId: b.id, paymentId: p.id };
+}
+const status = async (id) => (await pool.query(`SELECT status FROM payments WHERE id=$1`, [id])).rows[0]?.status;
+const bookingStatus = async (id) => (await pool.query(`SELECT status FROM bookings WHERE id=$1`, [id])).rows[0]?.status;
+
+test('reconcileStalePayments: resolves dropped-webhook payments, leaves fresh alone, fails only truly-stuck, never fails on unreachable', async () => {
+  try {
+    const succeeded = await seed({ ageMin: 30, marker: 'SUCCEED' });    // stale, provider says succeeded
+    const failed    = await seed({ ageMin: 30, marker: 'FAIL' });        // stale, provider says failed
+    const fresh     = await seed({ ageMin: 5,  marker: 'SUCCEED' });     // too new -> not touched
+    const stuckTTL  = await seed({ ageMin: 2000, marker: 'PROC' });      // past 24h TTL, provider still processing -> force-fail
+    const procYoung = await seed({ ageMin: 30, marker: 'PROC' });        // stale but < TTL, still processing -> left alone
+    const unreach   = await seed({ ageMin: 2000, marker: 'THROW' });     // past TTL BUT provider unreachable -> must NOT fail
+
+    // failAfterMinutes:1440 = opt-in force-fail (the safe default is OFF/null).
+    const r = await reconcileStalePayments({ olderThanMinutes: 15, failAfterMinutes: 1440, limit: 500 });
+    assert.ok(r.checked >= 5, `reconciler checked the stale set (got ${r.checked})`);
+
+    assert.equal(await status(succeeded.paymentId), 'succeeded', 'a dropped-webhook succeeded payment is resolved');
+    assert.equal(await bookingStatus(succeeded.bookingId), 'confirmed', 'and its booking is confirmed (idempotent confirmBooking)');
+
+    assert.equal(await status(failed.paymentId), 'failed', 'a provider-failed payment is marked failed (frees the in-flight slot)');
+
+    assert.equal(await status(fresh.paymentId), 'processing', 'a too-new payment is left alone (not yet stale)');
+
+    assert.equal(await status(stuckTTL.paymentId), 'failed', 'a payment the provider STILL calls processing past the TTL is force-failed');
+
+    assert.equal(await status(procYoung.paymentId), 'processing', 'a stale-but-under-TTL still-processing payment is left alone');
+
+    assert.equal(await status(unreach.paymentId), 'processing', 'an UNREACHABLE provider never fails a possibly-succeeded charge, even past the TTL');
+  } finally {
+
+    if (created.payments.length) await pool.query(`DELETE FROM payments WHERE id = ANY($1)`, [created.payments]);
+    if (created.bookings.length) await pool.query(`DELETE FROM bookings WHERE id = ANY($1)`, [created.bookings]);
+    if (created.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [created.users]);
+  }
+});
+
+test('reconcileStalePayments: idempotent — a 2nd run over the same (now-resolved) set is a no-op', async () => {
+  const local = { users: [], bookings: [], payments: [] };
+  try {
+    const off = 8000 + winOff++;
+    const { rows: [u] } = await pool.query(`INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-idem-${off}`]); local.users.push(u.id);
+    const { rows: [b] } = await pool.query(
+      `INSERT INTO bookings (traveler_id, place_id, code, status, currency, subtotal, total, platform_fee, host_payout, check_in, check_out)
+       VALUES ($1,1,$2,'pending','USD',10000,12000,1200,10800,CURRENT_DATE + $3::int, CURRENT_DATE + ($3::int + 1)) RETURNING id`,
+      [u.id, `${SENT}-idem-bk-${off}`, off]); local.bookings.push(b.id);
+    const { rows: [p] } = await pool.query(
+      `INSERT INTO payments (booking_id, provider, method, currency, amount, status, live_mode, provider_ref, created_at)
+       VALUES ($1,'tilopay','card','USD',12000,'processing',false,$2, NOW() - make_interval(mins => 30)) RETURNING id`,
+      [b.id, `${SENT}-SUCCEED-idem-${off}`]); local.payments.push(p.id);
+
+    const r1 = await reconcileStalePayments({ olderThanMinutes: 15, limit: 500 });
+    assert.ok(r1.succeeded >= 1);
+    assert.equal((await pool.query(`SELECT status FROM payments WHERE id=$1`, [p.id])).rows[0].status, 'succeeded');
+    // 2nd run: the row is no longer 'processing', so it isn't selected -> no re-work, no error.
+    const r2 = await reconcileStalePayments({ olderThanMinutes: 15, limit: 500 });
+    assert.equal((await pool.query(`SELECT status FROM payments WHERE id=$1`, [p.id])).rows[0].status, 'succeeded', 'still succeeded, unchanged by the 2nd run');
+  } finally {
+
+    if (local.payments.length) await pool.query(`DELETE FROM payments WHERE id = ANY($1)`, [local.payments]);
+    if (local.bookings.length) await pool.query(`DELETE FROM bookings WHERE id = ANY($1)`, [local.bookings]);
+    if (local.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [local.users]);
+  }
+});
+
+test('reconcileStalePayments: force-fail is OFF by default — a past-TTL still-processing payment is NOT auto-failed (Cody gate)', async () => {
+  const L = { users: [], bookings: [], payments: [] };
+  try {
+    const off = 8500 + winOff++;
+    const { rows: [u] } = await pool.query(`INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-nff-${off}`]); L.users.push(u.id);
+    const { rows: [b] } = await pool.query(
+      `INSERT INTO bookings (traveler_id, place_id, code, status, currency, subtotal, total, platform_fee, host_payout, check_in, check_out)
+       VALUES ($1,1,$2,'pending','USD',10000,12000,1200,10800,CURRENT_DATE + $3::int, CURRENT_DATE + ($3::int + 1)) RETURNING id`,
+      [u.id, `${SENT}-nff-bk-${off}`, off]); L.bookings.push(b.id);
+    const { rows: [p] } = await pool.query(
+      `INSERT INTO payments (booking_id, provider, method, currency, amount, status, live_mode, provider_ref, created_at)
+       VALUES ($1,'tilopay','card','USD',12000,'processing',false,$2, NOW() - make_interval(mins => 5000)) RETURNING id`,
+      [b.id, `${SENT}-PROC-nff-${off}`]); L.payments.push(p.id);
+    // NO failAfterMinutes -> default null -> force-fail branch skipped entirely.
+    const r = await reconcileStalePayments({ olderThanMinutes: 15, limit: 500 });
+    assert.equal(r.forceFailed, 0, 'no force-fail happens without an explicit failAfterMinutes');
+    assert.equal((await pool.query(`SELECT status FROM payments WHERE id=$1`, [p.id])).rows[0].status, 'processing', 'a past-TTL stuck payment is LEFT processing by default (surfaced, not auto-failed)');
+  } finally {
+    if (L.payments.length) await pool.query(`DELETE FROM payments WHERE id = ANY($1)`, [L.payments]);
+    if (L.bookings.length) await pool.query(`DELETE FROM bookings WHERE id = ANY($1)`, [L.bookings]);
+    if (L.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [L.users]);
+  }
+});
+
+test('reconcileStalePayments (Pass B): a SUCCEEDED payment whose booking is still PENDING is confirmed (orphan rescue, Cody hole #2)', async () => {
+  const L = { users: [], bookings: [], payments: [] };
+  try {
+    const off = 9000 + winOff++;
+    const { rows: [u] } = await pool.query(`INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-orph-${off}`]); L.users.push(u.id);
+    const { rows: [b] } = await pool.query(
+      `INSERT INTO bookings (traveler_id, place_id, code, status, currency, subtotal, total, platform_fee, host_payout, check_in, check_out)
+       VALUES ($1,1,$2,'pending','USD',10000,12000,1200,10800,CURRENT_DATE + $3::int, CURRENT_DATE + ($3::int + 1)) RETURNING id`,
+      [u.id, `${SENT}-orph-bk-${off}`, off]); L.bookings.push(b.id);
+    // A succeeded payment (webhook durably marked it) but confirmBooking never landed -> booking stuck pending.
+    const { rows: [p] } = await pool.query(
+      `INSERT INTO payments (booking_id, provider, method, currency, amount, status, live_mode, provider_ref, created_at, updated_at)
+       VALUES ($1,'tilopay','card','USD',12000,'succeeded',false,$2, NOW() - make_interval(mins => 60), NOW() - make_interval(mins => 60)) RETURNING id`,
+      [b.id, `${SENT}-orph-ref-${off}`]); L.payments.push(p.id);
+
+    const r = await reconcileStalePayments({ olderThanMinutes: 15, limit: 500 });
+    assert.ok(r.orphanConfirmed >= 1, 'Pass B confirmed at least one succeeded-but-pending booking');
+    assert.equal((await pool.query(`SELECT status FROM bookings WHERE id=$1`, [b.id])).rows[0].status, 'confirmed', 'the orphaned booking is now confirmed');
+    assert.equal((await pool.query(`SELECT status FROM payments WHERE id=$1`, [p.id])).rows[0].status, 'succeeded', 'the payment stays succeeded (untouched)');
+  } finally {
+    if (L.payments.length) await pool.query(`DELETE FROM payments WHERE id = ANY($1)`, [L.payments]);
+    if (L.bookings.length) await pool.query(`DELETE FROM bookings WHERE id = ANY($1)`, [L.bookings]);
+    if (L.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [L.users]);
+  }
+});

← d0912c8 cycle 19 docs: YOLO_NOTES ledger + GO-LIVE migrate_011 prere  ·  back to Costa Rica  ·  cycle 20 docs: YOLO_NOTES ledger — stale-payment reconciler 1ca7bf7 →