← 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
M lib/payments/onvo.jsM lib/payments/tilopay.jsM routes/app.jsA test/createcharge-error-status.test.jsA test/pay-failed-charge.test.jsM test/payments.test.js
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 →