← back to Costa Rica
costa-rica: close the cr_iban dead-payout path — completeness CHECKs + live fail-loud + live-mode test (cycle 23) — TK-10346
789a630561455735e840bf85d2a81fee8e8eda0c · 2026-09-24 03:21:31 -0700 · Steve
Cold Cody audit of the payout leg: kind='cr_iban' was registerable (route + DB
kind CHECK) but never wired — it routes to rail='sinpe' and tilopay.payout()
only reads sinpe_phone, so a bank/IBAN host got a {phone:null} transfer ->
silently $0 (or a stuck 'processing') in LIVE, invisible in sandbox tests.
Fix (mirrors the already-blessed plaid_ach pattern — registerable, fails loud
in live until wired):
- migrate_012: CHECK sinpe_movil carries a sinpe_phone and cr_iban carries a
cr_iban, so no incomplete method can strand a host at $0 (DB backstop).
- lib/payouts.js: cr_iban + liveMode throws before the phone-less transfer;
the payout row is marked 'failed' + the error surfaces.
- routes/app.js POST /host/payout-methods: 400 an incomplete method before INSERT.
- tests: route 400s (no INSERT) + accepts a complete method; DB CHECK rejects
incomplete (23514); and a NEW live-mode file proves the guard actually fires
for cr_iban+live (row lands 'failed') AND does not false-fire for
sinpe_movil+live (reaches provider.payout()) — the headline throw had zero
coverage before (Cody gate finding). Suite 203 -> 205, serial green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
Files touched
M docs/GO-LIVE.mdM lib/payouts.jsM routes/app.jsA scripts/migrate_012_payout_method_completeness.sqlA test/payout-methods-route.test.jsA test/payouts-live-cr-iban.test.jsM test/payouts.test.js
Diff
commit 789a630561455735e840bf85d2a81fee8e8eda0c
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 24 03:21:31 2026 -0700
costa-rica: close the cr_iban dead-payout path — completeness CHECKs + live fail-loud + live-mode test (cycle 23) — TK-10346
Cold Cody audit of the payout leg: kind='cr_iban' was registerable (route + DB
kind CHECK) but never wired — it routes to rail='sinpe' and tilopay.payout()
only reads sinpe_phone, so a bank/IBAN host got a {phone:null} transfer ->
silently $0 (or a stuck 'processing') in LIVE, invisible in sandbox tests.
Fix (mirrors the already-blessed plaid_ach pattern — registerable, fails loud
in live until wired):
- migrate_012: CHECK sinpe_movil carries a sinpe_phone and cr_iban carries a
cr_iban, so no incomplete method can strand a host at $0 (DB backstop).
- lib/payouts.js: cr_iban + liveMode throws before the phone-less transfer;
the payout row is marked 'failed' + the error surfaces.
- routes/app.js POST /host/payout-methods: 400 an incomplete method before INSERT.
- tests: route 400s (no INSERT) + accepts a complete method; DB CHECK rejects
incomplete (23514); and a NEW live-mode file proves the guard actually fires
for cr_iban+live (row lands 'failed') AND does not false-fire for
sinpe_movil+live (reaches provider.payout()) — the headline throw had zero
coverage before (Cody gate finding). Suite 203 -> 205, serial green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
docs/GO-LIVE.md | 2 +
lib/payouts.js | 7 ++
routes/app.js | 7 ++
scripts/migrate_012_payout_method_completeness.sql | 23 ++++
test/payout-methods-route.test.js | 75 +++++++++++++
test/payouts-live-cr-iban.test.js | 123 +++++++++++++++++++++
test/payouts.test.js | 33 +++++-
7 files changed, 269 insertions(+), 1 deletion(-)
diff --git a/docs/GO-LIVE.md b/docs/GO-LIVE.md
index 5b675c9..22b1acc 100644
--- a/docs/GO-LIVE.md
+++ b/docs/GO-LIVE.md
@@ -43,6 +43,8 @@ NEVER `--baseline` on prod (marks pending files applied WITHOUT running → woul
**`migrate_011_payments_one_inflight.sql` prerequisite (double-charge backstop):** builds a partial UNIQUE index on the HOT `payments` table. BEFORE applying, run the pre-flight duplicate check (in the migration header) — if any booking already has ≥2 in-flight payments the build fails; remediate (keep newest in-flight, mark the rest `failed`, verify against the processor which actually charged). The file uses `CREATE UNIQUE INDEX CONCURRENTLY` so it won't block checkout writes; an interrupted build leaves an INVALID index → `DROP INDEX CONCURRENTLY IF EXISTS payments_one_inflight_per_booking;` and re-run.
+**`migrate_012_payout_method_completeness.sql` prerequisite (payout completeness backstop):** adds two CHECK constraints so a `payout_methods` row carries the identifier its `kind` pays to (`sinpe_movil`→`sinpe_phone`, `cr_iban`→`cr_iban`); without them an incomplete method silently pays a host $0 in LIVE. A plain `ADD CONSTRAINT` fails if any existing row violates, so BEFORE applying run the pre-flight in the migration header — `SELECT id, kind FROM payout_methods WHERE (kind='sinpe_movil' AND sinpe_phone IS NULL) OR (kind='cr_iban' AND cr_iban IS NULL);` — and fix/delete any hits (verify the host's real payout target) first. `payout_methods` is a low-write table (host-initiated registration only, not hot like `payments`), so the brief validation lock is fine; no `CONCURRENTLY`/`NOT VALID` two-step needed at this scale. `plaid_ach` is intentionally unconstrained (its identifier arrives via the `/host/plaid` exchange). NOTE: `cr_iban` is registerable but the IBAN payout rail is NOT wired — a `cr_iban` payout fails loud in LIVE (`lib/payouts.js` guard) until wired; see the pending host-payout-visibility decision memo.
+
**`migrate_010_search_trgm.sql` prerequisite:** it runs `CREATE EXTENSION pg_trgm`, which needs **SUPERUSER / rds_superuser**. If the app's DB role isn't a superuser on prod, run the extension line once as the superuser BEFORE the migration pass: `psql "$SUPERUSER_URL" -c 'CREATE EXTENSION IF NOT EXISTS pg_trgm;'`. The three `CREATE INDEX CONCURRENTLY` lines then build without blocking writes to `places`; if a build aborts it leaves an INVALID index → `DROP INDEX CONCURRENTLY IF EXISTS idx_places_<col>_trgm;` and re-run.
## 4. Deploy code to prod — SURGICAL only (do NOT use canonical /deploy)
diff --git a/lib/payouts.js b/lib/payouts.js
index 7529556..72e7444 100644
--- a/lib/payouts.js
+++ b/lib/payouts.js
@@ -36,6 +36,13 @@ async function createPayoutForBooking(bookingId) {
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
diff --git a/routes/app.js b/routes/app.js
index 7018b24..af5b764 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -550,6 +550,13 @@ router.post('/host/payout-methods', authRequired, async (req, res) => {
const host = await requireHost(req, res); if (!host) return;
const b = req.body || {};
if (!['sinpe_movil', 'cr_iban', 'plaid_ach'].includes(b.kind)) return bad(res, 400, 'bad kind');
+ // Field completeness (Cody cold audit, cycle 23): a payout method must carry the
+ // identifier its kind actually pays to, or the host silently gets $0 at payout time
+ // (a sinpe_movil with no phone / a cr_iban with no IBAN). Clean 400 here, backed by
+ // DB CHECKs (migrate_012). (plaid_ach carries its identifier via the /host/plaid/*
+ // exchange, not this route.)
+ if (b.kind === 'sinpe_movil' && !b.sinpe_phone) return bad(res, 400, 'sinpe_movil requires a sinpe_phone');
+ if (b.kind === 'cr_iban' && !b.cr_iban) return bad(res, 400, 'cr_iban requires a cr_iban (IBAN)');
const { rows: [pm] } = await pool.query(
`INSERT INTO payout_methods (host_id, kind, label, sinpe_phone, cr_iban, bank_name, currency, is_default)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
diff --git a/scripts/migrate_012_payout_method_completeness.sql b/scripts/migrate_012_payout_method_completeness.sql
new file mode 100644
index 0000000..f960b2c
--- /dev/null
+++ b/scripts/migrate_012_payout_method_completeness.sql
@@ -0,0 +1,23 @@
+-- migrate_012_payout_method_completeness.sql — payout-method field completeness.
+--
+-- BUG (cold audit, cycle 23): payout_methods.kind CHECK allows 'sinpe_movil' /
+-- 'cr_iban' / 'plaid_ach', but NOTHING required the matching identifier column to be
+-- present. A host could register kind='cr_iban' with cr_iban NULL (or sinpe_movil
+-- with sinpe_phone NULL), it becomes their default, and at payout time
+-- lib/payments/tilopay.js payout() builds {phone: sinpe_phone} — a cr_iban method has
+-- no phone, so the host silently gets $0 (or a row stuck 'processing') in LIVE mode.
+-- The route now 400s an incomplete method (routes/app.js) and payouts.js fails loud
+-- in live for cr_iban; these DB CHECKs are the backstop so no incomplete row can
+-- exist regardless of the entry path.
+--
+-- plaid_ach carries its identifier via plaid_account_id (set by the /host/plaid/*
+-- exchange), so it is intentionally NOT constrained here.
+--
+-- PROD-APPLY: pre-check for existing violators first (a plain ADD CONSTRAINT fails if
+-- any row violates), then remediate before applying:
+-- SELECT id, kind FROM payout_methods
+-- WHERE (kind='sinpe_movil' AND sinpe_phone IS NULL) OR (kind='cr_iban' AND cr_iban IS NULL);
+-- -- if any: fix or delete those rows (verify the host's real payout target) first.
+ALTER TABLE payout_methods
+ ADD CONSTRAINT payout_methods_sinpe_phone_reqd CHECK (kind <> 'sinpe_movil' OR sinpe_phone IS NOT NULL),
+ ADD CONSTRAINT payout_methods_iban_reqd CHECK (kind <> 'cr_iban' OR cr_iban IS NOT NULL);
diff --git a/test/payout-methods-route.test.js b/test/payout-methods-route.test.js
new file mode 100644
index 0000000..9402e91
--- /dev/null
+++ b/test/payout-methods-route.test.js
@@ -0,0 +1,75 @@
+'use strict';
+// POST /host/payout-methods field-completeness validation (Cody cold audit, cycle 23).
+// A payout method must carry the identifier its kind pays to, or the host silently
+// gets $0 at payout time. These prove the route 400s an incomplete method BEFORE any
+// INSERT, and accepts a complete one. No real DB: pool.query is a recording mock.
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+const express = require('express');
+
+const { signToken } = require('../lib/auth');
+const db = require('../lib/db');
+const { router } = require('../routes/app');
+
+let responses = [];
+let calls = [];
+const origQuery = db.pool.query;
+let server, base;
+before(async () => {
+ db.pool.query = async (sql, args) => { calls.push({ sql, args }); return responses.length ? responses.shift() : { rows: [], rowCount: 0 }; };
+ const app = express();
+ app.use(express.json());
+ app.use('/api/app', router);
+ await new Promise(r => { server = app.listen(0, r); });
+ base = `http://127.0.0.1:${server.address().port}`;
+});
+after(() => { db.pool.query = origQuery; server && server.close(); });
+
+const HOST = { rows: [{ id: 9, user_id: 3, legal_name: 'H', country: 'CR' }] }; // requireHost
+function reset(resp) { responses = resp.slice(); calls = []; }
+const hasSql = (re) => calls.some(c => re.test(c.sql));
+
+function post(path, body, token) {
+ const data = JSON.stringify(body);
+ return new Promise((resolve, reject) => {
+ const r = http.request(base + path, { method: 'POST', headers: {
+ 'content-type': 'application/json', 'content-length': Buffer.byteLength(data),
+ ...(token ? { authorization: 'Bearer ' + token } : {}) } },
+ res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(b || '{}') })); });
+ r.on('error', reject); r.end(data);
+ });
+}
+const token = () => signToken({ sub: 3, role: 'guest' });
+
+test('POST /host/payout-methods: cr_iban WITHOUT an IBAN -> 400, no INSERT (would silently pay $0)', async () => {
+ reset([HOST]);
+ const r = await post('/api/app/host/payout-methods', { kind: 'cr_iban' }, token());
+ assert.equal(r.status, 400);
+ assert.match(r.json.error, /cr_iban requires/i);
+ assert.equal(hasSql(/INSERT INTO payout_methods/), false, 'an incomplete method must not be persisted');
+});
+
+test('POST /host/payout-methods: sinpe_movil WITHOUT a phone -> 400, no INSERT', async () => {
+ reset([HOST]);
+ const r = await post('/api/app/host/payout-methods', { kind: 'sinpe_movil' }, token());
+ assert.equal(r.status, 400);
+ assert.match(r.json.error, /sinpe_movil requires/i);
+ assert.equal(hasSql(/INSERT INTO payout_methods/), false);
+});
+
+test('POST /host/payout-methods: a COMPLETE sinpe_movil (with phone) is accepted -> 200 + INSERT', async () => {
+ reset([HOST, { rows: [{ id: 55, kind: 'sinpe_movil', currency: 'CRC', is_default: true }] }, { rowCount: 1 }]);
+ const r = await post('/api/app/host/payout-methods', { kind: 'sinpe_movil', sinpe_phone: '8888-0000' }, token());
+ assert.equal(r.status, 200);
+ assert.equal(r.json.payout_method.id, 55);
+ assert.ok(hasSql(/INSERT INTO payout_methods/), 'a complete method is persisted');
+});
+
+test('POST /host/payout-methods: an unknown kind -> 400', async () => {
+ reset([HOST]);
+ const r = await post('/api/app/host/payout-methods', { kind: 'venmo' }, token());
+ assert.equal(r.status, 400);
+ assert.match(r.json.error, /bad kind/i);
+});
diff --git a/test/payouts-live-cr-iban.test.js b/test/payouts-live-cr-iban.test.js
new file mode 100644
index 0000000..bfcf8b6
--- /dev/null
+++ b/test/payouts-live-cr-iban.test.js
@@ -0,0 +1,123 @@
+'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);
+ }
+});
diff --git a/test/payouts.test.js b/test/payouts.test.js
index 45f4736..ff5ac4b 100644
--- a/test/payouts.test.js
+++ b/test/payouts.test.js
@@ -64,6 +64,35 @@ async function mkBooking(hostId, travelerId, { status = 'completed', hostPayout
return b.id;
}
+// migrate_012 completeness backstop (Cody cold audit, cycle 23): a payout method must
+// carry the identifier its kind pays to, so an incomplete method can't strand a host
+// at $0. The route 400s these; these CHECKs are the DB-level backstop.
+test('payout_methods completeness CHECKs: sinpe_movil needs a phone, cr_iban needs an IBAN (23514)', async () => {
+ const created = { users: [], hosts: [] };
+ try {
+ const uid = await mkUser('completeness'); created.users.push(uid);
+ const hid = await mkHost(uid); created.hosts.push(hid);
+ await assert.rejects(
+ () => pool.query(`INSERT INTO payout_methods (host_id, kind) VALUES ($1,'sinpe_movil')`, [hid]),
+ (e) => { assert.equal(e.code, '23514'); assert.match(String(e.constraint), /sinpe_phone_reqd/); return true; },
+ 'a sinpe_movil with no phone must be rejected');
+ await assert.rejects(
+ () => pool.query(`INSERT INTO payout_methods (host_id, kind) VALUES ($1,'cr_iban')`, [hid]),
+ (e) => { assert.equal(e.code, '23514'); assert.match(String(e.constraint), /iban_reqd/); return true; },
+ 'a cr_iban with no IBAN must be rejected');
+ // A complete method AND a plaid_ach (identifier via /host/plaid exchange) are allowed.
+ const { rows: [okp] } = await pool.query(`INSERT INTO payout_methods (host_id, kind, sinpe_phone) VALUES ($1,'sinpe_movil','8888-0000') RETURNING id`, [hid]);
+ assert.ok(okp.id, 'a complete sinpe_movil is accepted');
+ const { rows: [oka] } = await pool.query(`INSERT INTO payout_methods (host_id, kind) VALUES ($1,'plaid_ach') RETURNING id`, [hid]);
+ assert.ok(oka.id, 'plaid_ach is not constrained by these CHECKs (its identifier comes from the exchange)');
+ } finally {
+ if (created.hosts.length) await pool.query(`UPDATE hosts SET default_payout_method_id=NULL WHERE id = ANY($1)`, [created.hosts]).catch(() => {});
+ if (created.hosts.length) await pool.query(`DELETE FROM payout_methods WHERE host_id = ANY($1)`, [created.hosts]);
+ if (created.hosts.length) await pool.query(`DELETE FROM hosts WHERE id = ANY($1)`, [created.hosts]);
+ if (created.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [created.users]);
+ }
+});
+
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; };
@@ -119,7 +148,9 @@ test('payouts settlement — sandbox, real DB, self-cleaning', async (t) => {
// rail-selection query can never tie. hostA already has one default (sinpe).
await t.test('DB forbids a second default payout method per host', async () => {
await assert.rejects(
- () => pool.query(`INSERT INTO payout_methods (host_id, kind, is_default) VALUES ($1,'cr_iban',true)`, [hostA]),
+ // Complete method (has an IBAN) so it passes migrate_012's completeness CHECK
+ // and actually reaches the one-default unique index this test targets.
+ () => pool.query(`INSERT INTO payout_methods (host_id, kind, cr_iban, is_default) VALUES ($1,'cr_iban','CR05000000000000000001',true)`, [hostA]),
/duplicate key|one_default_per_host/,
'a second is_default=true for the same host must be rejected');
});
← 1ccb0ed cycle 22 docs: YOLO_NOTES ledger — per-event WhatsApp auto-r
·
back to Costa Rica
·
cycle 23 docs: YOLO_NOTES ledger — cr_iban dead-payout fix + a52a312 →