← back to Costa Rica
test/pay-failed-charge.test.js
129 lines
'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 }); if (responses.length) { const r = responses.shift(); if (r instanceof Error) throw r; return r; } return { 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');
});
function dupErr() {
const e = new Error('duplicate key value violates unique constraint "payments_one_inflight_per_booking"');
e.code = '23505'; e.constraint = 'payments_one_inflight_per_booking';
return e;
}
const RACE_BOOKING = { rows: [{ id: 7, code: 'CR-RACE', 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() }] };
const RACE_USER = { rows: [{ email: 'a@b.c', full_name: 'A', phone_e164: '+50611112222' }] };
test('RACE: a concurrent /pay whose pre-charge INSERT loses the unique index (23505) REUSES the winner (no 2nd charge)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
let chargeCalls = 0;
tilopay.createCharge = async () => { chargeCalls++; return { providerRef: 'x', status: 'requires_action', raw: {}, clientAction: null }; };
reset([
RACE_BOOKING, RACE_USER,
{ rows: [] }, // inflight() fast-path: saw none (raced past the SELECT)
dupErr(), // pre-charge INSERT -> 23505 (the other request won the slot)
{ rows: [{ id: 88, status: 'processing' }] }, // catch re-SELECT: the winner's still-in-flight payment
]);
const r = await post('/api/app/bookings/CR-RACE/pay', { method: 'card' }, token);
assert.equal(r.status, 200, 'the race-loser gets a clean response, not a 500');
assert.equal(r.json.payment_id, 88, 'it reuses the winner payment');
assert.equal(r.json.status, 'processing');
assert.equal(chargeCalls, 0, 'the race-loser must NOT call createCharge — no second charge on the card');
});
test('RACE: if the winner ALREADY RESOLVED (fast succeed) before the loser catches, the loser still returns it (not a 500)', async () => {
const token = signToken({ sub: 3, role: 'guest' });
let chargeCalls = 0;
tilopay.createCharge = async () => { chargeCalls++; return { providerRef: 'x', status: 'succeeded', raw: {}, clientAction: null }; };
reset([
RACE_BOOKING, RACE_USER,
{ rows: [] }, // inflight() fast-path: none
dupErr(), // INSERT -> 23505
// catch re-SELECT (no status filter): the winner already flipped to 'succeeded'.
// An in-flight-ONLY lookup would miss this and 500 a traveler whose payment DID go through.
{ rows: [{ id: 88, status: 'succeeded' }] },
]);
const r = await post('/api/app/bookings/CR-RACE/pay', { method: 'card' }, token);
assert.equal(r.status, 200, 'a resolved winner must NOT 500 the loser');
assert.equal(r.json.payment_id, 88);
assert.equal(r.json.status, 'succeeded', 'the loser sees the winner\'s real (resolved) status to poll on');
assert.equal(chargeCalls, 0, 'still no second charge');
});
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');
});