← back to Costa Rica
test/payouts-live-cr-iban.test.js
124 lines
'use strict';
// LIVE-mode coverage for the cr_iban dead-payout guard (Cody gate, cycle 23).
//
// The headline of the cycle-23 fix is the runtime throw in lib/payouts.js:
// if (pm.kind === 'cr_iban' && provider.liveMode) throw new Error('cr_iban ... not implemented')
// A cr_iban method has NO sinpe_phone, and tilopay.payout() builds {phone: sinpe_phone},
// so without this guard a bank/IBAN host gets a {phone:null} transfer -> silently $0 in
// LIVE mode. Every OTHER payouts test runs in SANDBOX (liveMode=false), so that throw —
// the one line that actually prevents money movement — had ZERO coverage in either
// direction. These two tests force LIVE mode (env creds + require-cache reset, the same
// pattern test/payments-body-timeout-failclosed.test.js uses) against the real dev DB:
// (1) cr_iban + live -> throws the guard AND marks the payout row 'failed'
// (2) sinpe_movil + live -> the guard does NOT false-fire; execution reaches
// provider.payout() (proven by a faked /sinpe/transfer call)
// global.fetch is faked, so there is zero network and zero real money.
//
// Isolation: like payouts.test.js, createPayoutForBooking writes via the pooled
// pool.query, so we insert committed sentinel fixtures and DELETE them in a FK-safe
// finally. Sentinel prefix + far-future date windows keep it collision-free.
require('dotenv').config(); // DATABASE_URL before lib/db builds the pool
const { test, after } = require('node:test');
const assert = require('node:assert');
const { pool } = require('../lib/db');
const TILOPAY = require.resolve('../lib/payments/tilopay');
const PAYMENTS = require.resolve('../lib/payments');
const PAYOUTS = require.resolve('../lib/payouts');
// Set creds + drop the cache for the whole provider chain so a re-require reads
// liveMode=true. lib/db is NOT reset, so the pool stays the one shared instance.
function loadLive() {
process.env.TILOPAY_API_USER = 'u';
process.env.TILOPAY_API_PASSWORD = 'p';
process.env.TILOPAY_API_KEY = 'k';
delete require.cache[TILOPAY];
delete require.cache[PAYMENTS];
delete require.cache[PAYOUTS];
return require(PAYOUTS); // re-requires payments + tilopay WITH creds -> liveMode=true
}
function unloadLive() {
delete process.env.TILOPAY_API_USER;
delete process.env.TILOPAY_API_PASSWORD;
delete process.env.TILOPAY_API_KEY;
delete require.cache[TILOPAY];
delete require.cache[PAYMENTS];
delete require.cache[PAYOUTS];
}
after(() => pool.end()); // let the process exit (an open pool would hang it)
const SENT = `YOLOTEST-LIVE-${Date.now()}`;
let win = 0;
async function fixtures(kind, extra = {}) {
const { rows: [u] } = await pool.query(`INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-host`]);
const { rows: [tr] } = await pool.query(`INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-trav`]);
const { rows: [h] } = await pool.query(
`INSERT INTO hosts (user_id, legal_name, country, kyc_status) VALUES ($1,$2,'CR','verified') RETURNING id`,
[u.id, `${SENT} Host`]);
const { rows: [pm] } = await pool.query(
`INSERT INTO payout_methods (host_id, kind, sinpe_phone, cr_iban, is_default, verified)
VALUES ($1,$2,$3,$4,true,true) RETURNING id`,
[h.id, kind, extra.sinpe_phone || null, extra.cr_iban || null]);
await pool.query(`UPDATE hosts SET default_payout_method_id=$1 WHERE id=$2`, [pm.id, h.id]);
const off = 3000 + (win++) * 3; // far-future, non-overlapping (EXCLUDE on place 1)
const { rows: [b] } = await pool.query(
`INSERT INTO bookings (code, place_id, host_id, traveler_id, currency, subtotal, total, platform_fee, host_payout, status, check_in, check_out)
VALUES ($1,1,$2,$3,'CRC',36000,40000,4000,36000,'completed', CURRENT_DATE + $4::int, CURRENT_DATE + ($4::int + 2)) RETURNING id`,
[`${SENT}-${Math.random().toString(36).slice(2, 8)}`, h.id, tr.id, off]);
return { users: [u.id, tr.id], hostId: h.id, bookingId: b.id };
}
async function cleanup(f) {
await pool.query(`DELETE FROM payouts WHERE booking_id=$1`, [f.bookingId]).catch(() => {});
await pool.query(`DELETE FROM bookings WHERE id=$1`, [f.bookingId]).catch(() => {});
await pool.query(`UPDATE hosts SET default_payout_method_id=NULL WHERE id=$1`, [f.hostId]).catch(() => {});
await pool.query(`DELETE FROM payout_methods WHERE host_id=$1`, [f.hostId]).catch(() => {});
await pool.query(`DELETE FROM hosts WHERE id=$1`, [f.hostId]).catch(() => {});
await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [f.users]).catch(() => {});
}
test('cr_iban + LIVE: createPayoutForBooking fails loud AND marks the payout row failed (no {phone:null} transfer)', async () => {
const f = await fixtures('cr_iban', { cr_iban: 'CR05000000000000000001' });
const { createPayoutForBooking } = loadLive();
try {
await assert.rejects(
() => createPayoutForBooking(f.bookingId),
/cr_iban.*not implemented/i,
'a cr_iban payout in LIVE must throw, not send a phone-less SINPE transfer');
const { rows: [row] } = await pool.query(`SELECT status, live_mode FROM payouts WHERE booking_id=$1`, [f.bookingId]);
assert.ok(row, 'the payout row was created before the throw');
assert.equal(row.live_mode, true, 'sanity: the provider really was in LIVE mode');
assert.equal(row.status, 'failed', 'the guard-throw lands the row at failed, not a stuck processing');
} finally {
unloadLive();
await cleanup(f);
}
});
test('sinpe_movil + LIVE: the cr_iban guard does NOT false-fire — execution reaches provider.payout()', async () => {
const f = await fixtures('sinpe_movil', { sinpe_phone: '8888-0000' });
const realFetch = global.fetch;
let transferCalled = false;
global.fetch = (url) => {
const u = String(url);
if (u.includes('/login')) return Promise.resolve({ ok: true, status: 200, json: async () => ({ access_token: 'tok' }) });
if (u.includes('/sinpe/transfer')) { transferCalled = true; return Promise.resolve({ ok: true, status: 200, json: async () => ({ id: 'pyt_live_1', status: 'processing' }) }); }
return Promise.resolve({ ok: true, status: 200, json: async () => ({}) });
};
const { createPayoutForBooking } = loadLive();
try {
const res = await createPayoutForBooking(f.bookingId); // must NOT throw the cr_iban guard
assert.equal(transferCalled, true, 'sinpe_movil reached the real /sinpe/transfer call — the guard is kind-specific');
assert.equal(res.rail, 'sinpe');
const { rows: [row] } = await pool.query(`SELECT status FROM payouts WHERE booking_id=$1`, [f.bookingId]);
assert.equal(row.status, 'processing', 'a live sinpe_movil payout records processing, not failed');
} catch (e) {
assert.doesNotMatch(String(e.message), /cr_iban.*not implemented/i, 'sinpe_movil must never hit the cr_iban guard');
throw e; // any other error is a genuine failure
} finally {
global.fetch = realFetch;
unloadLive();
await cleanup(f);
}
});