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