[object Object]

← back to Costa Rica

costa-rica: fix ONVO getCharge declined-status fallthrough (declined/canceled/failed->failed via pure mapStatus) + regression test + go-live preflight notes — TK-10346 (Cody C1)

0ed0c3d3414f1519643119b0ea274eb09d553b45 · 2026-08-07 11:30:15 -0700 · Steve

Files touched

Diff

commit 0ed0c3d3414f1519643119b0ea274eb09d553b45
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 11:30:15 2026 -0700

    costa-rica: fix ONVO getCharge declined-status fallthrough (declined/canceled/failed->failed via pure mapStatus) + regression test + go-live preflight notes — TK-10346 (Cody C1)
---
 YOLO_NOTES.md         | 13 +++++++++++++
 lib/payments/onvo.js  | 14 +++++++++++---
 test/payments.test.js | 13 +++++++++++++
 3 files changed, 37 insertions(+), 3 deletions(-)

diff --git a/YOLO_NOTES.md b/YOLO_NOTES.md
index e646ff5..b756d3c 100644
--- a/YOLO_NOTES.md
+++ b/YOLO_NOTES.md
@@ -48,3 +48,16 @@
 
 **Live verification (local):**
 - `/provinces` HTTP 200, `/api/provinces` returns 5.4KB JSON, all 7 provinces present, San José leads at 11,570 listings, Escazú #2 with 1,086.
+
+## yoloforever Cycle 1 (Tilopay go-live prep — TK-10346)
+
+**Landed (local, reversible):**
+- `test/payments.test.js` — 16 tests: tilopay+onvo sandbox createCharge(card/sinpe)/getCharge/refund/payout + webhook HMAC accept/tamper/wrong-length/missing. Suite 7→23.
+- **Bugfix** `lib/payments/onvo.js`: `getCharge` status map had no failure entry → a live *declined* intent fell through to `'processing'` and trapped the booking forever (Tilopay mapped `declined→failed`, ONVO did not). Extracted to pure exported `mapStatus()`; added `canceled/declined/failed/requires_payment_method → failed`; added regression test. Suite →24.
+
+**GO-LIVE PRE-FLIGHT (Cody risks, live-only — verify against the REAL provider before flipping liveMode; do NOT guess-fix now):**
+1. **Webhook signature encoding.** Both adapters do `timingSafeEqual(Buffer.from(sig), Buffer.from(expect))` where `expect` is hex. If Tilopay/ONVO send the signature as **base64** (not lowercase hex), lengths differ → throws → every real webhook silently rejected. Confirm each provider's actual signature encoding from their sandbox delivery / docs, then set `Buffer.from(sig, 'hex'|'base64')` explicitly.
+2. **Live createCharge error path.** tilopay.js `providerRef: j.paymentId || j.id` + status fallback `'processing'`: a declined/erroring live response writes `providerRef=undefined, status=processing` → `getCharge(undefined)` 404s → stuck. Add a `res.ok` guard + explicit `failed` mapping before extracting the ref.
+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.
diff --git a/lib/payments/onvo.js b/lib/payments/onvo.js
index db5d5f9..b0aea2e 100644
--- a/lib/payments/onvo.js
+++ b/lib/payments/onvo.js
@@ -27,12 +27,20 @@ async function createCharge({ amount, currency, method = 'card', booking, return
   return { providerRef: j.id, status: j.nextAction ? 'requires_action' : 'processing', raw: j,
     clientAction: j.nextAction?.redirectUrl ? { type: 'redirect', url: j.nextAction.redirectUrl } : null };
 }
+// Map an ONVO payment-intent status to our canonical charge status. Terminal
+// failure states (canceled/declined/failed) MUST resolve to 'failed' — a
+// fallthrough to 'processing' would trap a declined booking in limbo forever.
+// Exported (pure, no I/O) so it is unit-testable without live creds.
+const STATUS_MAP = { succeeded: 'succeeded', processing: 'processing',
+  requires_action: 'processing', requires_payment_method: 'failed',
+  canceled: 'failed', declined: 'failed', failed: 'failed' };
+function mapStatus(s) { return STATUS_MAP[s] || 'processing'; }
+
 async function getCharge(ref) {
   if (!LIVE) return { status: /_sbx_|success/.test(String(ref)) ? 'succeeded' : 'processing', raw: { sandbox: true } };
   const res = await fetch(`${BASE}/payment-intents/${ref}`, { headers: { Authorization: `Bearer ${SECRET}` } });
   const j = await res.json();
-  const map = { succeeded: 'succeeded', processing: 'processing', requires_action: 'processing', canceled: 'failed' };
-  return { status: map[j.status] || 'processing', raw: j };
+  return { status: mapStatus(j.status), raw: j };
 }
 async function refund(ref, amount) {
   if (!LIVE) return { status: 'refunded', raw: { sandbox: true } };
@@ -56,4 +64,4 @@ function verifyWebhook(headers, rawBody) {
 }
 function safeParse(b) { try { return JSON.parse(b); } catch { return null; } }
 
-module.exports = { name: 'onvo', get liveMode() { return LIVE; }, createCharge, getCharge, refund, payout, verifyWebhook };
+module.exports = { name: 'onvo', get liveMode() { return LIVE; }, createCharge, getCharge, refund, payout, verifyWebhook, mapStatus };
diff --git a/test/payments.test.js b/test/payments.test.js
index 45705d7..5e5547d 100644
--- a/test/payments.test.js
+++ b/test/payments.test.js
@@ -88,6 +88,19 @@ test('tilopay: payout (sandbox SINPE) returns a ref and processes', async () =>
   assert.equal(r.status, 'processing');
 });
 
+test('onvo.mapStatus: terminal-failure statuses resolve to failed (not processing)', () => {
+  // Regression: a live declined/failed intent used to fall through to
+  // 'processing' and trap the booking forever (Tilopay mapped declined->failed
+  // but ONVO did not). Failure statuses must be terminal.
+  assert.equal(onvo.mapStatus('succeeded'), 'succeeded');
+  assert.equal(onvo.mapStatus('processing'), 'processing');
+  assert.equal(onvo.mapStatus('requires_action'), 'processing');
+  for (const s of ['canceled', 'declined', 'failed', 'requires_payment_method']) {
+    assert.equal(onvo.mapStatus(s), 'failed', `${s} must map to failed`);
+  }
+  assert.equal(onvo.mapStatus('some_unknown_status'), 'processing'); // safe default
+});
+
 test('payments registry: unknown provider throws, known ones resolve', () => {
   const { getProvider } = require('../lib/payments');
   assert.equal(getProvider('tilopay').name, 'tilopay');

← a273d21 costa-rica: places map viewer (/map) — Leaflet + marker clus  ·  back to Costa Rica  ·  costa-rica: sort+density controls on search + vertical grids 3eaa493 →