WifiBiz / src /services /snippe.js
Mbonea's picture
Migrate production app to v2 Omada workflow
9b8d6f0
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,
};