const crypto = require('crypto'); const axios = require('axios'); const BASE_URL = 'https://api.snippe.sh'; function ikey(prefix, id) { return `${prefix}-${id}`.slice(0, 30); } function normalizePhone(phone) { const d = phone.replace(/\D/g, ''); if (d.startsWith('255')) return d; if (d.startsWith('0')) return '255' + d.slice(1); return '255' + d; } async function snippeRequest(method, path, body = null, idempotencyKey = null, apiKey = null) { const headers = { Authorization: `Bearer ${apiKey || process.env.SNIPPE_API_KEY}`, 'Content-Type': 'application/json', }; if (idempotencyKey) { if (idempotencyKey.length > 30) { throw new Error(`Idempotency key too long (${idempotencyKey.length} chars, max 30)`); } headers['Idempotency-Key'] = idempotencyKey; } const res = await axios({ method, url: `${BASE_URL}${path}`, headers, data: body ?? undefined, timeout: 30000, }); return res.data; } // ── Collection ──────────────────────────────────────────────────────────────── async function createPayment({ reference, amount, phone, webhookUrl, metadata = {}, apiKey = null }) { return snippeRequest('POST', '/v1/payments', { payment_type: 'mobile', details: { amount, currency: 'TZS' }, phone_number: normalizePhone(phone), customer: { firstname: metadata.firstname || 'Guest', lastname: metadata.lastname || 'User', email: metadata.email || 'guest@wifi.local', }, webhook_url: webhookUrl, metadata, }, ikey('pay', reference), apiKey); } function extractPaymentError(err, fallbackMessage = 'Payment request failed') { const data = err?.response?.data || {}; const message = data.message || err?.message || fallbackMessage; return { status: err?.response?.status || 500, code: data.error_code || null, message, retryable: !(err?.response?.status >= 400 && err?.response?.status < 500), raw: data, }; } async function getPayment(reference) { return snippeRequest('GET', `/v1/payments/${reference}`); } // ── Disbursements ───────────────────────────────────────────────────────────── async function getPayoutFee(amount) { const res = await snippeRequest('GET', `/v1/payouts/fee?amount=${amount}`); return res.data; // { amount, fee_amount, total_amount, currency } } async function createPayout({ payoutId, amount, phone, recipientName, narration, webhookUrl }) { const feeData = await getPayoutFee(amount); const res = await snippeRequest('POST', '/v1/payouts/send', { amount, channel: 'mobile', recipient_phone: normalizePhone(phone), recipient_name: recipientName, narration: narration || 'WiFi platform payout', webhook_url: webhookUrl, metadata: { payout_id: String(payoutId) }, }, ikey('po', payoutId)); return { ...res, feeData }; } // ── Webhook verification ────────────────────────────────────────────────────── function verifyWebhookSignature(rawBody, headers, webhookSecret = null) { const timestamp = headers['x-webhook-timestamp']; const receivedSig = headers['x-webhook-signature']; const secret = webhookSecret || process.env.SNIPPE_WEBHOOK_SECRET; if (!timestamp || !receivedSig) throw new Error('Missing webhook signature headers'); if (!secret) throw new Error('Missing webhook secret'); const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10); if (age > 300) throw new Error('Webhook timestamp too old (possible replay attack)'); const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex'); const received = Buffer.from(receivedSig, 'hex'); const expectedBuffer = Buffer.from(expected, 'hex'); if (received.length !== expectedBuffer.length || !crypto.timingSafeEqual(received, expectedBuffer)) { throw new Error('Invalid webhook signature'); } return JSON.parse(rawBody); } module.exports = { createPayment, extractPaymentError, getPayment, getPayoutFee, createPayout, verifyWebhookSignature, normalizePhone, };