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