← 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');
});