airtel-money-node-sdk
Version:
A Node.js SDK to integrate with Airtel Money API
340 lines (306 loc) • 14.7 kB
JavaScript
// airtel-money-sdk/lib/index.js
const axios = require('axios');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
require('dotenv').config();
// ───────────────────────────────────────────────────────────────────────────────
// Configuration & Constants
// ───────────────────────────────────────────────────────────────────────────────
const {
AIRTEL_API_BASE_URL,
CLIENT_ID,
CLIENT_SECRET,
GRANT_TYPE,
COUNTRY,
CURRENCY,
AIRTEL_API_VERSION,
DEFAULT_MAX_RETRIES,
DEFAULT_POLLING_INTERVAL_MS,
POLLING_TIMEOUT
} = process.env;
const version = AIRTEL_API_VERSION || '1';
let bearerTokenCache = null;
let tokenExpiry = 0;
// ───────────────────────────────────────────────────────────────────────────────
// Utility: Centralized Axios Error Logging
// ───────────────────────────────────────────────────────────────────────────────
function logAxiosError(fnName, error) {
console.error(`Error in ${fnName}:`, error.message);
if (error.response) {
console.error(' Response data:', error.response.data);
console.error(' Status code :', error.response.status);
} else if (error.request) {
console.error(' No response received:', error.request);
}
}
// ───────────────────────────────────────────────────────────────────────────────
// 1) Bearer-Token Management (cached until expiry minus 60s buffer)
// ───────────────────────────────────────────────────────────────────────────────
async function getBearerToken() {
if (Date.now() < tokenExpiry && bearerTokenCache) {
return bearerTokenCache;
}
try {
console.log('Fetching new Bearer Token...');
const { data } = await axios.post(
`${AIRTEL_API_BASE_URL}/auth/oauth2/token`,
{ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, grant_type: GRANT_TYPE },
{
headers: { 'Content-Type': 'application/json', Accept: '*/*' },
timeout: POLLING_TIMEOUT
}
);
bearerTokenCache = data.access_token;
tokenExpiry = Date.now() + data.expires_in * 1000 - 60000;
console.log('New Bearer token cached.');
return bearerTokenCache;
} catch (err) {
logAxiosError('getBearerToken', err);
throw err;
}
}
// ───────────────────────────────────────────────────────────────────────────────
// 2) V1: Simple "JSON" Payment Request
// ───────────────────────────────────────────────────────────────────────────────
async function requestPaymentV1(token, amount, msisdn, reference, transactionId) {
try {
const payload = {
reference,
subscriber: { country: COUNTRY, currency: CURRENCY, msisdn },
transaction: { amount, country: COUNTRY, currency: CURRENCY, id: transactionId }
};
console.log('Payment request (V1):', JSON.stringify(payload, null, 2));
const { data } = await axios.post(
`${AIRTEL_API_BASE_URL}/merchant/v1/payments/`,
payload,
{
headers: {
Accept: '*/*',
'Content-Type': 'application/json',
'X-Country': COUNTRY,
'X-Currency': CURRENCY,
Authorization: `Bearer ${token}`
},
timeout: POLLING_TIMEOUT
}
);
console.log('Payment response (V1):', JSON.stringify(data, null, 2));
if (!data?.data?.transaction) {
throw new Error('Invalid response structure from Airtel Payment V1');
}
const txn = data.data.transaction;
if (txn.status === 'TF') {
console.error('Transaction failed (V1):', txn);
throw new Error(`Transaction failed: ${txn.message}`);
}
if (txn.status !== 'TS') {
console.warn('Transaction not yet successful (V1):', txn);
} else {
console.log('Transaction successful (V1):', txn);
}
return data;
} catch (err) {
logAxiosError('requestPaymentV1', err);
throw err;
}
}
// ───────────────────────────────────────────────────────────────────────────────
// 3) V2: Encrypted-Payload Payment Request
// ───────────────────────────────────────────────────────────────────────────────
async function requestPaymentV2(token, amount, msisdn, reference, retries = 0) {
const transactionId = uuidv4();
const payload = {
reference,
subscriber: { country: COUNTRY, currency: CURRENCY, msisdn },
transaction: { amount, country: COUNTRY, currency: CURRENCY, id: transactionId }
};
console.log('Payment request (V2):', JSON.stringify(payload, null, 2));
// AES key/IV
const { key, iv } = {
key: crypto.randomBytes(32).toString('base64'),
iv: crypto.randomBytes(16).toString('base64')
};
const cipher = crypto.createCipheriv(
'aes-256-cbc',
Buffer.from(key, 'base64'),
Buffer.from(iv, 'base64')
);
let encryptedPayload = cipher.update(JSON.stringify(payload), 'utf8', 'base64');
encryptedPayload += cipher.final('base64');
// Fetch RSA public key
let rsaPublicKey;
try {
console.log('Fetching RSA public key for V2 encryption...');
const { data } = await axios.get(
`${AIRTEL_API_BASE_URL}/v1/rsa/encryption-keys`,
{
headers: {
Authorization: `Bearer ${await getBearerToken()}`,
'X-Country': COUNTRY,
'X-Currency': CURRENCY
}
}
);
rsaPublicKey = data.data.key;
console.log('RSA public key fetched (V2).');
} catch (err) {
logAxiosError('fetchRSAPublicKey', err);
throw err;
}
// Encrypt AES key:iv under RSA
const keyIvString = `${key}:${iv}`;
const encryptedKeyIv = crypto
.publicEncrypt(
{ key: rsaPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(keyIvString, 'utf8')
)
.toString('base64');
// Attempt payment
try {
const { data } = await axios.post(
`${AIRTEL_API_BASE_URL}/merchant/v2/payments/`,
payload,
{
headers: {
Accept: '*/*',
'Content-Type': 'application/json',
'X-Country': COUNTRY,
'X-Currency': CURRENCY,
Authorization: `Bearer ${token}`,
'x-signature': encryptedPayload,
'x-key': encryptedKeyIv
},
timeout: POLLING_TIMEOUT
}
);
console.log('Payment response (V2):', JSON.stringify(data, null, 2));
return data;
} catch (err) {
if (retries + 1 < DEFAULT_MAX_RETRIES) {
console.warn(`Retrying payment request (V2): attempt ${retries + 2}/${DEFAULT_MAX_RETRIES}`);
return requestPaymentV2(token, amount, msisdn, reference, retries + 1);
}
logAxiosError('requestPaymentV2', err);
throw err;
}
}
// ───────────────────────────────────────────────────────────────────────────────
// 4) Polling: Common Flow for V1 & V2 Status Checks
// ───────────────────────────────────────────────────────────────────────────────
async function pollPaymentStatus(transactionIdOrRef) {
console.log(`Starting polling for transaction ${transactionIdOrRef}`);
const checkFn = version === '1' ? pollStatusV1 : pollStatusV2;
let lastStatus = null;
for (let attempt = 0; attempt < Number(DEFAULT_MAX_RETRIES); attempt++) {
console.log(`Polling attempt ${attempt + 1}/${DEFAULT_MAX_RETRIES} for ${transactionIdOrRef}`);
try {
const responseData = await checkFn(transactionIdOrRef);
const txn = responseData.data.transaction;
console.log(`Poll response:`, JSON.stringify(txn, null, 2));
lastStatus = txn?.status ?? 'UNKNOWN';
if (lastStatus === 'TS') {
console.log('Final status: Transaction successful:', txn);
return { status: 'SUCCESS', data: responseData };
}
if (lastStatus === 'TF') {
console.error('Final status: Transaction failed:', txn);
return { status: 'FAILED', data: responseData };
}
console.log(`Status is pending (${lastStatus}). Will retry after delay.`);
const waitMs = Number(DEFAULT_POLLING_INTERVAL_MS) * (attempt + 1);
console.log(`Waiting ${waitMs}ms before next poll…`);
await new Promise(res => setTimeout(res, waitMs));
} catch (err) {
console.error(`Error during polling attempt ${attempt + 1}: ${err.message}`);
if (attempt + 1 >= Number(DEFAULT_MAX_RETRIES)) {
console.error(`Polling stopped. Last known status: ${lastStatus}`);
throw new Error(`Polling exceeded max retries (last status=${lastStatus})`);
}
console.log(`Will retry polling (attempt ${attempt + 2}/${DEFAULT_MAX_RETRIES})…`);
}
}
console.error(`Polling timed out. Last known status: ${lastStatus}`);
throw new Error(`Polling timed out (last status=${lastStatus})`);
}
// ───────────────────────────────────────────────────────────────────────────────
// 5) Status Check V1
// ───────────────────────────────────────────────────────────────────────────────
async function pollStatusV1(transactionId) {
const token = await getBearerToken();
try {
console.log(`Requesting status (V1) for ${transactionId}…`);
const { data } = await axios.get(
`${AIRTEL_API_BASE_URL}/standard/v1/payments/${transactionId}`,
{
headers: {
Accept: '*/*',
'X-Country': COUNTRY,
'X-Currency': CURRENCY,
Authorization: `Bearer ${token}`
},
timeout: POLLING_TIMEOUT
}
);
console.log('Status response (V1):', JSON.stringify(data, null, 2));
return data;
} catch (err) {
logAxiosError('pollStatusV1', err);
throw err;
}
}
// ───────────────────────────────────────────────────────────────────────────────
// 6) Status Check V2
// ───────────────────────────────────────────────────────────────────────────────
async function pollStatusV2(reference) {
const token = await getBearerToken();
try {
console.log(`Requesting status (V2) for ${reference}…`);
const { data } = await axios.get(
`${AIRTEL_API_BASE_URL}/standard/v1/payments/${reference}`,
{
headers: {
Authorization: `Bearer ${token}`,
'X-Country': COUNTRY,
'X-Currency': CURRENCY
},
timeout: POLLING_TIMEOUT
}
);
console.log('Status response (V2):', JSON.stringify(data, null, 2));
return data;
} catch (err) {
logAxiosError('pollStatusV2', err);
throw err;
}
}
// ───────────────────────────────────────────────────────────────────────────────
// 7) Public API: Initiate Airtel Payment
// ───────────────────────────────────────────────────────────────────────────────
async function initiateAirtelPayment(amount, msisdn, reference) {
console.log(`\n=== Initiating Airtel payment ===`);
console.log(`Amount: ${amount}, MSISDN: ${msisdn}, Reference: ${reference}, Version: ${version}`);
try {
const token = await getBearerToken();
console.log('Bearer token acquired.');
// Use the provided reference as transaction ID for consistency
const transactionIdOrRef = reference;
let paymentData;
if (version === '1') {
paymentData = await requestPaymentV1(token, amount, msisdn, reference, transactionIdOrRef);
} else {
paymentData = await requestPaymentV2(token, amount, msisdn, reference);
}
console.log('Payment request complete. Now polling status…');
const result = await pollPaymentStatus(transactionIdOrRef);
return result;
} catch (err) {
console.error(`initiateAirtelPayment failed: ${err.message}`);
throw err;
}
}
// ───────────────────────────────────────────────────────────────────────────────
// 8) Export
// ───────────────────────────────────────────────────────────────────────────────
module.exports = {
initiateAirtelPayment
};