[object Object]

← 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

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 →