← back to Costa Rica

test/createcharge-error-status.test.js

149 lines

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