[object Object]

← back to Costa Rica

costa-rica: payment layer — provider abstraction (Tilopay primary + ONVO), money split math, sandbox charge/sinpe/refund/payout; smoke-tested — TK-10346

2a36c950c74475b2719de5f648873f4f3d69b619 · 2026-08-07 09:42:51 -0700 · Steve

Files touched

Diff

commit 2a36c950c74475b2719de5f648873f4f3d69b619
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 09:42:51 2026 -0700

    costa-rica: payment layer — provider abstraction (Tilopay primary + ONVO), money split math, sandbox charge/sinpe/refund/payout; smoke-tested — TK-10346
---
 lib/db.js               |   6 +++
 lib/money.js            |  39 ++++++++++++++++
 lib/payments/index.js   |  30 +++++++++++++
 lib/payments/onvo.js    |  58 ++++++++++++++++++++++++
 lib/payments/tilopay.js | 116 ++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 249 insertions(+)

diff --git a/lib/db.js b/lib/db.js
new file mode 100644
index 0000000..c807fec
--- /dev/null
+++ b/lib/db.js
@@ -0,0 +1,6 @@
+'use strict';
+// Single shared pg pool for server.js + all route/lib modules.
+const { Pool } = require('pg');
+const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+pool.on('error', (err) => console.error('[db] idle client error', err.message));
+module.exports = { pool, query: (t, p) => pool.query(t, p) };
diff --git a/lib/money.js b/lib/money.js
new file mode 100644
index 0000000..6f011cc
--- /dev/null
+++ b/lib/money.js
@@ -0,0 +1,39 @@
+'use strict';
+// Money helpers. Everything is integer MINOR UNITS (USD cents, CRC centimos).
+// The marketplace fee math lives here so bookings + payouts agree to the cent.
+
+const CURRENCIES = { USD: 2, CRC: 2 }; // both use 2 minor digits
+
+function assertCurrency(c) {
+  if (!(c in CURRENCIES)) throw new Error(`unsupported currency ${c}`);
+}
+
+// bps = basis points (100 bps = 1%). platformFeeBps default 1000 = 10%.
+function computeSplit({ subtotal, cleaningFee = 0, currency, platformFeeBps = 1000, processorFeeBps = 0 }) {
+  assertCurrency(currency);
+  const chargeableFees = cleaningFee;                     // fees the guest pays on top of subtotal
+  const platformFee = Math.round((subtotal + chargeableFees) * platformFeeBps / 10000);
+  const total = subtotal + chargeableFees;                // what the traveler is charged
+  const processorFee = Math.round(total * processorFeeBps / 10000);
+  const hostPayout = total - platformFee - processorFee;  // what the host receives
+  return {
+    currency,
+    subtotal,
+    fees: chargeableFees + platformFee,
+    cleaningFee,
+    platformFee,
+    processorFee,
+    total,
+    hostPayout,
+  };
+}
+
+// Nightly booking subtotal from a base nightly price and a date range.
+function nights(checkIn, checkOut) {
+  const a = new Date(checkIn + 'T00:00:00Z'), b = new Date(checkOut + 'T00:00:00Z');
+  return Math.max(0, Math.round((b - a) / 86400000));
+}
+
+const fmt = (minor, currency) => `${currency} ${(minor / 100).toFixed(2)}`;
+
+module.exports = { computeSplit, nights, fmt, assertCurrency, CURRENCIES };
diff --git a/lib/payments/index.js b/lib/payments/index.js
new file mode 100644
index 0000000..c6e0471
--- /dev/null
+++ b/lib/payments/index.js
@@ -0,0 +1,30 @@
+'use strict';
+// Payment provider abstraction. CR-native processors only (no Stripe): they
+// accept international USD cards AND local CRC + SINPE Movil in one integration.
+//
+// A provider implements this interface:
+//   name
+//   liveMode                       -> bool (false = sandbox/test)
+//   createCharge({ amount, currency, method, booking, customer, returnUrl })
+//        -> { providerRef, status, clientAction, raw }
+//        status: 'requires_action' | 'succeeded' | 'processing' | 'failed'
+//        clientAction: { type:'redirect'|'sinpe_instructions'|'sdk', url?, ... }
+//   getCharge(providerRef)         -> { status, raw }
+//   refund(providerRef, amount?)   -> { status, raw }
+//   payout({ method, amount, currency, reference }) -> { providerRef, status, raw }  (SINPE)
+//   verifyWebhook(headers, rawBody)-> { ok, event }   // HMAC / signature check
+//
+// Swap Tilopay<->ONVO by PAYMENT_PROVIDER env with zero call-site changes.
+
+const tilopay = require('./tilopay');
+const onvo = require('./onvo');
+
+const REGISTRY = { tilopay, onvo };
+
+function getProvider(name = process.env.PAYMENT_PROVIDER || 'tilopay') {
+  const p = REGISTRY[name];
+  if (!p) throw new Error(`unknown payment provider ${name}`);
+  return p;
+}
+
+module.exports = { getProvider, REGISTRY };
diff --git a/lib/payments/onvo.js b/lib/payments/onvo.js
new file mode 100644
index 0000000..633c14e
--- /dev/null
+++ b/lib/payments/onvo.js
@@ -0,0 +1,58 @@
+'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 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 fetch(`${BASE}/payment-intents`, {
+    method: 'POST', headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ amount, currency, description: booking?.code, redirectUrl: returnUrl }),
+  });
+  const j = await res.json();
+  return { providerRef: j.id, status: j.nextAction ? 'requires_action' : 'processing', raw: j,
+    clientAction: j.nextAction?.redirectUrl ? { type: 'redirect', url: j.nextAction.redirectUrl } : null };
+}
+async function getCharge(ref) {
+  if (!LIVE) return { status: /_sbx_|success/.test(String(ref)) ? 'succeeded' : 'processing', raw: { sandbox: true } };
+  const res = await fetch(`${BASE}/payment-intents/${ref}`, { headers: { Authorization: `Bearer ${SECRET}` } });
+  const j = await res.json();
+  const map = { succeeded: 'succeeded', processing: 'processing', requires_action: 'processing', canceled: 'failed' };
+  return { status: map[j.status] || 'processing', raw: j };
+}
+async function refund(ref, amount) {
+  if (!LIVE) return { status: 'refunded', raw: { sandbox: true } };
+  const res = await fetch(`${BASE}/refunds`, { method: 'POST',
+    headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ paymentIntentId: ref, amount }) });
+  return { status: res.ok ? 'refunded' : 'failed', raw: await res.json().catch(() => ({})) };
+}
+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');
+  const ok = sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect));
+  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; }, createCharge, getCharge, refund, payout, verifyWebhook };
diff --git a/lib/payments/tilopay.js b/lib/payments/tilopay.js
new file mode 100644
index 0000000..10ae0c7
--- /dev/null
+++ b/lib/payments/tilopay.js
@@ -0,0 +1,116 @@
+'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 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 fetch(`${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 fetch(`${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,
+    }),
+  });
+  const j = await res.json();
+  const status = j.url ? 'requires_action' : (j.status === 'success' ? 'succeeded' : 'processing');
+  return { providerRef: j.paymentId || j.id, status, raw: j,
+    clientAction: j.url ? { type: 'redirect', url: j.url } : null };
+}
+
+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 fetch(`${BASE}/payment/${providerRef}`, { headers: { Authorization: `Bearer ${t}`, 'X-Api-Key': API_KEY } });
+  const j = await res.json();
+  const map = { success: 'succeeded', pending: 'processing', declined: 'failed', reversed: 'refunded' };
+  return { status: map[j.status] || 'processing', raw: j };
+}
+
+async function refund(providerRef, amount) {
+  if (!LIVE) return { status: 'refunded', raw: { sandbox: true } };
+  const t = await token();
+  const res = await fetch(`${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 }),
+  });
+  return { status: res.ok ? 'refunded' : 'failed', raw: await res.json().catch(() => ({})) };
+}
+
+// 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 fetch(`${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 }),
+  });
+  const j = await res.json().catch(() => ({}));
+  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');
+  const ok = sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect));
+  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; }, createCharge, getCharge, refund, payout, verifyWebhook };

← aea4cf6 costa-rica: OSM Overpass fetch (3257 CR POIs) feeding cr-osm  ·  back to Costa Rica  ·  costa-rica: add GOOGLE_PLACES_API_KEY to .env.example; launc 098634c →