← back to Costa Rica

lib/payments/tilopay.js

166 lines

'use strict';
// Tilopay adapter — Costa Rica native processor.
//   * Accepts international cards in USD AND local cards in CRC.
//   * Native SINPE Movil (both charge and payout).
//   * 3-D Secure via a redirect flow (clientAction.type='redirect').
//   * REST API base https://app.tilopay.com/api/v1 ; auth via API user/password
//     -> bearer token (POST /login). Docs: tilopay.com/documentacion.
//
// SANDBOX-SAFE: with no TILOPAY_* creds set, liveMode=false and every method
// returns a deterministic FAKE result so the whole booking->pay->webhook flow
// is exercisable end-to-end with zero real money and zero live account.
//
// NOTE: exact field names are pinned in the gated go-live memo; the adapter is
// structured so wiring real creds is a config change, not a code rewrite.

const crypto = require('crypto');
const { fetchT } = require('./http'); // live fetches get a timeout (no infinite hang)

const BASE = process.env.TILOPAY_BASE || 'https://app.tilopay.com/api/v1';
const API_USER = process.env.TILOPAY_API_USER || '';
const API_PASS = process.env.TILOPAY_API_PASSWORD || '';
const API_KEY  = process.env.TILOPAY_API_KEY || '';
const WEBHOOK_SECRET = process.env.TILOPAY_WEBHOOK_SECRET || '';
const LIVE = !!(API_USER && API_PASS && API_KEY);

let _token = null, _tokenExp = 0;
async function token() {
  if (!LIVE) return 'sandbox';
  if (_token && Date.now() < _tokenExp) return _token;
  const res = await fetchT(`${BASE}/login`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ apiuser: API_USER, password: API_PASS }),
  });
  if (!res.ok) throw new Error(`tilopay login HTTP ${res.status}`);
  const j = await res.json();
  _token = j.access_token || j.token;
  _tokenExp = Date.now() + 50 * 60 * 1000;
  return _token;
}

function fakeRef(prefix) {
  return `${prefix}_sbx_${crypto.randomBytes(6).toString('hex')}`;
}

async function createCharge({ amount, currency, method = 'card', booking, customer, returnUrl }) {
  if (!LIVE) {
    // Sandbox: card -> a redirect the app "completes"; sinpe -> instructions.
    if (method === 'sinpe') {
      return { providerRef: fakeRef('til'), status: 'requires_action', raw: { sandbox: true },
        clientAction: { type: 'sinpe_instructions', sinpe_phone: '8888-0000',
          note: `SANDBOX: send ${currency} ${(amount/100).toFixed(2)} via SINPE Movil, ref ${booking?.code}` } };
    }
    const ref = fakeRef('til');
    return { providerRef: ref, status: 'requires_action', raw: { sandbox: true },
      clientAction: { type: 'redirect', url: `${returnUrl}?ref=${ref}&sandbox=1&result=success` } };
  }
  const t = await token();
  const res = await fetchT(`${BASE}/processPayment`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY },
    body: JSON.stringify({
      amount: (amount / 100).toFixed(2), currency, paymentMethod: method,
      orderNumber: booking?.code, capture: 1, redirect: returnUrl,
      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();
  // 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: 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));
    return { status: succeeded ? 'succeeded' : 'processing', raw: { sandbox: true } };
  }
  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();
  return { status: mapStatus(j.status), raw: j };
}

async function refund(providerRef, amount) {
  if (!LIVE) return { status: 'refunded', raw: { sandbox: true } };
  const t = await token();
  const res = await fetchT(`${BASE}/refund`, {
    method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY },
    body: JSON.stringify({ paymentId: providerRef, amount: amount != null ? (amount / 100).toFixed(2) : undefined }),
  });
  // FAIL-CLOSED on a body-read timeout: res.ok comes from the (fast) headers, but a
  // stalled body means we never confirmed the refund. Do NOT let a PROVIDER_TIMEOUT
  // get swallowed into raw:{} and read as 'refunded' — mark it failed so it isn't
  // treated as a completed refund. A merely empty/malformed (but fully-received)
  // 200 body is still tolerated, since refund success is HTTP-status-driven.
  let raw;
  try {
    raw = await res.json();
  } catch (e) {
    if (e && e.code === 'PROVIDER_TIMEOUT') return { status: 'failed', raw: { error: e.message } };
    raw = {};
  }
  return { status: res.ok ? 'refunded' : 'failed', raw };
}

// SINPE Movil payout to a host.
async function payout({ method, amount, currency = 'CRC', reference }) {
  if (!LIVE) return { providerRef: fakeRef('pyt'), status: 'processing', raw: { sandbox: true } };
  const t = await token();
  const res = await fetchT(`${BASE}/sinpe/transfer`, {
    method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY },
    body: JSON.stringify({ phone: method.sinpe_phone, amount: (amount / 100).toFixed(2), currency, description: reference }),
  });
  // FAIL-CLOSED on a body-read timeout: THROW so payouts.js's catch marks the
  // payout row 'failed' and surfaces it. Swallowing a PROVIDER_TIMEOUT into raw:{}
  // here returns {providerRef: undefined, status:'processing'} (res.ok is true from
  // the fast headers) -> payouts.js writes a stuck 'processing' row with a NULL
  // provider_ref that can NEVER reconcile, silently, on exactly the header-fast/
  // body-stalled failure this timeout exists to catch. (Cody gate, cycle 6.)
  let j;
  try {
    j = await res.json();
  } catch (e) {
    if (e && e.code === 'PROVIDER_TIMEOUT') throw e;
    j = {}; // tolerate an empty/malformed but fully-received body
  }
  return { providerRef: j.id, status: res.ok ? 'processing' : 'failed', raw: j };
}

function verifyWebhook(headers, rawBody) {
  if (!WEBHOOK_SECRET) return { ok: !LIVE, event: safeParse(rawBody) }; // sandbox: accept
  const sig = headers['x-tilopay-signature'] || headers['tilopay-signature'] || '';
  const expect = crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
  let ok = false;
  try { ok = !!sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect)); } catch { ok = false; }
  return { ok, event: ok ? safeParse(rawBody) : null };
}

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, mapStatus };