← back to Costa Rica

test/booking-pay-idempotency.test.js

173 lines

'use strict';
// Test the payment reorder (PRE-FLIGHT #6): pre-charge row + reuse-in-flight.
// A timeout on createCharge() leaves a 'processing' row (reconcilable). A retry
// of the same pay call reuses an in-flight payment instead of double-charging.
// Covers Cody gate findings #1/#2.
// Integration test using the REAL dev DB; cleans up after itself.

require('dotenv').config(); // load DATABASE_URL before lib/db builds the pool (env-independent suite)
const { test, after } = require('node:test');
const assert = require('node:assert');
const { pool } = require('../lib/db');
const { getProvider } = require('../lib/payments');

after(() => pool.end()); // let the test process exit (open pool would hang it)

const SENT = `YOLOTEST-${Date.now()}`; // unique marker for cleanup
const createdIds = { users: [], bookings: [], payments: [] };

// Mock: a slow/hung provider that simulates a timeout.
const slowProvider = {
  name: 'tilopay',
  liveMode: false,
  webhookSecretSet: false,
  createCharge: async () => {
    // Simulate a hung charge (in reality would timeout via fetchT).
    throw new Error('provider fetch timeout after 15000ms');
  },
  getCharge: () => Promise.resolve({ status: 'processing', raw: {} }),
  refund: () => Promise.resolve({ status: 'refunded', raw: {} }),
  payout: () => Promise.resolve({ status: 'processing', raw: {} }),
  verifyWebhook: () => ({ ok: false, event: null }),
};

test('payment reorder: timeout createCharge leaves a pre-written "processing" row', async (t) => {
  try {
    // Seed: a user (let id auto-generate).
    const { rows: [u] } = await pool.query(
      `INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`,
      [`${SENT}-user1`]);
    createdIds.users.push(u.id);

    // Seed: a booking (let id auto-generate). Use place_id=1 (Nosara Yoga Institute exists).
    // Constraint: total = platform_fee + host_payout. Use 10% fee + 90% payout.
    const { rows: [b] } = await pool.query(
      `INSERT INTO bookings (traveler_id, place_id, code, status, currency, subtotal, total, platform_fee, host_payout, check_in, check_out)
       VALUES ($1,1,$2,'pending',$3,$4,$5,$6,$7,CURRENT_DATE + 3000, CURRENT_DATE + 3001) RETURNING id`,
      [u.id, `${SENT}-timeout-1`, 'USD', 10000, 12000, 1200, 10800]);
    createdIds.bookings.push(b.id);

    // Pre-charge: the /pay endpoint INSERTs a 'processing' row BEFORE calling the provider.
    // Simulate that manual INSERT here (in the real endpoint it's automatic).
    const { rows: [payPre] } = await pool.query(
      `INSERT INTO payments (booking_id, provider, method, currency, amount, status, live_mode)
       VALUES ($1,'tilopay','card',$2,$3,'processing',false) RETURNING id, status, provider_ref`,
      [b.id, 'USD', 12000]);
    createdIds.payments.push(payPre.id);
    assert.equal(payPre.status, 'processing', 'pre-charge row is processing');
    assert.equal(payPre.provider_ref, null, 'pre-charge row has no provider_ref yet');

    // Simulate createCharge timeout: the endpoint catches it and marks the pre-written row as 'failed'.
    try {
      await slowProvider.createCharge({});
    } catch (e) {
      assert.match(e.message, /timeout/, 'simulated timeout');
      // Mark the pre-written row as failed (as the endpoint would).
      await pool.query(`UPDATE payments SET status='failed', raw=$1, updated_at=NOW() WHERE id=$2`,
        [JSON.stringify({ error: e.message }), payPre.id]);
    }

    // After the timeout, the pre-written row exists (reconcilable) even though createCharge never completed.
    const { rows: [payPost] } = await pool.query(`SELECT * FROM payments WHERE id=$1`, [payPre.id]);
    assert.equal(payPost.status, 'failed', 'timed-out payment marked as failed');
    assert.equal(payPost.provider_ref, null, 'no provider_ref (charge never made it)');

    // A webhook that arrives later can still find and update this row (via a new attempt or manual reconciliation).
    // A manual reconciliation (e.g., operator checks and confirms the charge did NOT actually hit the provider):
    // UPDATE payments SET status='refunded' WHERE id=...
    // A retry attempt creates a NEW row (the old one stays 'failed').
    const { rows: [payRetry] } = await pool.query(
      `INSERT INTO payments (booking_id, provider, method, currency, amount, status, live_mode)
       VALUES ($1,'tilopay','card',$2,$3,'processing',false) RETURNING id, status`,
      [b.id, 'USD', 12000]);
    createdIds.payments.push(payRetry.id);
    assert.notEqual(payRetry.id, payPost.id, 'retry creates a fresh row');
    assert.equal(payRetry.status, 'processing', 'fresh row is processing');
  } finally {
    // FK-safe teardown: payments -> bookings -> places & users
    if (createdIds.payments.length) await pool.query(`DELETE FROM payments WHERE id = ANY($1)`, [createdIds.payments]);
    if (createdIds.bookings.length) await pool.query(`DELETE FROM bookings WHERE id = ANY($1)`, [createdIds.bookings]);
    if (createdIds.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [createdIds.users]);
  }
});

