← 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
A lib/reconcile.jsA test/reconcile.test.js
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 →