← back to Costa Rica
costa-rica: payouts settlement integration test — sandbox rail routing (sinpe/plaid_ach) + row recording + guards, sentinel fixtures w/ FK-safe self-cleanup (verified 0 row leak) — TK-10346 yoloforever C3
c04da8f98c5a5c9e3f5cd3cb040c0b123cee0e3b · 2026-08-07 13:27:24 -0700 · Steve
Files touched
Diff
commit c04da8f98c5a5c9e3f5cd3cb040c0b123cee0e3b
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 7 13:27:24 2026 -0700
costa-rica: payouts settlement integration test — sandbox rail routing (sinpe/plaid_ach) + row recording + guards, sentinel fixtures w/ FK-safe self-cleanup (verified 0 row leak) — TK-10346 yoloforever C3
---
test/payouts.test.js | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
diff --git a/test/payouts.test.js b/test/payouts.test.js
new file mode 100644
index 0000000..f7900c9
--- /dev/null
+++ b/test/payouts.test.js
@@ -0,0 +1,106 @@
+'use strict';
+// Integration test for the settlement engine (lib/payouts.createPayoutForBooking)
+// against the REAL local dev DB, in SANDBOX (liveMode=false — no creds, no money).
+//
+// Isolation note: createPayoutForBooking uses the pooled `pool.query` (many
+// connections), so a single-client BEGIN/ROLLBACK can NOT wrap its writes.
+// Instead we insert sentinel fixtures (committed), assert, and DELETE everything
+// we created in a guaranteed FK-safe `finally` — the DB ends exactly as it began.
+require('dotenv').config(); // load 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 { createPayoutForBooking } = require('../lib/payouts');
+
+after(() => pool.end()); // let the test process exit (open pool would hang it)
+
+const SENT = `YOLOTEST-${Date.now()}`; // unique so we only ever delete our own rows
+
+async function mkUser(name) {
+ const { rows: [u] } = await pool.query(
+ `INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`, [`${SENT}-${name}`]);
+ return u.id;
+}
+async function mkHost(userId) {
+ const { rows: [h] } = await pool.query(
+ `INSERT INTO hosts (user_id, legal_name, country, kyc_status) VALUES ($1,$2,'CR','verified') RETURNING id`,
+ [userId, `${SENT} Host`]);
+ return h.id;
+}
+async function mkPayoutMethod(hostId, kind, extra = {}) {
+ 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`,
+ [hostId, 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, hostId]);
+ return pm.id;
+}
+async function mkBooking(hostId, travelerId, { status = 'completed', hostPayout = 36000 } = {}) {
+ const { rows: [b] } = await pool.query(
+ `INSERT INTO bookings (code, place_id, host_id, traveler_id, currency, subtotal, total, host_payout, status)
+ VALUES ($1, 1, $2, $3, 'CRC', 36000, 40000, $4, $5) RETURNING id`,
+ [`${SENT}-${Math.random().toString(36).slice(2, 8)}`, hostId, travelerId, hostPayout, status]);
+ return b.id;
+}
+
+test('payouts settlement — sandbox, real DB, self-cleaning', async (t) => {
+ const created = { payouts: [], bookings: [], pms: [], hosts: [], users: [] };
+ const track = (bucket, id) => { created[bucket].push(id); return id; };
+ try {
+ const hostUser = track('users', await mkUser('hostuser'));
+ const traveler = track('users', await mkUser('traveler'));
+
+ // --- SINPE host ---
+ const hostA = track('hosts', await mkHost(hostUser));
+ track('pms', await mkPayoutMethod(hostA, 'sinpe_movil', { sinpe_phone: '8888-0000' }));
+ const bkSinpe = track('bookings', await mkBooking(hostA, traveler));
+
+ await t.test('sinpe: routes rail=sinpe and records a processing payout', async () => {
+ const res = await createPayoutForBooking(bkSinpe);
+ assert.equal(res.rail, 'sinpe');
+ assert.equal(res.amount, 36000, 'amount = booking.host_payout');
+ const { rows: [row] } = await pool.query(`SELECT * FROM payouts WHERE booking_id=$1`, [bkSinpe]);
+ track('payouts', row.id);
+ assert.equal(row.rail, 'sinpe');
+ assert.equal(row.status, 'processing');
+ assert.equal(row.live_mode, false, 'sandbox — never live');
+ assert.match(String(row.provider_ref), /_sbx_/, 'sandbox provider ref');
+ });
+
+ // --- Plaid ACH host (sandbox branch must NOT throw; liveMode=false) ---
+ const hostB = track('hosts', await mkHost(track('users', await mkUser('achuser'))));
+ track('pms', await mkPayoutMethod(hostB, 'plaid_ach'));
+ const bkAch = track('bookings', await mkBooking(hostB, traveler));
+
+ await t.test('plaid_ach: sandbox records rail=plaid_ach without throwing', async () => {
+ const res = await createPayoutForBooking(bkAch);
+ assert.equal(res.rail, 'plaid_ach');
+ const { rows: [row] } = await pool.query(`SELECT * FROM payouts WHERE booking_id=$1`, [bkAch]);
+ track('payouts', row.id);
+ assert.equal(row.status, 'processing');
+ assert.match(String(row.provider_ref), /ach_sbx_/);
+ });
+
+ // --- Guards ---
+ await t.test('rejects a booking that is not completed', async () => {
+ const bkPending = track('bookings', await mkBooking(hostA, traveler, { status: 'pending' }));
+ await assert.rejects(() => createPayoutForBooking(bkPending), /not completed/);
+ });
+
+ await t.test('rejects a host with no payout method', async () => {
+ const lonelyHost = track('hosts', await mkHost(track('users', await mkUser('nopm'))));
+ const bk = track('bookings', await mkBooking(lonelyHost, traveler));
+ await assert.rejects(() => createPayoutForBooking(bk), /no payout method/);
+ });
+ } finally {
+ // FK-safe teardown: payouts -> bookings -> null host default -> payout_methods -> hosts -> users
+ const del = async (sql, ids) => { if (ids.length) await pool.query(sql, [ids]); };
+ await del(`DELETE FROM payouts WHERE id = ANY($1)`, created.payouts);
+ await del(`DELETE FROM payouts WHERE booking_id = ANY($1)`, created.bookings); // any we didn't track
+ await del(`DELETE FROM bookings WHERE id = ANY($1)`, created.bookings);
+ if (created.hosts.length) await pool.query(`UPDATE hosts SET default_payout_method_id=NULL WHERE id = ANY($1)`, [created.hosts]);
+ await del(`DELETE FROM payout_methods WHERE id = ANY($1)`, created.pms);
+ await del(`DELETE FROM hosts WHERE id = ANY($1)`, created.hosts);
+ await del(`DELETE FROM app_users WHERE id = ANY($1)`, created.users);
+ }
+});
← 46aa26d costa-rica: untrack .deploy.conf (prod IP) + .playwright-mcp
·
back to Costa Rica
·
costa-rica: Cody-gate fixes on Apple tests — fix real exp:0 55c9557 →