[object Object]

← back to Costa Rica

costa-rica: createCharge fail-closed on live error status + Cody-found synchronous-decline gap (PRE-FLIGHT §5b #2) — TK-10346

b7da97ccfdccdd0a3c00cc323d449183cc891b90 · 2026-09-23 19:00:00 -0700 · Steve

GO-LIVE PRE-FLIGHT §5b #2 (provider-agnostic half): a live createCharge that
returned an HTTP error status (4xx/5xx) had its error body parsed for
paymentId/id (-> undefined) and mis-mapped to status:'processing' — a stuck
booking whose webhook (UPDATE ... WHERE provider_ref=...) can never match.

Round 1 (transport-level guard): tilopay.js + onvo.js createCharge now check
`!res.ok` and return status:'failed' before extracting a ref. routes/app.js
POST /bookings/:code/pay now honors charge.status==='failed' — records the
payments row 'failed' + returns 402, instead of flattening every non-succeeded
status into 'processing' (which would have silently defeated the adapter guard
at the DB layer).

Cody gate — FIX FIRST verdict: the transport guard only caught HTTP-status-coded
declines. A SYNCHRONOUS decline via HTTP 200 + a body-level status (Stripe-like
intent creation, which both onvo docs itself as and tilopay's own getCharge
already maps 'declined'->'failed' for) fell through the exact same stuck-booking
bug, just via the body instead of the status line — and both adapters' own
mapStatus/status-map (built + unit-tested for getCharge) already knew how to
recognize it; createCharge just never consulted it.

Round 2 (body-level guard, Cody-directed): hoisted each adapter's status map to
a shared, exported `mapStatus()` used by BOTH createCharge and getCharge, so a
decline is recognized identically regardless of which call surfaces it.
createCharge now checks mapStatus(j.status)==='failed' before falling through
to 'requires_action'/'processing'. providerRef is KEPT on a body-level decline
(flows through the normal return, not the null-ref transport-error branch) —
a later webhook/reconciliation may need it (Cody probe #5). Also: a
swallowed error-body-read on the transport-error path now captures e.message
into raw instead of silently substituting {} (Cody probe #4, diagnostics only —
status is already hardcoded 'failed' above the catch, so this cannot fabricate
a success).

Tests (+7 across 2 rounds; suite 126 -> 135):
  - createcharge-error-status.test.js (NEW, 8 tests): 4xx fail-closed + 200-body
    synchronous decline fail-closed (both providers) + providerRef kept + happy
    path unchanged.
  - pay-failed-charge.test.js (NEW, 2 tests): the POST /pay route records
    'failed' + returns 402 on a failed charge; a succeeded charge is unaffected.
  - payments.test.js: tilopay.mapStatus parity test (mirrors the existing
    onvo.mapStatus regression test).

Provider-agnostic hardening for #4/#5/#6/#7 + §5b #2 now complete. Remaining
money-path items (sig encoding, provider-honored idempotency key, $1 verify)
are live-only, gated on Tilopay/ONVO account provisioning (CR-KYC blocked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic

Files touched

Diff

commit b7da97ccfdccdd0a3c00cc323d449183cc891b90
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 23 19:00:00 2026 -0700

    costa-rica: createCharge fail-closed on live error status + Cody-found synchronous-decline gap (PRE-FLIGHT §5b #2) — TK-10346
    
    GO-LIVE PRE-FLIGHT §5b #2 (provider-agnostic half): a live createCharge that
    returned an HTTP error status (4xx/5xx) had its error body parsed for
    paymentId/id (-> undefined) and mis-mapped to status:'processing' — a stuck
    booking whose webhook (UPDATE ... WHERE provider_ref=...) can never match.
    
    Round 1 (transport-level guard): tilopay.js + onvo.js createCharge now check
    `!res.ok` and return status:'failed' before extracting a ref. routes/app.js
    POST /bookings/:code/pay now honors charge.status==='failed' — records the
    payments row 'failed' + returns 402, instead of flattening every non-succeeded
    status into 'processing' (which would have silently defeated the adapter guard
    at the DB layer).
    
    Cody gate — FIX FIRST verdict: the transport guard only caught HTTP-status-coded
    declines. A SYNCHRONOUS decline via HTTP 200 + a body-level status (Stripe-like
    intent creation, which both onvo docs itself as and tilopay's own getCharge
    already maps 'declined'->'failed' for) fell through the exact same stuck-booking
    bug, just via the body instead of the status line — and both adapters' own
    mapStatus/status-map (built + unit-tested for getCharge) already knew how to
    recognize it; createCharge just never consulted it.
    
    Round 2 (body-level guard, Cody-directed): hoisted each adapter's status map to
    a shared, exported `mapStatus()` used by BOTH createCharge and getCharge, so a
    decline is recognized identically regardless of which call surfaces it.
    createCharge now checks mapStatus(j.status)==='failed' before falling through
    to 'requires_action'/'processing'. providerRef is KEPT on a body-level decline
    (flows through the normal return, not the null-ref transport-error branch) —
    a later webhook/reconciliation may need it (Cody probe #5). Also: a
    swallowed error-body-read on the transport-error path now captures e.message
    into raw instead of silently substituting {} (Cody probe #4, diagnostics only —
    status is already hardcoded 'failed' above the catch, so this cannot fabricate
    a success).
    
    Tests (+7 across 2 rounds; suite 126 -> 135):
      - createcharge-error-status.test.js (NEW, 8 tests): 4xx fail-closed + 200-body
        synchronous decline fail-closed (both providers) + providerRef kept + happy
        path unchanged.
      - pay-failed-charge.test.js (NEW, 2 tests): the POST /pay route records
        'failed' + returns 402 on a failed charge; a succeeded charge is unaffected.
      - payments.test.js: tilopay.mapStatus parity test (mirrors the existing
        onvo.mapStatus regression test).
    
    Provider-agnostic hardening for #4/#5/#6/#7 + §5b #2 now complete. Remaining
    money-path items (sig encoding, provider-honored idempotency key, $1 verify)
    are live-only, gated on Tilopay/ONVO account provisioning (CR-KYC blocked).
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
 lib/payments/onvo.js                   |  20 ++++-
 lib/payments/tilopay.js                |  33 ++++++--
 routes/app.js                          |  15 +++-
 test/createcharge-error-status.test.js | 148 +++++++++++++++++++++++++++++++++
 test/pay-failed-charge.test.js         |  84 +++++++++++++++++++
 test/payments.test.js                  |  12 +++
 6 files changed, 304 insertions(+), 8 deletions(-)

diff --git a/lib/payments/onvo.js b/lib/payments/onvo.js
index 8fb9fc2..0738969 100644
--- a/lib/payments/onvo.js
+++ b/lib/payments/onvo.js
@@ -24,13 +24,29 @@ async function createCharge({ amount, currency, method = 'card', booking, return
     method: 'POST', headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
     body: JSON.stringify({ amount, currency, description: booking?.code, redirectUrl: returnUrl }),
   });
+  // FAIL-CLOSED on a live error status (4xx/5xx) — see tilopay.js createCharge. An
+  // error body has no id, so {providerRef: j.id, status: 'processing'} would strand
+  // the booking. Return 'failed'; body read is best-effort diagnostics only.
+  if (!res.ok) {
+    return { providerRef: null, status: 'failed', raw: await res.json().catch((e) => ({ error: `error body unreadable: ${e.message}` })), clientAction: null };
+  }
   const j = await res.json();
-  return { providerRef: j.id, status: j.nextAction ? 'requires_action' : 'processing', raw: j,
-    clientAction: j.nextAction?.redirectUrl ? { type: 'redirect', url: j.nextAction.redirectUrl } : null };
+  // A SYNCHRONOUS decline (Stripe-like intent creation can return HTTP 200 with
+  // status:'requires_payment_method'/'canceled'/etc — the same terminal-failure
+  // vocabulary STATUS_MAP below already maps to 'failed' for getCharge polling)
+  // must be caught here too, not just the transport-level !res.ok above — a
+  // fallthrough to 'processing' is the exact stuck-booking bug this ticket is
+  // about, just via the body instead of the HTTP status. (Cody gate, cycle 7.)
+  const mapped = mapStatus(j.status);
+  const status = mapped === 'failed' ? 'failed' : (j.nextAction ? 'requires_action' : mapped);
+  return { providerRef: j.id, status, raw: j,
+    clientAction: status === 'failed' ? null : (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.
+// Shared by createCharge (a synchronous decline) AND getCharge (polling) so a
+// decline is recognized identically regardless of which call surfaces it.
 // 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',
diff --git a/lib/payments/tilopay.js b/lib/payments/tilopay.js
index 2b3c51c..4ecc133 100644
--- a/lib/payments/tilopay.js
+++ b/lib/payments/tilopay.js
@@ -64,12 +64,36 @@ async function createCharge({ amount, currency, method = 'card', booking, custom
       billToEmail: customer?.email, billToFirstName: customer?.name,
     }),
   });
+  // FAIL-CLOSED on a live error status (4xx/5xx). An error body has no paymentId/id,
+  // so extracting `j.paymentId || j.id` -> undefined and the status fallback -> the
+  // bare 'processing' branch, i.e. {providerRef: undefined, status: 'processing'} —
+  // a stuck booking whose webhook (UPDATE ... WHERE provider_ref=...) matches nothing.
+  // Return 'failed' instead; the error body is read best-effort for diagnostics only
+  // (status is already fixed, so a swallowed body timeout here cannot fabricate a
+  // success — unlike the refund/payout sites). (GO-LIVE PRE-FLIGHT §5b #2.)
+  if (!res.ok) {
+    return { providerRef: null, status: 'failed', raw: await res.json().catch((e) => ({ error: `error body unreadable: ${e.message}` })), clientAction: null };
+  }
   const j = await res.json();
-  const status = j.url ? 'requires_action' : (j.status === 'success' ? 'succeeded' : 'processing');
+  // A SYNCHRONOUS decline (HTTP 200, j.status='declined') must be caught here too,
+  // not just the transport-level !res.ok above — the old ternary only recognized
+  // j.status==='success' and fell everything else (including a real decline)
+  // through to 'processing', stranding the booking. Route through the SAME
+  // status vocabulary getCharge already uses below, so a decline is recognized
+  // identically regardless of which call surfaces it. (Cody gate, cycle 7.)
+  const mapped = mapStatus(j.status);
+  const status = mapped === 'failed' ? 'failed' : (j.url ? 'requires_action' : mapped);
   return { providerRef: j.paymentId || j.id, status, raw: j,
-    clientAction: j.url ? { type: 'redirect', url: j.url } : null };
+    clientAction: status === 'failed' ? null : (j.url ? { type: 'redirect', url: j.url } : null) };
 }
 
+// Map a Tilopay payment status string to our canonical status vocabulary. Shared
+// by createCharge (a synchronous decline) AND getCharge (polling) so a decline is
+// recognized identically regardless of which call surfaces it. Exported (pure, no
+// I/O) so it is unit-testable without live creds.
+const STATUS_MAP = { success: 'succeeded', pending: 'processing', declined: 'failed', reversed: 'refunded' };
+function mapStatus(s) { return STATUS_MAP[s] || 'processing'; }
+
 async function getCharge(providerRef) {
   if (!LIVE) {
     const succeeded = /result=success|_sbx_/.test(String(providerRef));
@@ -78,8 +102,7 @@ async function getCharge(providerRef) {
   const t = await token();
   const res = await fetchT(`${BASE}/payment/${providerRef}`, { headers: { Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY } });
   const j = await res.json();
-  const map = { success: 'succeeded', pending: 'processing', declined: 'failed', reversed: 'refunded' };
-  return { status: map[j.status] || 'processing', raw: j };
+  return { status: mapStatus(j.status), raw: j };
 }
 
 async function refund(providerRef, amount) {
@@ -139,4 +162,4 @@ function verifyWebhook(headers, rawBody) {
 
 function safeParse(b) { try { return JSON.parse(b); } catch { return null; } }
 
-module.exports = { name: 'tilopay', get liveMode() { return LIVE; }, get webhookSecretSet() { return !!WEBHOOK_SECRET; }, createCharge, getCharge, refund, payout, verifyWebhook };
+module.exports = { name: 'tilopay', get liveMode() { return LIVE; }, get webhookSecretSet() { return !!WEBHOOK_SECRET; }, createCharge, getCharge, refund, payout, verifyWebhook, mapStatus };
diff --git a/routes/app.js b/routes/app.js
index 5811885..6bd4488 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -276,7 +276,20 @@ router.post('/bookings/:code/pay', authRequired, async (req, res) => {
     return bad(res, 502, `processor error: ${e.message}`);
   }
 
-  // Post-charge: write the provider_ref and final status.
+  // A provider that returned a FAILED charge (e.g. a live 4xx decline surfaced by the
+  // createCharge res.ok guard) must record 'failed' + surface an error — it must NOT
+  // be flattened into 'processing' (a stuck row with a null provider_ref the webhook
+  // can never resolve, holding the booking 'pending' forever). PRE-FLIGHT §5b #2,
+  // consumer half — the adapter's fail-closed 'failed' only helps if the route honors it.
+  if (charge.status === 'failed') {
+    await pool.query(`UPDATE payments SET provider_ref=$1, status='failed', raw=$2, updated_at=NOW() WHERE id=$3`,
+      [charge.providerRef || null, JSON.stringify(charge.raw || {}), pay.id]);
+    return bad(res, 402, 'payment failed');
+  }
+
+  // Post-charge: write the provider_ref and final status. ('requires_action' is
+  // surfaced to the client but stored as 'processing' — the payments.status CHECK
+  // has no 'requires_action'; the row is in-flight until the webhook/poll resolves it.)
   await pool.query(
     `UPDATE payments SET provider_ref=$1, status=$2, raw=$3, updated_at=NOW() WHERE id=$4`,
     [charge.providerRef, charge.status === 'succeeded' ? 'succeeded' : 'processing', JSON.stringify(charge.raw || {}), pay.id]);
diff --git a/test/createcharge-error-status.test.js b/test/createcharge-error-status.test.js
new file mode 100644
index 0000000..d84f588
--- /dev/null
+++ b/test/createcharge-error-status.test.js
@@ -0,0 +1,148 @@
+'use strict';
+// GO-LIVE PRE-FLIGHT §5b #2 (provider-agnostic half), TK-10346.
+//
+// A live createCharge that returns a 4xx/5xx error status has a body with no
+// paymentId/id. Before this guard, tilopay/onvo createCharge extracted the ref
+// from that error body (-> undefined) and fell through to status 'processing',
+// returning {providerRef: undefined, status: 'processing'} — a stuck booking whose
+// webhook (UPDATE ... WHERE provider_ref=...) can never match. Now both fail closed
+// (status: 'failed', providerRef: null) on !res.ok. The happy path is unchanged.
+//
+// Forces LIVE mode via env + require-cache reset; global.fetch is faked, so zero
+// network and zero real money.
+
+const { test } = require('node:test');
+const assert = require('node:assert');
+
+const TILOPAY = require.resolve('../lib/payments/tilopay');
+const ONVO = require.resolve('../lib/payments/onvo');
+const realFetch = global.fetch;
+
+function loadLiveTilopay() {
+  process.env.TILOPAY_API_USER = 'u';
+  process.env.TILOPAY_API_PASSWORD = 'p';
+  process.env.TILOPAY_API_KEY = 'k';
+  delete require.cache[TILOPAY];
+  return require(TILOPAY);
+}
+function unloadLiveTilopay() {
+  delete process.env.TILOPAY_API_USER;
+  delete process.env.TILOPAY_API_PASSWORD;
+  delete process.env.TILOPAY_API_KEY;
+  delete require.cache[TILOPAY];
+}
+function loadLiveOnvo() {
+  process.env.ONVO_SECRET_KEY = 'sk_test';
+  delete require.cache[ONVO];
+  return require(ONVO);
+}
+function unloadLiveOnvo() {
+  delete process.env.ONVO_SECRET_KEY;
+  delete require.cache[ONVO];
+}
+
+const charge = { amount: 12000, currency: 'USD', method: 'card', booking: { code: 'CR-X' }, customer: { email: 'a@b.c', name: 'A' }, returnUrl: 'https://app/return' };
+
+test('tilopay createCharge(): a live 4xx error status fails closed (status:failed, providerRef:null)', async () => {
+  const tilopay = loadLiveTilopay();
+  global.fetch = (url) => {
+    if (String(url).includes('/login')) return Promise.resolve({ ok: true, status: 200, json: async () => ({ access_token: 'tok' }) });
+    // 402 error body with NO paymentId/id — the exact shape that used to map to 'processing'.
+    return Promise.resolve({ ok: false, status: 402, json: async () => ({ error: 'card_declined' }) });
+  };
+  try {
+    const r = await tilopay.createCharge(charge);
+    assert.equal(r.status, 'failed', 'a 4xx charge must not read as processing');
+    assert.equal(r.providerRef, null, 'no ref extracted from an error body');
+    assert.equal(r.clientAction, null, 'no client action on a failed charge');
+    assert.deepEqual(r.raw, { error: 'card_declined' }, 'error body captured for diagnostics');
+  } finally {
+    global.fetch = realFetch;
+    unloadLiveTilopay();
+  }
+});
+
+test('tilopay createCharge(): a SYNCHRONOUS decline (HTTP 200, j.status="declined") fails closed, NOT flattened to processing', async () => {
+  const tilopay = loadLiveTilopay();
+  global.fetch = (url) => {
+    if (String(url).includes('/login')) return Promise.resolve({ ok: true, status: 200, json: async () => ({ access_token: 'tok' }) });
+    // ok:true (HTTP 200) — the transport-level !res.ok guard does NOT catch this;
+    // the decline is only visible in the body. This is the gap Cody found: the old
+    // ternary only checked j.status==='success' and fell everything else through
+    // to 'processing'.
+    return Promise.resolve({ ok: true, status: 200, json: async () => ({ paymentId: 'pay_declined_1', status: 'declined' }) });
+  };
+  try {
+    const r = await tilopay.createCharge(charge);
+    assert.equal(r.status, 'failed', 'a body-level decline must not read as processing');
+    assert.equal(r.providerRef, 'pay_declined_1', 'a real providerRef from a declined charge is KEPT (Cody probe #5) — a later webhook/reconciliation needs it');
+    assert.equal(r.clientAction, null, 'no client action on a failed charge');
+  } finally {
+    global.fetch = realFetch;
+    unloadLiveTilopay();
+  }
+});
+
+test('tilopay createCharge(): a live 200 success is UNCHANGED by the guard', async () => {
+  const tilopay = loadLiveTilopay();
+  global.fetch = (url) => {
+    if (String(url).includes('/login')) return Promise.resolve({ ok: true, status: 200, json: async () => ({ access_token: 'tok' }) });
+    return Promise.resolve({ ok: true, status: 200, json: async () => ({ paymentId: 'pay_1', url: 'https://3ds/redirect' }) });
+  };
+  try {
+    const r = await tilopay.createCharge(charge);
+    assert.equal(r.status, 'requires_action', '3DS redirect -> requires_action');
+    assert.equal(r.providerRef, 'pay_1');
+    assert.deepEqual(r.clientAction, { type: 'redirect', url: 'https://3ds/redirect' });
+  } finally {
+    global.fetch = realFetch;
+    unloadLiveTilopay();
+  }
+});
+
+test('onvo createCharge(): a live 4xx error status fails closed (status:failed, providerRef:null)', async () => {
+  const onvo = loadLiveOnvo();
+  global.fetch = () => Promise.resolve({ ok: false, status: 400, json: async () => ({ message: 'invalid amount' }) });
+  try {
+    const r = await onvo.createCharge(charge);
+    assert.equal(r.status, 'failed', 'a 4xx charge must not read as processing');
+    assert.equal(r.providerRef, null, 'no ref extracted from an error body');
+    assert.equal(r.clientAction, null);
+    assert.deepEqual(r.raw, { message: 'invalid amount' });
+  } finally {
+    global.fetch = realFetch;
+    unloadLiveOnvo();
+  }
+});
+
+test('onvo createCharge(): a SYNCHRONOUS decline (HTTP 200, j.status="requires_payment_method") fails closed, NOT flattened to processing', async () => {
+  const onvo = loadLiveOnvo();
+  // ok:true (HTTP 200) — Stripe-like intent creation returns 200 even on a
+  // synchronous card decline. requires_payment_method is exactly what onvo's own
+  // STATUS_MAP already maps to 'failed' for getCharge polling; createCharge must
+  // recognize it identically (Cody gate, cycle 7).
+  global.fetch = () => Promise.resolve({ ok: true, status: 200, json: async () => ({ id: 'pi_declined_1', status: 'requires_payment_method' }) });
+  try {
+    const r = await onvo.createCharge(charge);
+    assert.equal(r.status, 'failed', 'a body-level decline must not read as processing');
+    assert.equal(r.providerRef, 'pi_declined_1', 'a real providerRef from a declined intent is KEPT (Cody probe #5)');
+    assert.equal(r.clientAction, null);
+  } finally {
+    global.fetch = realFetch;
+    unloadLiveOnvo();
+  }
+});
+
+test('onvo createCharge(): a live 200 success is UNCHANGED by the guard', async () => {
+  const onvo = loadLiveOnvo();
+  global.fetch = () => Promise.resolve({ ok: true, status: 200, json: async () => ({ id: 'pi_1', nextAction: { redirectUrl: 'https://3ds/onvo' } }) });
+  try {
+    const r = await onvo.createCharge(charge);
+    assert.equal(r.status, 'requires_action');
+    assert.equal(r.providerRef, 'pi_1');
+    assert.deepEqual(r.clientAction, { type: 'redirect', url: 'https://3ds/onvo' });
+  } finally {
+    global.fetch = realFetch;
+    unloadLiveOnvo();
+  }
+});
diff --git a/test/pay-failed-charge.test.js b/test/pay-failed-charge.test.js
new file mode 100644
index 0000000..8b165f8
--- /dev/null
+++ b/test/pay-failed-charge.test.js
@@ -0,0 +1,84 @@
+'use strict';
+// PRE-FLIGHT §5b #2 consumer half (TK-10346): the POST /bookings/:code/pay endpoint
+// must HONOR a provider createCharge that returns status:'failed' — recording the
+// payments row 'failed' and returning a 402 — instead of flattening it into
+// 'processing' (a stuck row with a null provider_ref the webhook can never resolve).
+//
+// No real DB / network: pool.query is a queue mock, the provider's createCharge is
+// stubbed to a failed charge (as a live 4xx decline now produces via the res.ok guard).
+
+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 tilopay = require('../lib/payments/tilopay');
+const { router } = require('../routes/app');
+
+let responses = [];
+let calls = [];
+const origQuery = db.pool.query;
+const origCreateCharge = tilopay.createCharge;
+
+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; tilopay.createCharge = origCreateCharge; server && server.close(); });
+
+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);
+  });
+}
+
+test('MONEY: a failed createCharge records the payment as failed + returns 402 (NOT flattened to processing)', async () => {
+  const token = signToken({ sub: 3, role: 'guest' });
+  // Stub the default provider (tilopay) to a FAILED charge — the shape a live 4xx
+  // decline now produces via the createCharge res.ok guard.
+  tilopay.createCharge = async () => ({ providerRef: null, status: 'failed', raw: { error: 'card_declined' }, clientAction: null });
+  reset([
+    { rows: [{ id: 7, code: 'CR-FAIL', place_id: 5, host_id: 9, traveler_id: 3, currency: 'USD', subtotal: 10000, fees: 0, platform_fee: 1200, total: 12000, host_payout: 10800, status: 'pending', created_at: new Date().toISOString() }] }, // load booking
+    { rows: [{ email: 'a@b.c', full_name: 'A', phone_e164: '+50611112222' }] }, // load user
+    { rows: [] },                    // no in-flight payment
+    { rows: [{ id: 99 }] },          // pre-charge INSERT ... RETURNING id
+    { rowCount: 1 },                 // the UPDATE ... status='failed'
+  ]);
+  const r = await post('/api/app/bookings/CR-FAIL/pay', { method: 'card' }, token);
+  assert.equal(r.status, 402, 'a declined charge returns 402, not a 200 success');
+  assert.ok(hasSql(/UPDATE payments SET .*status='failed'/), 'the payment row is recorded as failed (literal), not processing');
+  assert.equal(hasSql(/confirmBooking|UPDATE bookings SET status='confirmed'/), false, 'a failed charge never confirms the booking');
+});
+
+test('MONEY: a succeeded createCharge still records + returns 200 (guard did not break the happy path)', async () => {
+  const token = signToken({ sub: 3, role: 'guest' });
+  tilopay.createCharge = async () => ({ providerRef: 'pay_ok', status: 'succeeded', raw: { ok: true }, clientAction: null });
+  reset([
+    { rows: [{ id: 8, code: 'CR-OK', place_id: 5, host_id: 9, traveler_id: 3, currency: 'USD', subtotal: 10000, fees: 0, platform_fee: 1200, total: 12000, host_payout: 10800, status: 'pending', created_at: new Date().toISOString() }] },
+    { rows: [{ email: 'a@b.c', full_name: 'A', phone_e164: '+50611112222' }] },
+    { rows: [] },
+    { rows: [{ id: 100 }] },         // pre-charge INSERT
+    { rowCount: 1 },                 // post-charge UPDATE (status=$2 -> 'succeeded')
+    { rows: [{ id: 8, code: 'CR-OK', currency: 'USD', total: 12000, traveler_id: 3, place_id: 5 }] }, // confirmBooking UPDATE ... RETURNING *
+    { rows: [{ phone_e164: '+50611112222', wa_opt_in: false }] }, // confirmBooking loads user
+    { rows: [{ name: 'Casa' }] },    // confirmBooking loads place
+  ]);
+  const r = await post('/api/app/bookings/CR-OK/pay', { method: 'card' }, token);
+  assert.equal(r.status, 200, 'a succeeded charge returns 200');
+  assert.equal(r.json.status, 'succeeded');
+});
diff --git a/test/payments.test.js b/test/payments.test.js
index 5e5547d..784883d 100644
--- a/test/payments.test.js
+++ b/test/payments.test.js
@@ -88,6 +88,18 @@ test('tilopay: payout (sandbox SINPE) returns a ref and processes', async () =>
   assert.equal(r.status, 'processing');
 });
 
+test('tilopay.mapStatus: terminal-failure + terminal-other statuses resolve correctly (shared by createCharge + getCharge)', () => {
+  // Cody gate, cycle 7: createCharge used to only check j.status==='success',
+  // falling declined/etc through to 'processing' — a synchronous decline (HTTP
+  // 200 body) stranded the booking, mirroring the ONVO fallthrough bug above.
+  // Hoisted so both createCharge and getCharge recognize the same vocabulary.
+  assert.equal(tilopay.mapStatus('success'), 'succeeded');
+  assert.equal(tilopay.mapStatus('pending'), 'processing');
+  assert.equal(tilopay.mapStatus('declined'), 'failed');
+  assert.equal(tilopay.mapStatus('reversed'), 'refunded');
+  assert.equal(tilopay.mapStatus('some_unknown_status'), 'processing'); // safe default
+});
+
 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

← 9b74e96 cycle 6 docs: YOLO_NOTES ledger + GO-LIVE runbook — #4/#5/#6  ·  back to Costa Rica  ·  cycle 7 docs: YOLO_NOTES ledger + GO-LIVE runbook — §5b #2 c f4f8e10 →