← back to Costa Rica

lib/payments/onvo.js

107 lines

'use strict';
// ONVO Pay adapter — Costa Rica native processor, Stripe-like API.
// Swappable alternate to Tilopay (set PAYMENT_PROVIDER=onvo). Implements the
// same interface. Sandbox-safe with no creds. Base https://api.onvopay.com/v1.
// Full method bodies mirror Tilopay; live wiring pinned in the go-live memo.

const crypto = require('crypto');
const { fetchT } = require('./http'); // live fetches get a timeout (no infinite hang)
const BASE = process.env.ONVO_BASE || 'https://api.onvopay.com/v1';
const SECRET = process.env.ONVO_SECRET_KEY || '';
const WEBHOOK_SECRET = process.env.ONVO_WEBHOOK_SECRET || '';
const LIVE = !!SECRET;
const fakeRef = (p) => `${p}_sbx_${crypto.randomBytes(6).toString('hex')}`;

async function createCharge({ amount, currency, method = 'card', booking, returnUrl }) {
  if (!LIVE) {
    const ref = fakeRef('onv');
    return { providerRef: ref, status: 'requires_action', raw: { sandbox: true },
      clientAction: method === 'sinpe'
        ? { type: 'sinpe_instructions', sinpe_phone: '8888-0000', note: `SANDBOX ${booking?.code}` }
        : { type: 'redirect', url: `${returnUrl}?ref=${ref}&sandbox=1&result=success` } };
  }
  const res = await fetchT(`${BASE}/payment-intents`, {
    method: 'POST', headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount, currency, description: booking?.code, redirectUrl: returnUrl }),
  });
  // FAIL-CLOSED on a live error status (4xx/5xx) — see tilopay.js createCharge. An
  // error body has no id, so {providerRef: j.id, status: 'processing'} would strand
  // the booking. Return 'failed'; body read is best-effort diagnostics only.
  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 (Stripe-like intent creation can return HTTP 200 with
  // status:'requires_payment_method'/'canceled'/etc — the same terminal-failure
  // vocabulary STATUS_MAP below already maps to 'failed' for getCharge polling)
  // must be caught here too, not just the transport-level !res.ok above — a
  // fallthrough to 'processing' is the exact stuck-booking bug this ticket is
  // about, just via the body instead of the HTTP status. (Cody gate, cycle 7.)
  const mapped = mapStatus(j.status);
  const status = mapped === 'failed' ? 'failed' : (j.nextAction ? 'requires_action' : mapped);
  return { providerRef: j.id, status, raw: j,
    clientAction: status === 'failed' ? null : (j.nextAction?.redirectUrl ? { type: 'redirect', url: j.nextAction.redirectUrl } : null) };
}
// Map an ONVO payment-intent status to our canonical charge status. Terminal
// failure states (canceled/declined/failed) MUST resolve to 'failed' — a
// fallthrough to 'processing' would trap a declined booking in limbo forever.
// 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.
// NB: 'refunded'/'reversed' map to 'refunded' so a refunded-charge status from
// getCharge reaches the webhook's refund branch — tilopay.js already does this
// ('reversed'->'refunded') and WITHOUT it an ONVO refund silently mapped to
// 'processing' (the || default), regressing payments.status + never flipping the
// booking to refunded. (Cody webhook-state-machine audit, cycle 29.)
// ⚠ GO-LIVE GATE: ONVO's EXACT post-refund vocabulary is UNVERIFIED here. If ONVO
// is Stripe-like, the payment-intent status may STAY 'succeeded' after a refund
// (refund tracked as a separate object / a charge.refunded flag), in which case a
// status-map key is insufficient and getCharge must inspect the refund object.
// Verify against ONVO's real API BEFORE flipping PAYMENT_PROVIDER=onvo live — see
// docs/GO-LIVE.md.
const STATUS_MAP = { succeeded: 'succeeded', processing: 'processing',
  requires_action: 'processing', requires_payment_method: 'failed',
  canceled: 'failed', declined: 'failed', failed: 'failed',
  refunded: 'refunded', reversed: 'refunded' };
function mapStatus(s) { return STATUS_MAP[s] || 'processing'; }

async function getCharge(ref) {
  if (!LIVE) return { status: /_sbx_|success/.test(String(ref)) ? 'succeeded' : 'processing', raw: { sandbox: true } };
  const res = await fetchT(`${BASE}/payment-intents/${ref}`, { headers: { Authorization: `Bearer ${SECRET}` } });
  const j = await res.json();
  return { status: mapStatus(j.status), raw: j };
}
async function refund(ref, amount) {
  if (!LIVE) return { status: 'refunded', raw: { sandbox: true } };
  const res = await fetchT(`${BASE}/refunds`, { method: 'POST',
    headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ paymentIntentId: ref, amount }) });
  // FAIL-CLOSED on a body-read timeout (see tilopay.js refund): a stalled body must
  // not be swallowed into raw:{} and read as 'refunded'. A merely empty/malformed
  // (fully-received) 200 body is still tolerated — refund success is HTTP-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 };
}
async function payout({ method, amount, currency = 'CRC', reference }) {
  if (!LIVE) return { providerRef: fakeRef('pyt'), status: 'processing', raw: { sandbox: true } };
  // ONVO SINPE payout endpoint pinned in go-live memo.
  return { providerRef: null, status: 'failed', raw: { error: 'onvo payout not wired' } };
}
function verifyWebhook(headers, rawBody) {
  if (!WEBHOOK_SECRET) return { ok: !LIVE, event: safeParse(rawBody) };
  const sig = headers['onvo-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: 'onvo', get liveMode() { return LIVE; }, get webhookSecretSet() { return !!WEBHOOK_SECRET; }, createCharge, getCharge, refund, payout, verifyWebhook, mapStatus };