← 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
A lib/db.jsA lib/money.jsA lib/payments/index.jsA lib/payments/onvo.jsA lib/payments/tilopay.js
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 →