| 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; |
| } |
|
|
| |
|
|
| 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}`); |
| } |
|
|
| |
|
|
| async function getPayoutFee(amount) { |
| const res = await snippeRequest('GET', `/v1/payouts/fee?amount=${amount}`); |
| return res.data; |
| } |
|
|
| 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 }; |
| } |
|
|
| |
|
|
| 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, |
| }; |
|
|