← back to Costa Rica
costa-rica: enforce one-default-payout-method-per-host (partial unique index) — closes silent money-misdirection tie in createPayoutForBooking + guard test; record Tilopay CR-KYC blocker + payout catch/timeout preflight — TK-10346 Cody C3
936e3edd682d38455f14d8a28f51f3a823937355 · 2026-08-07 13:33:50 -0700 · Steve
Files touched
M YOLO_NOTES.mdM scripts/migrate_004_marketplace.sqlM test/payouts.test.js
Diff
commit 936e3edd682d38455f14d8a28f51f3a823937355
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 7 13:33:50 2026 -0700
costa-rica: enforce one-default-payout-method-per-host (partial unique index) — closes silent money-misdirection tie in createPayoutForBooking + guard test; record Tilopay CR-KYC blocker + payout catch/timeout preflight — TK-10346 Cody C3
---
YOLO_NOTES.md | 18 ++++++++++++++++++
scripts/migrate_004_marketplace.sql | 6 ++++++
test/payouts.test.js | 14 +++++++++++++-
3 files changed, 37 insertions(+), 1 deletion(-)
diff --git a/YOLO_NOTES.md b/YOLO_NOTES.md
index b756d3c..af1d775 100644
--- a/YOLO_NOTES.md
+++ b/YOLO_NOTES.md
@@ -61,3 +61,21 @@
3. **`LIVE && !WEBHOOK_SECRET` blackhole.** With live creds but no webhook secret, `verifyWebhook` returns `ok:false` for EVERY webhook → payments complete but booking never transitions to succeeded (silent revenue-event loss). Add a loud startup guard (fatal log/throw) if live and the webhook secret is missing.
These are the correctness items to close during the go-live pre-flight, once Steve's Tilopay/ONVO account exists and the real webhook/status vocabulary is observable.
+
+## yoloforever Cycle 3 (TK-10346)
+
+**Landed (local, reversible):**
+- `test/payouts.test.js` — real-DB SANDBOX integration test for `createPayoutForBooking`: sinpe/plaid_ach rail routing, payouts-row recording, not-completed + no-payout-method guards; sentinel fixtures + FK-safe self-cleanup (verified 0 row leak). Suite →47.
+- **Bugfix (Cody C3, VERIFIED):** DB structurally allowed TWO `is_default=true` payout methods per host → `createPayoutForBooking`'s `ORDER BY is_default DESC LIMIT 1` ties → arbitrary rail pick → silent money-misdirection. Added partial-unique index `idx_payout_methods_one_default_per_host ON payout_methods(host_id) WHERE is_default` to `scripts/migrate_004_marketplace.sql` AND applied it to the dev DB; added a subtest proving a 2nd default is rejected. **PROD NOTE: this index must be applied to the Kamatera prod DB during the go-live migration (gated deploy).**
+
+**Tilopay affiliation — HARD KYC BLOCKER discovered (drove the form to CR/step 1):**
+Tilopay merchant affiliation REQUIRES, before any account/keys exist:
+- CR identity: Cédula or Dimex (Persona Física) / legal-rep Cédula·Dimex·Passport + **notarial shareholding certification down to natural persons** (Persona Jurídica)
+- **Registration with the Ministerio de Hacienda as a contribuyente** (CR tax registration)
+- **A Costa Rican bank IBAN in a CR bank, in the affiliate's name** (for settlements)
+- Docs issued within the last 3 months.
+=> Steve cannot get Tilopay creds without CR banking + Hacienda presence. Same likely applies to ONVO (any CR-native processor). **Strategic decision for Steve: confirm the CR entity/bank path, or reconsider the processor.**
+
+**GO-LIVE PRE-FLIGHT (added C3, live-only — verify against real account):**
+4. **payouts.js catch path untested.** `UPDATE payouts SET status='failed'` (lines 46-49) only fires when `provider.payout()` rejects — never in sandbox. Cover with a `provider.payout` mock before go-live; a typo there would strand a failed payout as 'processing'.
+5. **Live payout/charge fetch has no timeout.** `tilopay.js` payout/charge `fetch` has no AbortController — a hung TCP leaves the payouts row 'processing' forever (host never paid). Add a timeout+abort to all live provider fetches during the go-live hardening pass.
diff --git a/scripts/migrate_004_marketplace.sql b/scripts/migrate_004_marketplace.sql
index b7ffa61..cf38c86 100644
--- a/scripts/migrate_004_marketplace.sql
+++ b/scripts/migrate_004_marketplace.sql
@@ -177,6 +177,12 @@ CREATE TABLE IF NOT EXISTS payout_methods (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_payout_methods_host ON payout_methods(host_id);
+-- At most ONE default payout method per host. Without this, a host with two
+-- is_default=TRUE rows makes createPayoutForBooking's `ORDER BY is_default DESC
+-- LIMIT 1` tie and pick an arbitrary rail — silent money-misdirection. The UI
+-- must clear the prior default before setting a new one. (Cody gate, TK-10346 C3)
+CREATE UNIQUE INDEX IF NOT EXISTS idx_payout_methods_one_default_per_host
+ ON payout_methods(host_id) WHERE is_default;
-- Now that payout_methods exists, wire the host default FK
ALTER TABLE hosts
diff --git a/test/payouts.test.js b/test/payouts.test.js
index f7900c9..9c828fe 100644
--- a/test/payouts.test.js
+++ b/test/payouts.test.js
@@ -5,7 +5,9 @@
// 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.
+// we created in a guaranteed FK-safe `finally`. This restores the DB on any
+// assertion failure; it does NOT protect against a hard process kill (SIGINT)
+// mid-run, which would leave YOLOTEST-* rows behind (harmless, dedupable).
require('dotenv').config(); // load DATABASE_URL before lib/db builds the pool
const { test, after } = require('node:test');
const assert = require('node:assert');
@@ -92,6 +94,16 @@ test('payouts settlement — sandbox, real DB, self-cleaning', async (t) => {
const bk = track('bookings', await mkBooking(lonelyHost, traveler));
await assert.rejects(() => createPayoutForBooking(bk), /no payout method/);
});
+
+ // Guard the money-misdirection bug Cody found: the partial-unique index
+ // idx_payout_methods_one_default_per_host must forbid a second default so the
+ // 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]),
+ /duplicate key|one_default_per_host/,
+ 'a second is_default=true for the same host must be rejected');
+ });
} 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]); };
← 2880a1f costa-rica: logo-agent — hot-or-not tournament brand/logo bu
·
back to Costa Rica
·
yoloforever: STOPPED by Steve after cycle 4 — loop halted, n f2fa431 →