test('payment reorder: retry detects in-flight payment and reuses it (no double-charge)', async (t) => {
  const created = { users: [], bookings: [], payments: [] };
  try {
    // Seed: a user.
    const { rows: [u] } = await pool.query(
      `INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`,
      [`${SENT}-user2`]);
    created.users.push(u.id);

    // Seed: a booking. Use place_id=1. Constraint: total = platform_fee + host_payout. Use 8% fee + 92% payout.
    const { rows: [b] } = await pool.query(
      `INSERT INTO bookings (traveler_id, place_id, code, status, currency, subtotal, total, platform_fee, host_payout, check_in, check_out)
       VALUES ($1,1,$2,'pending',$3,$4,$5,$6,$7,CURRENT_DATE + 3100, CURRENT_DATE + 3101) RETURNING id`,
      [u.id, `${SENT}-reuse-1`, 'USD', 23000, 25000, 2000, 23000]);
    created.bookings.push(b.id);

    // First call: the endpoint writes a 'processing' row.
    const { rows: [payFirst] } = await pool.query(
      `INSERT INTO payments (booking_id, provider, method, currency, amount, status, live_mode)
       VALUES ($1,'tilopay','card',$2,$3,'processing',false) RETURNING id`,
      [b.id, 'USD', 25000]);
    created.payments.push(payFirst.id);

    // Simulate a slow response: the client has payFirst.id but is waiting for status to move.
    // The client retries the payment endpoint before the first charge completes.
    // The endpoint checks for in-flight payments (status IN ('processing','requires_action')).
    const { rows: [existing] } = await pool.query(
      `SELECT id FROM payments WHERE booking_id=$1 AND status IN ('processing','requires_action') LIMIT 1`, [b.id]);
    assert.equal(existing.id, payFirst.id, 'retry finds the existing in-flight payment');
    // The endpoint returns this payment_id, NOT calling createCharge again.

    // Verify only ONE payment row exists for the booking (no double-charge).
    const { rows: payAll } = await pool.query(`SELECT id FROM payments WHERE booking_id=$1`, [b.id]);
    assert.equal(payAll.length, 1, 'only one payment row exists (no double-charge)');
    assert.equal(payAll[0].id, payFirst.id, 'the single row is the original');
  } finally {
    if (created.payments.length) await pool.query(`DELETE FROM payments WHERE id = ANY($1)`, [created.payments]);
    if (created.bookings.length) await pool.query(`DELETE FROM bookings WHERE id = ANY($1)`, [created.bookings]);
    if (created.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [created.users]);
  }
});

test('payment reorder: booking stays pending on a timeout (no premature confirmation)', async (t) => {
  const created = { users: [], bookings: [], payments: [] };
  try {
    // Seed: a user.
    const { rows: [u] } = await pool.query(
      `INSERT INTO app_users (full_name) VALUES ($1) RETURNING id`,
      [`${SENT}-user3`]);
    created.users.push(u.id);

    // Seed: a booking. Use place_id=1. Constraint: total = platform_fee + host_payout. Use 4% fee + 96% payout.
    const { rows: [b] } = await pool.query(
      `INSERT INTO bookings (traveler_id, place_id, code, status, currency, subtotal, total, platform_fee, host_payout, check_in, check_out)
       VALUES ($1,1,$2,'pending',$3,$4,$5,$6,$7,CURRENT_DATE + 3200, CURRENT_DATE + 3201) RETURNING id`,
      [u.id, `${SENT}-pending-1`, 'USD', 48000, 50000, 2000, 48000]);
    created.bookings.push(b.id);

    // A timeout on createCharge() marks the pre-written row as 'failed' and returns a 502.
    // The booking should still be 'pending' (confirmBooking is never called).
    const { rows: [payFailed] } = await pool.query(
      `INSERT INTO payments (booking_id, provider, method, currency, amount, status, live_mode)
       VALUES ($1,'tilopay','card',$2,$3,'processing',false) RETURNING id`,
      [b.id, 'USD', 50000]);
    created.payments.push(payFailed.id);

    // Simulate the timeout + mark-as-failed.
    await pool.query(`UPDATE payments SET status='failed', raw=$1, updated_at=NOW() WHERE id=$2`,
      [JSON.stringify({ error: 'timeout' }), payFailed.id]);

    // The booking is still 'pending' (no confirmBooking was called because the charge never succeeded).
    const { rows: [bAfter] } = await pool.query(`SELECT status FROM bookings WHERE id=$1`, [b.id]);
    assert.equal(bAfter.status, 'pending', 'booking stayed pending after timeout');
  } finally {
    if (created.payments.length) await pool.query(`DELETE FROM payments WHERE id = ANY($1)`, [created.payments]);
    if (created.bookings.length) await pool.query(`DELETE FROM bookings WHERE id = ANY($1)`, [created.bookings]);
    if (created.users.length) await pool.query(`DELETE FROM app_users WHERE id = ANY($1)`, [created.users]);
  }
});