← back to Costa Rica
costa-rica: marketplace API — JWT auth, listings, bookings, payments, host onboarding (SINPE/Plaid), payment+whatsapp webhooks; wired pre-basic-auth; E2E sandbox flow verified (book->pay->confirm->wa) — TK-10346
a1c238c12d78da5bb700ec47c0608e7c3d6e75a3 · 2026-08-07 09:51:43 -0700 · Steve
Files touched
A lib/auth.jsA lib/payouts.jsA lib/plaid.jsA lib/whatsapp.jsA routes/app.jsA routes/webhooks.jsM server.js
Diff
commit a1c238c12d78da5bb700ec47c0608e7c3d6e75a3
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 7 09:51:43 2026 -0700
costa-rica: marketplace API — JWT auth, listings, bookings, payments, host onboarding (SINPE/Plaid), payment+whatsapp webhooks; wired pre-basic-auth; E2E sandbox flow verified (book->pay->confirm->wa) — TK-10346
---
lib/auth.js | 62 +++++++++++
lib/payouts.js | 52 ++++++++++
lib/plaid.js | 48 +++++++++
lib/whatsapp.js | 156 ++++++++++++++++++++++++++++
routes/app.js | 297 +++++++++++++++++++++++++++++++++++++++++++++++++++++
routes/webhooks.js | 79 ++++++++++++++
server.js | 15 +++
7 files changed, 709 insertions(+)
diff --git a/lib/auth.js b/lib/auth.js
new file mode 100644
index 0000000..c8c0a6d
--- /dev/null
+++ b/lib/auth.js
@@ -0,0 +1,62 @@
+'use strict';
+// Zero-dependency auth: HMAC-SHA256 JWTs + scrypt password hashing, both from
+// Node's built-in crypto. Tokens carry { sub, role, host_id }.
+const crypto = require('crypto');
+
+const SECRET = process.env.JWT_SECRET || process.env.BASIC_AUTH_PASS || 'dev-insecure-secret-change-me';
+const TTL_SEC = parseInt(process.env.JWT_TTL_SEC || String(60 * 60 * 24 * 30), 10); // 30d
+
+const b64u = (buf) => Buffer.from(buf).toString('base64url');
+const b64uJson = (o) => b64u(JSON.stringify(o));
+
+function signToken(payload, ttl = TTL_SEC) {
+ const header = { alg: 'HS256', typ: 'JWT' };
+ const now = Math.floor(Date.now() / 1000);
+ const body = { iat: now, exp: now + ttl, ...payload };
+ const data = `${b64uJson(header)}.${b64uJson(body)}`;
+ const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
+ return `${data}.${sig}`;
+}
+
+function verifyToken(token) {
+ if (!token) return null;
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ const [h, p, sig] = parts;
+ const expect = crypto.createHmac('sha256', SECRET).update(`${h}.${p}`).digest('base64url');
+ try {
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
+ } catch { return null; }
+ let body; try { body = JSON.parse(Buffer.from(p, 'base64url').toString()); } catch { return null; }
+ if (body.exp && Math.floor(Date.now() / 1000) > body.exp) return null;
+ return body;
+}
+
+// scrypt password hashing (salt$hash)
+function hashPassword(pw) {
+ const salt = crypto.randomBytes(16);
+ const hash = crypto.scryptSync(pw, salt, 32);
+ return `${salt.toString('hex')}$${hash.toString('hex')}`;
+}
+function verifyPassword(pw, stored) {
+ if (!stored || !stored.includes('$')) return false;
+ const [saltHex, hashHex] = stored.split('$');
+ const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 32);
+ try { return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')); } catch { return false; }
+}
+
+// Express middleware
+function authRequired(req, res, next) {
+ const t = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
+ const claims = verifyToken(t);
+ if (!claims) return res.status(401).json({ error: 'unauthorized' });
+ req.user = claims;
+ next();
+}
+function optionalAuth(req, _res, next) {
+ const t = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
+ req.user = verifyToken(t) || null;
+ next();
+}
+
+module.exports = { signToken, verifyToken, hashPassword, verifyPassword, authRequired, optionalAuth };
diff --git a/lib/payouts.js b/lib/payouts.js
new file mode 100644
index 0000000..b900dda
--- /dev/null
+++ b/lib/payouts.js
@@ -0,0 +1,52 @@
+'use strict';
+// Payout engine — settles a host's earnings after a booking completes.
+// Routes by payout_method.kind:
+// sinpe_movil / cr_iban -> SINPE via the payment provider (Tilopay) [rail=sinpe]
+// plaid_ach -> ACH via Plaid [rail=plaid_ach]
+// Records every attempt in the payouts table with live_mode + provider_ref.
+
+const { pool } = require('./db');
+const { getProvider } = require('./payments');
+
+async function createPayoutForBooking(bookingId) {
+ const { rows: [b] } = await pool.query(`SELECT * FROM bookings WHERE id=$1`, [bookingId]);
+ if (!b) throw new Error('booking not found');
+ if (!b.host_id) throw new Error('booking has no host');
+ if (b.status !== 'completed') throw new Error(`booking not completed (${b.status})`);
+
+ const { rows: [pm] } = await pool.query(
+ `SELECT pm.* FROM payout_methods pm
+ JOIN hosts h ON h.id = pm.host_id
+ WHERE pm.host_id = $1 AND (pm.is_default OR h.default_payout_method_id = pm.id)
+ ORDER BY pm.is_default DESC LIMIT 1`, [b.host_id]);
+ if (!pm) throw new Error('host has no payout method');
+
+ const rail = pm.kind === 'plaid_ach' ? 'plaid_ach' : 'sinpe';
+ const provider = getProvider();
+ const amount = b.host_payout;
+ const currency = b.currency;
+
+ const { rows: [payout] } = await pool.query(
+ `INSERT INTO payouts (host_id, booking_id, payout_method_id, rail, currency, amount, status, live_mode)
+ VALUES ($1,$2,$3,$4,$5,$6,'processing',$7) RETURNING *`,
+ [b.host_id, b.id, pm.id, rail, currency, amount, provider.liveMode]);
+
+ let result;
+ try {
+ if (rail === 'sinpe') {
+ result = await provider.payout({ method: { sinpe_phone: pm.sinpe_phone, cr_iban: pm.cr_iban }, amount, currency, reference: b.code });
+ } else {
+ // Plaid ACH payout — provider/ledger wiring pinned in go-live memo; sandbox records intent.
+ result = { providerRef: `ach_sbx_${payout.id}`, status: 'processing', raw: { rail: 'plaid_ach', sandbox: !provider.liveMode } };
+ }
+ await pool.query(`UPDATE payouts SET provider_ref=$1, status=$2, raw=$3 WHERE id=$4`,
+ [result.providerRef || null, result.status || 'processing', JSON.stringify(result.raw || {}), payout.id]);
+ } catch (e) {
+ await pool.query(`UPDATE payouts SET status='failed', raw=$1 WHERE id=$2`,
+ [JSON.stringify({ error: String(e.message) }), payout.id]);
+ throw e;
+ }
+ return { ...payout, ...result };
+}
+
+module.exports = { createPayoutForBooking };
diff --git a/lib/plaid.js b/lib/plaid.js
new file mode 100644
index 0000000..c29c92a
--- /dev/null
+++ b/lib/plaid.js
@@ -0,0 +1,48 @@
+'use strict';
+// Plaid client — bank-account verification + ACH for FOREIGN/US hosts only.
+// (Costa Rican banks are NOT in Plaid's network — Tico hosts use SINPE, see
+// lib/payouts.js.) Sandbox-safe: no PLAID creds => liveMode=false and Link
+// token / exchange return deterministic sandbox values.
+//
+// Env (gated): PLAID_CLIENT_ID, PLAID_SECRET, PLAID_ENV (sandbox|production)
+
+const crypto = require('crypto');
+const CLIENT_ID = process.env.PLAID_CLIENT_ID || '';
+const SECRET = process.env.PLAID_SECRET || '';
+const ENV = process.env.PLAID_ENV || 'sandbox';
+const LIVE = !!(CLIENT_ID && SECRET);
+const BASE = `https://${ENV}.plaid.com`;
+
+async function _post(path, body) {
+ const res = await fetch(BASE + path, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: CLIENT_ID, secret: SECRET, ...body }),
+ });
+ const j = await res.json();
+ if (!res.ok) throw new Error(`plaid ${path} HTTP ${res.status}: ${j.error_code || ''}`);
+ return j;
+}
+
+// Step 1 (app): create a Link token to open Plaid Link in the app.
+async function createLinkToken(userId) {
+ if (!LIVE) return { link_token: `link-sandbox-${crypto.randomBytes(6).toString('hex')}`, sandbox: true };
+ return _post('/link/token/create', {
+ user: { client_user_id: String(userId) },
+ client_name: 'Costa Rica Marketplace',
+ products: ['auth'], country_codes: ['US'], language: 'en',
+ });
+}
+
+// Step 2 (app -> server): exchange the public_token for a persistent access_token.
+async function exchangePublicToken(publicToken) {
+ if (!LIVE) return { access_token: `access-sandbox-${crypto.randomBytes(8).toString('hex')}`, item_id: `item-${crypto.randomBytes(6).toString('hex')}`, sandbox: true };
+ return _post('/item/public_token/exchange', { public_token: publicToken });
+}
+
+// Fetch the ACH numbers/account so we can store last4 + verified flag.
+async function getAuth(accessToken) {
+ if (!LIVE) return { accounts: [{ account_id: 'acc_sbx', mask: '4321' }], sandbox: true };
+ return _post('/auth/get', { access_token: accessToken });
+}
+
+module.exports = { get liveMode() { return LIVE; }, ENV, createLinkToken, exchangePublicToken, getAuth };
diff --git a/lib/whatsapp.js b/lib/whatsapp.js
new file mode 100644
index 0000000..9f21fe3
--- /dev/null
+++ b/lib/whatsapp.js
@@ -0,0 +1,156 @@
+'use strict';
+// WhatsApp — Meta Cloud API direct client + webhook helpers.
+// Full message surface: text, template, interactive (buttons/list), media
+// (image/document), location, reaction, and typing/read receipts. Persists
+// every in/out message to whatsapp_messages and tracks the 24h session window.
+//
+// SANDBOX-SAFE: with no WHATSAPP_TOKEN set, liveMode=false and sends are
+// simulated (logged + persisted, no Meta call) so booking notifications are
+// exercisable before the WABA number is provisioned.
+//
+// Env (all gated in the go-live memo):
+// WHATSAPP_TOKEN permanent system-user token
+// WHATSAPP_PHONE_ID phone number id (sender)
+// WHATSAPP_VERIFY_TOKEN webhook GET verification token
+// WHATSAPP_APP_SECRET for X-Hub-Signature-256 verification
+// WHATSAPP_API_VERSION default v21.0
+
+const crypto = require('crypto');
+const { pool } = require('./db');
+
+const TOKEN = process.env.WHATSAPP_TOKEN || '';
+const PHONE_ID = process.env.WHATSAPP_PHONE_ID || '';
+const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN || 'cr-verify-sandbox';
+const APP_SECRET = process.env.WHATSAPP_APP_SECRET || '';
+const VER = process.env.WHATSAPP_API_VERSION || 'v21.0';
+const LIVE = !!(TOKEN && PHONE_ID);
+
+const GRAPH = (path) => `https://graph.facebook.com/${VER}/${path}`;
+
+async function _send(payload) {
+ if (!LIVE) {
+ return { messages: [{ id: `wamid.SBX_${crypto.randomBytes(8).toString('hex')}` }], sandbox: true };
+ }
+ const res = await fetch(GRAPH(`${PHONE_ID}/messages`), {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),
+ });
+ const j = await res.json();
+ if (!res.ok) throw new Error(`wa send HTTP ${res.status}: ${JSON.stringify(j).slice(0, 300)}`);
+ return j;
+}
+
+// --- persistence -----------------------------------------------------------
+async function contactByWaId(waId, profileName) {
+ const { rows } = await pool.query(
+ `INSERT INTO whatsapp_contacts (wa_id, profile_name) VALUES ($1,$2)
+ ON CONFLICT (wa_id) DO UPDATE SET profile_name=COALESCE(EXCLUDED.profile_name, whatsapp_contacts.profile_name)
+ RETURNING *`, [waId, profileName || null]);
+ return rows[0];
+}
+async function logMessage({ waMessageId, contactId, bookingId, direction, msgType, body, payload, status }) {
+ await pool.query(
+ `INSERT INTO whatsapp_messages (wa_message_id, contact_id, booking_id, direction, msg_type, body, payload, status)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (wa_message_id) DO NOTHING`,
+ [waMessageId || null, contactId || null, bookingId || null, direction, msgType, body || null,
+ payload ? JSON.stringify(payload) : null, status || null]);
+}
+
+// --- outbound message types ------------------------------------------------
+async function sendText(to, text, { bookingId, previewUrl = false } = {}) {
+ const r = await _send({ to, type: 'text', text: { body: text, preview_url: previewUrl } });
+ const c = await contactByWaId(to);
+ await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'text', body: text, payload: r });
+ return r;
+}
+// Template = the ONLY thing allowed outside the 24h session window (must be pre-approved in Meta).
+async function sendTemplate(to, name, langCode = 'es', components = [], { bookingId } = {}) {
+ const r = await _send({ to, type: 'template', template: { name, language: { code: langCode }, components } });
+ const c = await contactByWaId(to);
+ await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'template', body: name, payload: r });
+ return r;
+}
+async function sendButtons(to, bodyText, buttons, { bookingId, header, footer } = {}) {
+ const action = { buttons: buttons.slice(0, 3).map((b, i) => ({ type: 'reply', reply: { id: b.id || `btn_${i}`, title: b.title.slice(0, 20) } })) };
+ const interactive = { type: 'button', body: { text: bodyText }, action };
+ if (header) interactive.header = { type: 'text', text: header };
+ if (footer) interactive.footer = { text: footer };
+ const r = await _send({ to, type: 'interactive', interactive });
+ const c = await contactByWaId(to);
+ await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'interactive', body: bodyText, payload: r });
+ return r;
+}
+async function sendList(to, bodyText, buttonLabel, sections, { bookingId, header } = {}) {
+ const interactive = { type: 'list', body: { text: bodyText }, action: { button: buttonLabel, sections } };
+ if (header) interactive.header = { type: 'text', text: header };
+ const r = await _send({ to, type: 'interactive', interactive });
+ const c = await contactByWaId(to);
+ await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'interactive', body: bodyText, payload: r });
+ return r;
+}
+async function sendImage(to, link, caption, { bookingId } = {}) {
+ const r = await _send({ to, type: 'image', image: { link, caption } });
+ const c = await contactByWaId(to);
+ await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'image', body: caption, payload: r });
+ return r;
+}
+async function sendDocument(to, link, filename, caption, { bookingId } = {}) {
+ const r = await _send({ to, type: 'document', document: { link, filename, caption } });
+ const c = await contactByWaId(to);
+ await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'document', body: filename, payload: r });
+ return r;
+}
+async function sendLocation(to, lat, lng, name, address, { bookingId } = {}) {
+ const r = await _send({ to, type: 'location', location: { latitude: lat, longitude: lng, name, address } });
+ const c = await contactByWaId(to);
+ await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'location', body: name, payload: r });
+ return r;
+}
+async function markRead(waMessageId) {
+ if (!LIVE) return { sandbox: true };
+ return _send({ status: 'read', message_id: waMessageId });
+}
+
+// --- webhook ---------------------------------------------------------------
+// GET verification handshake
+function verifyChallenge(query) {
+ if (query['hub.mode'] === 'subscribe' && query['hub.verify_token'] === VERIFY_TOKEN) {
+ return { ok: true, challenge: query['hub.challenge'] };
+ }
+ return { ok: false };
+}
+// POST signature check (X-Hub-Signature-256: sha256=...)
+function verifySignature(headers, rawBody) {
+ if (!APP_SECRET) return !LIVE; // sandbox accepts
+ const sig = (headers['x-hub-signature-256'] || '').replace('sha256=', '');
+ const expect = crypto.createHmac('sha256', APP_SECRET).update(rawBody).digest('hex');
+ try { return sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect)); } catch { return false; }
+}
+// Parse an inbound webhook body into normalized events + persist inbound msgs.
+async function handleInbound(body) {
+ const out = [];
+ for (const entry of body.entry || []) {
+ for (const ch of entry.changes || []) {
+ const v = ch.value || {};
+ const profileName = v.contacts?.[0]?.profile?.name;
+ for (const m of v.messages || []) {
+ const c = await contactByWaId(m.from, profileName);
+ await pool.query(`UPDATE whatsapp_contacts SET last_inbound_at=NOW() WHERE id=$1`, [c.id]);
+ const body_ = m.text?.body || m.interactive?.button_reply?.title || m.interactive?.list_reply?.title || m.button?.text || null;
+ await logMessage({ waMessageId: m.id, contactId: c.id, direction: 'in', msgType: m.type, body: body_, payload: m, status: 'received' });
+ out.push({ contact: c, message: m, text: body_ });
+ }
+ for (const s of v.statuses || []) {
+ await pool.query(`UPDATE whatsapp_messages SET status=$1 WHERE wa_message_id=$2`, [s.status, s.id]);
+ }
+ }
+ }
+ return out;
+}
+
+module.exports = {
+ get liveMode() { return LIVE; }, VERIFY_TOKEN,
+ sendText, sendTemplate, sendButtons, sendList, sendImage, sendDocument, sendLocation, markRead,
+ verifyChallenge, verifySignature, handleInbound, contactByWaId,
+};
diff --git a/routes/app.js b/routes/app.js
new file mode 100644
index 0000000..eb9d472
--- /dev/null
+++ b/routes/app.js
@@ -0,0 +1,297 @@
+'use strict';
+// Mobile-app API (JWT-authed). Mounted at /api/app BEFORE the site basic-auth
+// gate so public app users and the Expo client can reach it.
+const express = require('express');
+const crypto = require('crypto');
+const { pool } = require('../lib/db');
+const { signToken, hashPassword, verifyPassword, authRequired, optionalAuth } = require('../lib/auth');
+const { computeSplit, nights } = require('../lib/money');
+const { getProvider } = require('../lib/payments');
+const plaid = require('../lib/plaid');
+const wa = require('../lib/whatsapp');
+
+const router = express.Router();
+const bookingCode = () => 'CR-' + crypto.randomBytes(3).toString('hex').toUpperCase();
+const ok = (res, data) => res.json({ ok: true, ...data });
+const bad = (res, code, msg) => res.status(code).json({ ok: false, error: msg });
+
+// ---------------------------------------------------------------- auth
+router.post('/auth/register', async (req, res) => {
+ const { email, password, full_name, phone } = req.body || {};
+ if (!email || !password) return bad(res, 400, 'email and password required');
+ try {
+ const { rows } = await pool.query(
+ `INSERT INTO app_users (email, password_hash, full_name, phone_e164)
+ VALUES ($1,$2,$3,$4) RETURNING id, email, full_name, role, is_host`,
+ [String(email).toLowerCase(), hashPassword(password), full_name || null, phone || null]);
+ const u = rows[0];
+ ok(res, { token: signToken({ sub: u.id, role: u.role }), user: u });
+ } catch (e) {
+ if (e.code === '23505') return bad(res, 409, 'email or phone already registered');
+ bad(res, 500, e.message);
+ }
+});
+
+router.post('/auth/login', async (req, res) => {
+ const { email, password } = req.body || {};
+ const { rows } = await pool.query(`SELECT * FROM app_users WHERE email=$1`, [String(email || '').toLowerCase()]);
+ const u = rows[0];
+ if (!u || !verifyPassword(password || '', u.password_hash)) return bad(res, 401, 'invalid credentials');
+ ok(res, { token: signToken({ sub: u.id, role: u.role, host_id: null }),
+ user: { id: u.id, email: u.email, full_name: u.full_name, role: u.role, is_host: u.is_host } });
+});
+
+router.get('/me', authRequired, async (req, res) => {
+ const { rows } = await pool.query(`SELECT id,email,full_name,phone_e164,role,is_host,wa_opt_in FROM app_users WHERE id=$1`, [req.user.sub]);
+ if (!rows[0]) return bad(res, 404, 'not found');
+ const { rows: hostRows } = await pool.query(`SELECT id, kyc_status FROM hosts WHERE user_id=$1`, [req.user.sub]);
+ ok(res, { user: rows[0], host: hostRows[0] || null });
+});
+
+// ---------------------------------------------------------------- listings (bookable inventory)
+router.get('/listings', optionalAuth, async (req, res) => {
+ const { region, vertical, q, limit = 40, offset = 0 } = req.query;
+ const where = ['pb.is_active = TRUE'];
+ const args = [];
+ if (region) { args.push(region); where.push(`r.slug = $${args.length}`); }
+ if (vertical) { args.push(vertical); where.push(`p.vertical = $${args.length}`); }
+ if (q) { args.push(`%${q}%`); where.push(`p.name ILIKE $${args.length}`); }
+ args.push(Math.min(+limit, 100)); const lim = `$${args.length}`;
+ args.push(+offset); const off = `$${args.length}`;
+ const { rows } = await pool.query(
+ `SELECT p.slug, p.name, p.vertical, p.category, p.description, p.image_url, p.rating,
+ r.name AS region, r.slug AS region_slug, p.lat, p.lng,
+ pb.booking_type, pb.currency, pb.base_price, pb.cleaning_fee, pb.max_guests, pb.instant_book
+ FROM place_booking pb
+ JOIN places p ON p.id = pb.place_id
+ LEFT JOIN regions r ON r.id = p.region_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY p.rating DESC NULLS LAST, p.name
+ LIMIT ${lim} OFFSET ${off}`, args);
+ ok(res, { count: rows.length, listings: rows });
+});
+
+router.get('/listings/:slug', optionalAuth, async (req, res) => {
+ const { rows } = await pool.query(
+ `SELECT p.*, pb.booking_type, pb.currency, pb.base_price, pb.cleaning_fee, pb.max_guests,
+ pb.min_nights, pb.instant_book, pb.cancellation, r.name AS region
+ FROM places p
+ JOIN place_booking pb ON pb.place_id = p.id
+ LEFT JOIN regions r ON r.id = p.region_id
+ WHERE p.slug=$1 AND pb.is_active`, [req.params.slug]);
+ if (!rows[0]) return bad(res, 404, 'listing not found or not bookable');
+ ok(res, { listing: rows[0] });
+});
+
+router.get('/listings/:slug/availability', async (req, res) => {
+ const { from, to } = req.query;
+ const { rows: [pl] } = await pool.query(`SELECT id FROM places WHERE slug=$1`, [req.params.slug]);
+ if (!pl) return bad(res, 404, 'listing not found');
+ const { rows } = await pool.query(
+ `SELECT day, slot_start, slot_end, capacity, price_override, is_blocked
+ FROM availability WHERE place_id=$1
+ AND ($2::date IS NULL OR day >= $2) AND ($3::date IS NULL OR day <= $3)
+ ORDER BY day, slot_start`, [pl.id, from || null, to || null]);
+ ok(res, { availability: rows });
+});
+
+// ---------------------------------------------------------------- bookings
+router.post('/bookings', authRequired, async (req, res) => {
+ const { place_slug, check_in, check_out, slot_start, slot_end, guests = 1 } = req.body || {};
+ const { rows: [pb] } = await pool.query(
+ `SELECT pb.*, p.id AS place_id, p.name FROM place_booking pb
+ JOIN places p ON p.id = pb.place_id WHERE p.slug=$1 AND pb.is_active`, [place_slug]);
+ if (!pb) return bad(res, 404, 'listing not bookable');
+ if (guests > pb.max_guests) return bad(res, 400, `max ${pb.max_guests} guests`);
+
+ let subtotal;
+ if (pb.booking_type === 'nightly') {
+ const n = nights(check_in, check_out);
+ if (n < (pb.min_nights || 1)) return bad(res, 400, `min ${pb.min_nights} nights`);
+ subtotal = pb.base_price * n;
+ } else {
+ subtotal = pb.base_price * (guests || 1); // slot/ticket priced per guest
+ }
+ const split = computeSplit({ subtotal, cleaningFee: pb.cleaning_fee, currency: pb.currency, platformFeeBps: pb.platform_fee_bps });
+ const code = bookingCode();
+ const { rows: [bk] } = await pool.query(
+ `INSERT INTO bookings (code, place_id, host_id, traveler_id, check_in, check_out, slot_start, slot_end,
+ guests, currency, subtotal, fees, platform_fee, total, host_payout, status)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'pending') RETURNING *`,
+ [code, pb.place_id, pb.host_id, req.user.sub, check_in || null, check_out || null, slot_start || null, slot_end || null,
+ guests, pb.currency, split.subtotal, split.fees, split.platformFee, split.total, split.hostPayout]);
+ ok(res, { booking: bk, split });
+});
+
+router.get('/bookings', authRequired, async (req, res) => {
+ const { rows } = await pool.query(
+ `SELECT b.*, p.name AS place_name, p.slug AS place_slug
+ FROM bookings b JOIN places p ON p.id=b.place_id
+ WHERE b.traveler_id=$1 ORDER BY b.created_at DESC`, [req.user.sub]);
+ ok(res, { bookings: rows });
+});
+
+router.get('/bookings/:code', authRequired, async (req, res) => {
+ const { rows } = await pool.query(
+ `SELECT b.*, p.name AS place_name, p.slug AS place_slug FROM bookings b
+ JOIN places p ON p.id=b.place_id WHERE b.code=$1 AND b.traveler_id=$2`, [req.params.code, req.user.sub]);
+ if (!rows[0]) return bad(res, 404, 'not found');
+ const { rows: pays } = await pool.query(`SELECT id,status,method,amount,currency,provider FROM payments WHERE booking_id=$1`, [rows[0].id]);
+ ok(res, { booking: rows[0], payments: pays });
+});
+
+router.post('/bookings/:code/cancel', authRequired, async (req, res) => {
+ const { rows } = await pool.query(
+ `UPDATE bookings SET status='cancelled', updated_at=NOW()
+ WHERE code=$1 AND traveler_id=$2 AND status IN ('pending','confirmed') RETURNING *`,
+ [req.params.code, req.user.sub]);
+ if (!rows[0]) return bad(res, 409, 'cannot cancel');
+ ok(res, { booking: rows[0] });
+});
+
+// ---------------------------------------------------------------- payments
+router.post('/bookings/:code/pay', authRequired, async (req, res) => {
+ const { method = 'card' } = req.body || {};
+ const { rows: [bk] } = await pool.query(
+ `SELECT * FROM bookings WHERE code=$1 AND traveler_id=$2`, [req.params.code, req.user.sub]);
+ if (!bk) return bad(res, 404, 'booking not found');
+ if (bk.status !== 'pending') return bad(res, 409, `booking is ${bk.status}`);
+
+ const { rows: [u] } = await pool.query(`SELECT email, full_name, phone_e164 FROM app_users WHERE id=$1`, [req.user.sub]);
+ const provider = getProvider();
+ const returnUrl = (process.env.APP_RETURN_URL || 'crmarketplace://pay/return');
+ let charge;
+ try {
+ charge = await provider.createCharge({
+ amount: bk.total, currency: bk.currency, method,
+ booking: { code: bk.code, id: bk.id },
+ customer: { email: u.email, name: u.full_name, phone: u.phone_e164 },
+ returnUrl: `${returnUrl}?code=${bk.code}`,
+ });
+ } catch (e) { return bad(res, 502, `processor error: ${e.message}`); }
+
+ const { rows: [pay] } = await pool.query(
+ `INSERT INTO payments (booking_id, provider, provider_ref, method, currency, amount, status, live_mode, raw)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
+ [bk.id, provider.name, charge.providerRef, method, bk.currency, bk.total,
+ charge.status === 'succeeded' ? 'succeeded' : 'processing', provider.liveMode, JSON.stringify(charge.raw || {})]);
+
+ // If sandbox/instant-succeeded, confirm the booking immediately.
+ if (charge.status === 'succeeded') await confirmBooking(bk.id);
+ ok(res, { payment_id: pay.id, status: charge.status, client_action: charge.clientAction, live_mode: provider.liveMode });
+});
+
+router.get('/payments/:id', authRequired, async (req, res) => {
+ const { rows: [pay] } = await pool.query(
+ `SELECT pm.* FROM payments pm JOIN bookings b ON b.id=pm.booking_id
+ WHERE pm.id=$1 AND b.traveler_id=$2`, [req.params.id, req.user.sub]);
+ if (!pay) return bad(res, 404, 'not found');
+ // Poll processor for latest status (covers sandbox redirect-return).
+ if (pay.status === 'processing') {
+ try {
+ const provider = getProvider(pay.provider);
+ const latest = await provider.getCharge(pay.provider_ref);
+ if (latest.status !== 'processing') {
+ await pool.query(`UPDATE payments SET status=$1, updated_at=NOW() WHERE id=$2`, [latest.status, pay.id]);
+ if (latest.status === 'succeeded') await confirmBooking(pay.booking_id);
+ pay.status = latest.status;
+ }
+ } catch { /* leave processing */ }
+ }
+ ok(res, { payment: { id: pay.id, status: pay.status, amount: pay.amount, currency: pay.currency } });
+});
+
+// Shared: mark booking confirmed + notify via WhatsApp (best-effort).
+async function confirmBooking(bookingId) {
+ const { rows: [b] } = await pool.query(
+ `UPDATE bookings SET status='confirmed', updated_at=NOW() WHERE id=$1 AND status='pending' RETURNING *`, [bookingId]);
+ if (!b) return;
+ try {
+ const { rows: [u] } = await pool.query(`SELECT phone_e164, wa_opt_in FROM app_users WHERE id=$1`, [b.traveler_id]);
+ const { rows: [p] } = await pool.query(`SELECT name FROM places WHERE id=$1`, [b.place_id]);
+ if (u?.phone_e164) {
+ const to = u.phone_e164.replace(/^\+/, '');
+ await wa.sendText(to,
+ `✅ ¡Reserva confirmada! ${p?.name} · code ${b.code} · ${b.currency} ${(b.total/100).toFixed(2)}. Gracias.`,
+ { bookingId: b.id });
+ }
+ } catch (e) { console.warn('[confirm] wa notify failed', e.message); }
+}
+
+// ---------------------------------------------------------------- host onboarding
+router.post('/host/apply', authRequired, async (req, res) => {
+ const { legal_name, cedula, country = 'CR' } = req.body || {};
+ const { rows: [h] } = await pool.query(
+ `INSERT INTO hosts (user_id, legal_name, cedula, country) VALUES ($1,$2,$3,$4)
+ ON CONFLICT (user_id) DO UPDATE SET legal_name=EXCLUDED.legal_name, cedula=EXCLUDED.cedula, country=EXCLUDED.country
+ RETURNING *`, [req.user.sub, legal_name || null, cedula || null, country]);
+ await pool.query(`UPDATE app_users SET is_host=TRUE WHERE id=$1`, [req.user.sub]);
+ ok(res, { host: h });
+});
+
+async function requireHost(req, res) {
+ const { rows } = await pool.query(`SELECT * FROM hosts WHERE user_id=$1`, [req.user.sub]);
+ if (!rows[0]) { bad(res, 403, 'not a host — call /host/apply first'); return null; }
+ return rows[0];
+}
+
+router.post('/host/claim', authRequired, async (req, res) => {
+ const host = await requireHost(req, res); if (!host) return;
+ const { rows: [pl] } = await pool.query(`SELECT id FROM places WHERE slug=$1`, [req.body?.place_slug]);
+ if (!pl) return bad(res, 404, 'place not found');
+ await pool.query(
+ `INSERT INTO place_hosts (place_id, host_id, claim_status) VALUES ($1,$2,'pending')
+ ON CONFLICT (place_id, host_id) DO NOTHING`, [pl.id, host.id]);
+ ok(res, { claimed: req.body.place_slug, status: 'pending' });
+});
+
+router.post('/host/listings', authRequired, async (req, res) => {
+ const host = await requireHost(req, res); if (!host) return;
+ const b = req.body || {};
+ const { rows: [pl] } = await pool.query(`SELECT id FROM places WHERE slug=$1`, [b.place_slug]);
+ if (!pl) return bad(res, 404, 'place not found');
+ const { rows: [pb] } = await pool.query(
+ `INSERT INTO place_booking (place_id, host_id, booking_type, currency, base_price, cleaning_fee, max_guests, min_nights, instant_book, is_active)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE)
+ ON CONFLICT (place_id) DO UPDATE SET host_id=EXCLUDED.host_id, booking_type=EXCLUDED.booking_type, currency=EXCLUDED.currency,
+ base_price=EXCLUDED.base_price, cleaning_fee=EXCLUDED.cleaning_fee, max_guests=EXCLUDED.max_guests,
+ min_nights=EXCLUDED.min_nights, instant_book=EXCLUDED.instant_book, is_active=TRUE, updated_at=NOW()
+ RETURNING *`,
+ [pl.id, host.id, b.booking_type || 'nightly', b.currency || 'USD', b.base_price | 0,
+ b.cleaning_fee | 0, b.max_guests || 1, b.min_nights || 1, b.instant_book !== false]);
+ ok(res, { listing: pb });
+});
+
+router.post('/host/payout-methods', authRequired, async (req, res) => {
+ const host = await requireHost(req, res); if (!host) return;
+ const b = req.body || {};
+ if (!['sinpe_movil', 'cr_iban', 'plaid_ach'].includes(b.kind)) return bad(res, 400, 'bad kind');
+ const { rows: [pm] } = await pool.query(
+ `INSERT INTO payout_methods (host_id, kind, label, sinpe_phone, cr_iban, bank_name, currency, is_default)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
+ [host.id, b.kind, b.label || null, b.sinpe_phone || null, b.cr_iban || null, b.bank_name || null,
+ b.currency || (b.kind === 'plaid_ach' ? 'USD' : 'CRC'), b.is_default !== false]);
+ if (pm.is_default) await pool.query(`UPDATE hosts SET default_payout_method_id=$1 WHERE id=$2`, [pm.id, host.id]);
+ ok(res, { payout_method: { id: pm.id, kind: pm.kind, currency: pm.currency, is_default: pm.is_default } });
+});
+
+// Plaid (foreign hosts): Link token + exchange
+router.post('/host/plaid/link-token', authRequired, async (req, res) => {
+ const host = await requireHost(req, res); if (!host) return;
+ const t = await plaid.createLinkToken(req.user.sub);
+ ok(res, { link_token: t.link_token, env: plaid.ENV, live: plaid.liveMode });
+});
+router.post('/host/plaid/exchange', authRequired, async (req, res) => {
+ const host = await requireHost(req, res); if (!host) return;
+ const ex = await plaid.exchangePublicToken(req.body?.public_token);
+ const auth = await plaid.getAuth(ex.access_token).catch(() => ({ accounts: [] }));
+ const acct = auth.accounts?.[0] || {};
+ const { rows: [pm] } = await pool.query(
+ `INSERT INTO payout_methods (host_id, kind, label, plaid_item_id, plaid_access_token, plaid_account_id, account_last4, currency, verified, is_default)
+ VALUES ($1,'plaid_ach','Bank (Plaid)',$2,$3,$4,$5,'USD',TRUE,TRUE) RETURNING id`,
+ [host.id, ex.item_id, ex.access_token, acct.account_id || null, acct.mask || null]);
+ await pool.query(`UPDATE hosts SET default_payout_method_id=$1 WHERE id=$2`, [pm.id, host.id]);
+ ok(res, { payout_method_id: pm.id, last4: acct.mask, sandbox: !!ex.sandbox });
+});
+
+module.exports = { router, confirmBooking };
diff --git a/routes/webhooks.js b/routes/webhooks.js
new file mode 100644
index 0000000..aac8662
--- /dev/null
+++ b/routes/webhooks.js
@@ -0,0 +1,79 @@
+'use strict';
+// Public webhooks — payment processors + WhatsApp. NO basic-auth (providers
+// POST unauthenticated; we verify by signature). Raw body needed for HMAC.
+const express = require('express');
+const { pool } = require('../lib/db');
+const { getProvider } = require('../lib/payments');
+const wa = require('../lib/whatsapp');
+const { confirmBooking } = require('./app');
+
+const router = express.Router();
+const raw = express.raw({ type: '*/*', limit: '2mb' });
+
+// Idempotency: record (source, external_id); return false if already seen.
+async function firstTime(source, externalId, eventType, payload) {
+ if (!externalId) return true; // no id -> best effort, still process
+ try {
+ const { rowCount } = await pool.query(
+ `INSERT INTO webhook_events (source, external_id, event_type, payload)
+ VALUES ($1,$2,$3,$4) ON CONFLICT (source, external_id) DO NOTHING`,
+ [source, String(externalId), eventType || null, payload ? JSON.stringify(payload) : null]);
+ return rowCount === 1;
+ } catch { return true; }
+}
+
+// ---- payment webhook (Tilopay / ONVO share the shape via the adapter) ----
+async function paymentWebhook(providerName, req, res) {
+ const provider = getProvider(providerName);
+ const { ok, event } = provider.verifyWebhook(req.headers, req.body); // req.body is a Buffer (raw)
+ if (!ok) return res.status(401).send('bad signature');
+ const evId = event?.id || event?.paymentId || event?.event_id;
+ const evType = event?.type || event?.status;
+ if (!(await firstTime(providerName, evId, evType, event))) return res.status(200).send('dup');
+
+ // Resolve the charge id the adapter reported to us.
+ const ref = event?.paymentId || event?.id || event?.data?.id;
+ if (ref) {
+ const latest = await provider.getCharge(ref).catch(() => null);
+ const status = latest?.status || (/(succe|approved|paid)/i.test(String(evType)) ? 'succeeded' : 'processing');
+ const { rows } = await pool.query(
+ `UPDATE payments SET status=$1, raw=$2, updated_at=NOW()
+ WHERE provider=$3 AND provider_ref=$4 RETURNING id, booking_id`,
+ [status, JSON.stringify(event || {}), providerName, ref]);
+ if (rows[0] && status === 'succeeded') await confirmBooking(rows[0].booking_id);
+ if (rows[0] && status === 'refunded') await pool.query(`UPDATE bookings SET status='refunded' WHERE id=$1`, [rows[0].booking_id]);
+ }
+ res.status(200).send('ok');
+}
+
+router.post('/tilopay', raw, (req, res) => paymentWebhook('tilopay', req, res).catch(e => res.status(500).send(e.message)));
+router.post('/onvo', raw, (req, res) => paymentWebhook('onvo', req, res).catch(e => res.status(500).send(e.message)));
+
+// ---- WhatsApp webhook ----
+router.get('/whatsapp', (req, res) => {
+ const v = wa.verifyChallenge(req.query);
+ if (v.ok) return res.status(200).send(v.challenge);
+ res.sendStatus(403);
+});
+router.post('/whatsapp', raw, async (req, res) => {
+ if (!wa.verifySignature(req.headers, req.body)) return res.sendStatus(401);
+ let body; try { body = JSON.parse(req.body.toString()); } catch { return res.sendStatus(400); }
+ const evId = body.entry?.[0]?.id + ':' + (body.entry?.[0]?.changes?.[0]?.value?.messages?.[0]?.id || Date.now());
+ if (!(await firstTime('whatsapp', evId, 'inbound', null))) return res.sendStatus(200);
+ try {
+ const events = await wa.handleInbound(body);
+ // Auto-reply hook: a simple keyword router (extend as needed).
+ for (const ev of events) {
+ const t = (ev.text || '').toLowerCase();
+ if (/^(hola|hi|hello|menu|ayuda|help)/.test(t)) {
+ await wa.sendButtons(ev.contact.wa_id,
+ '¡Hola! ¿En qué te ayudamos? / How can we help?',
+ [{ id: 'browse', title: 'Ver listados' }, { id: 'mybookings', title: 'Mis reservas' }, { id: 'support', title: 'Soporte' }],
+ { header: 'Costa Rica' });
+ }
+ }
+ } catch (e) { console.warn('[wa webhook]', e.message); }
+ res.sendStatus(200);
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index c0f2409..d4a6153 100644
--- a/server.js
+++ b/server.js
@@ -14,6 +14,11 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.set('trust proxy', true);
+
+// Payment/WhatsApp webhooks need the RAW body for HMAC verification, so mount
+// them BEFORE the global JSON parser (and before the site basic-auth gate).
+app.use('/webhooks', require('./routes/webhooks'));
+
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
@@ -25,6 +30,16 @@ app.use((req, res, next) => {
next();
});
+// Mobile-app API (JWT-authed) — mounted BEFORE the site basic-auth gate so
+// public app users can reach it.
+app.use('/api/app', require('./routes/app').router);
+app.get('/api/app/health', (_req, res) => {
+ const { getProvider } = require('./lib/payments');
+ res.json({ ok: true, layer: 'marketplace',
+ payment_provider: getProvider().name, payment_live: getProvider().liveMode,
+ whatsapp_live: require('./lib/whatsapp').liveMode, plaid_live: require('./lib/plaid').liveMode });
+});
+
// Whole-site Basic Auth gate (in-development; remove when ready for public)
const BA_USER = process.env.BASIC_AUTH_USER;
const BA_PASS = process.env.BASIC_AUTH_PASS;
← 098634c costa-rica: add GOOGLE_PLACES_API_KEY to .env.example; launc
·
back to Costa Rica
·
costa-rica: document marketplace env surface in .env.example d34e822 →