← back to Costa Rica

test/reconcile.test.js

158 lines

'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]);
  }
});