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