← back to Costa Rica

lib/payouts.js

64 lines

'use strict';
// Payout engine — settles a host's earnings after a booking completes.
// Routes by payout_method.kind:
//   sinpe_movil / cr_iban -> SINPE via the payment provider (Tilopay)  [rail=sinpe]
//   plaid_ach             -> ACH via Plaid                              [rail=plaid_ach]
// Records every attempt in the payouts table with live_mode + provider_ref.

const { pool } = require('./db');
const { getProvider } = require('./payments');

async function createPayoutForBooking(bookingId) {
  const { rows: [b] } = await pool.query(
    `SELECT id, code, host_id, traveler_id, currency, total, platform_fee, host_payout, status
       FROM bookings WHERE id=$1`, [bookingId]);
  if (!b) throw new Error('booking not found');
  if (!b.host_id) throw new Error('booking has no host');
  if (b.status !== 'completed') throw new Error(`booking not completed (${b.status})`);

  const { rows: [pm] } = await pool.query(
    `SELECT pm.* FROM payout_methods pm
       JOIN hosts h ON h.id = pm.host_id
      WHERE pm.host_id = $1 AND (pm.is_default OR h.default_payout_method_id = pm.id)
      ORDER BY pm.is_default DESC LIMIT 1`, [b.host_id]);
  if (!pm) throw new Error('host has no payout method');

  const rail = pm.kind === 'plaid_ach' ? 'plaid_ach' : 'sinpe';
  const provider = getProvider();
  const amount = b.host_payout;
  const currency = b.currency;

  const { rows: [payout] } = await pool.query(
    `INSERT INTO payouts (host_id, booking_id, payout_method_id, rail, currency, amount, status, live_mode)
     VALUES ($1,$2,$3,$4,$5,$6,'processing',$7) RETURNING *`,
    [b.host_id, b.id, pm.id, rail, currency, amount, provider.liveMode]);

  let result;
  try {
    if (rail === 'sinpe') {
      // A cr_iban (bank/IBAN) method has NO SINPE phone; provider.payout() only reads
      // sinpe_phone, so a cr_iban host would get a {phone: null} transfer -> silently
      // paid $0 (or a row stuck 'processing'). IBAN/bank-transfer payout isn't wired
      // yet, so fail loud in live (mirrors the plaid_ach guard below) rather than send
      // a malformed request -> the payout row is marked 'failed' + surfaced. (Cody cold
      // audit, cycle 23.)
      if (pm.kind === 'cr_iban' && provider.liveMode) throw new Error('cr_iban (bank/IBAN) payout not implemented — see go-live memo');
      result = await provider.payout({ method: { sinpe_phone: pm.sinpe_phone, cr_iban: pm.cr_iban }, amount, currency, reference: b.code });
    } else {
      // Plaid ACH payout not wired for real money yet — fail loud in live mode
      // rather than silently record a fake success (Cody gate, TK-10346 c1).
      if (provider.liveMode) throw new Error('plaid_ach payout not implemented — see go-live memo');
      result = { providerRef: `ach_sbx_${payout.id}`, status: 'processing', raw: { rail: 'plaid_ach', sandbox: true } };
    }
    await pool.query(`UPDATE payouts SET provider_ref=$1, status=$2, raw=$3 WHERE id=$4`,
      [result.providerRef || null, result.status || 'processing', JSON.stringify(result.raw || {}), payout.id]);
  } catch (e) {
    await pool.query(`UPDATE payouts SET status='failed', raw=$1 WHERE id=$2`,
      [JSON.stringify({ error: String(e.message) }), payout.id]);
    throw e;
  }
  return { ...payout, ...result };
}

module.exports = { createPayoutForBooking };