← 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
M YOLO_NOTES.mdM lib/payments/onvo.jsM test/payments.test.js
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 →