diff --git a/src/example.js b/examples/example.ts similarity index 79% rename from src/example.js rename to examples/example.ts index 787d157..53ef611 100644 --- a/src/example.js +++ b/examples/example.ts @@ -1,4 +1,4 @@ -const IntaSend = require('./intasend'); +import IntaSend from '../src/intasend'; // IMPORTANT: // ================================= @@ -8,14 +8,14 @@ const IntaSend = require('./intasend'); // In your code, do not place the keys and commit to code repositories. // We recommend using environment variables and 12Factor web approach. Please check it out here: https://12factor.net/ -let intasend = new IntaSend( +const intasend = new IntaSend( 'ISPubKey_test_91ffc81a-8ac4-419e-8008-7091caa8d73f', 'ISSecretKey_test_15515fe9-fb5d-4362-970e-625532df8181', true ); // Create checkout page i.e payment collection link -let collection = intasend.collection(); +const collection = intasend.collection(); collection .charge({ first_name: 'FELIX', @@ -50,7 +50,7 @@ collection }); // How to create and interact with Wallets -let wallets = intasend.wallets(); +const wallets = intasend.wallets(); wallets .create({ label: 'NodeJS-SDK-TEST', @@ -66,11 +66,13 @@ wallets // How to send money M-PESA (B2C, B2B, BANK, INTASEND P2P) // Learn more from our API reference on provider types and fields here - https://developers.intasend.com/reference/send-money_initiate_create -let payouts = intasend.payouts(); +const payouts = intasend.payouts(); +const requiresApproval = 'YES'; payouts .initiate({ provider: 'MPESA-B2B', currency: 'KES', + requires_approval: requiresApproval, // Set to 'NO' if you want the transaction to go through without calling the approve method transactions: [ { name: 'ABC', @@ -84,21 +86,23 @@ payouts .then((resp) => { console.log(`Payouts response: ${JSON.stringify(resp)}`); // Approve payouts - payouts - .approve(resp) - .then((resp) => { - console.log(`Payouts approve: ${JSON.stringify(resp)}`); - }) - .catch((err) => { - console.error(`Payouts approve error: ${err}`); - }); + if (requiresApproval === 'YES') { + payouts + .approve(resp) + .then((resp) => { + console.log(`Payouts approve: ${JSON.stringify(resp)}`); + }) + .catch((err) => { + console.error(`Payouts approve error: ${err}`); + }); + } }) .catch((err) => { console.error(`Payouts error: ${err}`); }); // How to handle refunds -let refunds = intasend.refunds(); +const refunds = intasend.refunds(); refunds .create({ invoice: 'INVOICE-NUMBER', diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..2d95d89 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,10 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts'], +}; + +export default config; diff --git a/package.json b/package.json index 9d3ce89..e2618dd 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,16 @@ "dist" ], "scripts": { - "build": "tsc" + "build": "tsc -p tsconfig.build.json", + "test": "jest" }, "license": "GNU Affero General Public License v3.0", "repository": "https://github.com/IntaSend/intasend-node.git", "devDependencies": { + "@types/jest": "^30.0.0", "@types/node": "^18.7.18", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", "typescript": "^5.4.5" }, "author": { diff --git a/src/collection.js b/src/collection.ts similarity index 54% rename from src/collection.js rename to src/collection.ts index 2d507f7..829555d 100644 --- a/src/collection.js +++ b/src/collection.ts @@ -1,21 +1,26 @@ -const RequestClient = require('./requests'); +import RequestClient from './requests'; +import { ChargePayload, MpesaStkPushPayload } from './types'; class Collection extends RequestClient { - charge(payload) { + charge(payload: ChargePayload): Promise { this.secret_key = ''; return this.send(payload, '/api/v1/checkout/', 'POST'); } - mpesaStkPush(payload) { + mpesaStkPush(payload: MpesaStkPushPayload): Promise { payload['method'] = 'M-PESA'; payload['currency'] = 'KES'; return this.send(payload, '/api/v1/payment/mpesa-stk-push/', 'POST'); } - status(invoiceID, checkoutID = '', signature = '') { + status( + invoiceID: string, + checkoutID: string = '', + signature: string = '' + ): Promise { this.secret_key = ''; - let payload = { - invoice_id: invoiceID + const payload: Record = { + invoice_id: invoiceID, }; if (checkoutID && signature) { payload['signature'] = signature; @@ -25,4 +30,4 @@ class Collection extends RequestClient { } } -module.exports = Collection; +export default Collection; diff --git a/src/intasend.js b/src/intasend.js deleted file mode 100644 index 6c799b2..0000000 --- a/src/intasend.js +++ /dev/null @@ -1,29 +0,0 @@ -const RequestClient = require('./requests'); -const Wallet = require('./wallets'); -const Collection = require('./collection'); -const Payouts = require('./payouts'); -const Refunds = require('./refunds'); - -class IntaSend extends RequestClient { - constructor(publishable_key, secret_key, test_mode) { - super(publishable_key, secret_key, test_mode); - } - wallets() { - return new Wallet(this.publishable_key, this.secret_key, this.test_mode); - } - collection() { - return new Collection( - this.publishable_key, - this.secret_key, - this.test_mode - ); - } - payouts() { - return new Payouts(this.publishable_key, this.secret_key, this.test_mode); - } - refunds() { - return new Refunds(this.publishable_key, this.secret_key, this.test_mode); - } -} - -module.exports = IntaSend; diff --git a/src/intasend.ts b/src/intasend.ts new file mode 100644 index 0000000..2fae129 --- /dev/null +++ b/src/intasend.ts @@ -0,0 +1,53 @@ +import RequestClient from './requests'; +import Wallet from './wallets'; +import Collection from './collection'; +import Payouts from './payouts'; +import Refunds from './refunds'; + +export { + RequestClient, + Wallet, + Collection, + Payouts, + Refunds, +}; + +export * from './types'; + +class IntaSend extends RequestClient { + constructor(publishable_key: string, secret_key: string, test_mode: boolean) { + super(publishable_key, secret_key, test_mode); + } + + wallets(): Wallet { + return new Wallet(this.publishable_key, this.secret_key, this.test_mode); + } + + collection(): Collection { + return new Collection( + this.publishable_key, + this.secret_key, + this.test_mode + ); + } + + payouts(): Payouts { + return new Payouts(this.publishable_key, this.secret_key, this.test_mode); + } + + refunds(): Refunds { + return new Refunds(this.publishable_key, this.secret_key, this.test_mode); + } +} + +export default IntaSend; + +// CommonJS backward compatibility: allows `const IntaSend = require('intasend-node')` +// without needing `.default`. Named exports are attached as properties. +module.exports = IntaSend; +module.exports.default = IntaSend; +module.exports.RequestClient = RequestClient; +module.exports.Wallet = Wallet; +module.exports.Collection = Collection; +module.exports.Payouts = Payouts; +module.exports.Refunds = Refunds; diff --git a/src/payouts.js b/src/payouts.js deleted file mode 100644 index 8f0ec88..0000000 --- a/src/payouts.js +++ /dev/null @@ -1,42 +0,0 @@ -const RequestClient = require('./requests'); - -class Payouts extends RequestClient { - initiate(payload) { - return this.send(payload, '/api/v1/send-money/initiate/', 'POST'); - } - - mpesa(payload) { - payload['provider'] = 'MPESA-B2C'; - return this.initiate(payload); - } - - mpesaB2B(payload) { - payload['provider'] = 'MPESA-B2B'; - return this.initiate(payload); - } - - bank(payload) { - payload['provider'] = 'PESALINK'; - return this.initiate(payload); - } - - intasend(payload) { - payload['provider'] = 'INTASEND'; - return this.initiate(payload); - } - - airtime(payload) { - payload['provider'] = 'AIRTIME'; - return this.initiate(payload); - } - - approve(payload) { - return this.send(payload, '/api/v1/send-money/approve/', 'POST'); - } - - status(payload) { - return this.send(payload, '/api/v1/send-money/status/', 'POST'); - } -} - -module.exports = Payouts; diff --git a/src/payouts.ts b/src/payouts.ts new file mode 100644 index 0000000..24a7aec --- /dev/null +++ b/src/payouts.ts @@ -0,0 +1,43 @@ +import RequestClient from './requests'; +import { PayoutPayload } from './types'; + +class Payouts extends RequestClient { + initiate(payload: PayoutPayload): Promise { + return this.send(payload, '/api/v1/send-money/initiate/', 'POST'); + } + + mpesa(payload: PayoutPayload): Promise { + payload.provider = 'MPESA-B2C'; + return this.initiate(payload); + } + + mpesaB2B(payload: PayoutPayload): Promise { + payload.provider = 'MPESA-B2B'; + return this.initiate(payload); + } + + bank(payload: PayoutPayload): Promise { + payload.provider = 'PESALINK'; + return this.initiate(payload); + } + + intasend(payload: PayoutPayload): Promise { + payload.provider = 'INTASEND'; + return this.initiate(payload); + } + + airtime(payload: PayoutPayload): Promise { + payload.provider = 'AIRTIME'; + return this.initiate(payload); + } + + approve(payload: Record): Promise { + return this.send(payload, '/api/v1/send-money/approve/', 'POST'); + } + + status(payload: Record): Promise { + return this.send(payload, '/api/v1/send-money/status/', 'POST'); + } +} + +export default Payouts; diff --git a/src/refunds.js b/src/refunds.ts similarity index 51% rename from src/refunds.js rename to src/refunds.ts index 0708c67..1bca70a 100644 --- a/src/refunds.js +++ b/src/refunds.ts @@ -1,17 +1,18 @@ -const RequestClient = require('./requests'); +import RequestClient from './requests'; +import { CreateRefundPayload } from './types'; class Refunds extends RequestClient { - list() { + list(): Promise { return this.send({}, '/api/v1/chargebacks/', 'GET'); } - create(payload) { + create(payload: CreateRefundPayload): Promise { return this.send(payload, '/api/v1/chargebacks/', 'POST'); } - get(chargebackID) { + get(chargebackID: string): Promise { return this.send({}, `/api/v1/chargebacks/${chargebackID}/`, 'GET'); } } -module.exports = Refunds; +export default Refunds; diff --git a/src/requests.js b/src/requests.ts similarity index 63% rename from src/requests.js rename to src/requests.ts index 10a38c7..e829165 100644 --- a/src/requests.js +++ b/src/requests.ts @@ -1,24 +1,33 @@ -const https = require('https'); +import https from 'https'; class RequestClient { - publishable_key; - secret_key; - prod_base_url = 'payment.intasend.com'; - test_base_url = 'sandbox.intasend.com'; - test_mode = true; - constructor(publishable_key, secret_key, test_mode) { + publishable_key: string; + secret_key: string; + prod_base_url: string = 'payment.intasend.com'; + test_base_url: string = 'sandbox.intasend.com'; + test_mode: boolean = true; + + constructor(publishable_key: string, secret_key: string, test_mode: boolean) { this.publishable_key = publishable_key; this.secret_key = secret_key; this.test_mode = test_mode; } - send(payload, service_path, req_method) { - let method = req_method || 'POST'; + + send( + payload: Record, + service_path: string, + req_method: string = 'POST' + ): Promise { return new Promise((resolve, reject) => { let base_url = this.prod_base_url; if (this.test_mode) { base_url = this.test_base_url; } - let headers = { 'Content-Type': 'application/json' }; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (this.secret_key) { headers['Authorization'] = `Bearer ${this.secret_key}`; } @@ -26,24 +35,27 @@ class RequestClient { headers['INTASEND_PUBLIC_API_KEY'] = this.publishable_key; payload['public_key'] = this.publishable_key; } - const options = { + + const options: https.RequestOptions = { hostname: base_url, port: 443, path: service_path, - method: method, + method: req_method, headers: headers, }; + const req = https.request(options, (res) => { if (res.statusCode !== 201 && res.statusCode !== 200) { console.error(`IntaSend Request HTTP Error Code: ${res.statusCode}`); res.resume(); - res.on('data', (data) => { + res.on('data', (data: Buffer) => { reject(data); }); return; } - var results = ''; - res.on('data', (data) => { + + let results = ''; + res.on('data', (data: Buffer) => { results += data; }); res.on('end', () => { @@ -51,13 +63,14 @@ class RequestClient { resolve(JSON.parse(results)); return; } - resolve({}); + resolve({} as T); }); }); + req.on('error', (err) => { reject(err.message); - return; }); + if (payload) { req.write(JSON.stringify(payload)); } @@ -66,4 +79,4 @@ class RequestClient { } } -module.exports = RequestClient; +export default RequestClient; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f02d3a7 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,77 @@ +export interface ChargePayload { + first_name: string; + last_name: string; + email: string; + host: string; + amount: number; + currency: string; + api_ref?: string; + [key: string]: unknown; +} + +export interface MpesaStkPushPayload { + phone_number: string; + name: string; + email: string; + amount: number; + api_ref?: string; + [key: string]: unknown; +} + +export interface PayoutTransaction { + name: string; + account: number | string; + amount: string; + account_type?: string; + account_reference?: string; +} + +export type PayoutProvider = + | 'MPESA-B2C' + | 'MPESA-B2B' + | 'PESALINK' + | 'INTASEND' + | 'AIRTIME'; + +export interface PayoutPayload { + provider?: PayoutProvider; + currency: string; + requires_approval?: 'YES' | 'NO'; + transactions: PayoutTransaction[]; + [key: string]: unknown; +} + +export interface CreateWalletPayload { + label: string; + wallet_type: string; + currency: string; + [key: string]: unknown; +} + +export interface CreateRefundPayload { + invoice: string; + amount: number; + reason: string; + reason_details?: string; + [key: string]: unknown; +} + +export interface FundMpesaPayload { + phone_number: string; + name: string; + email: string; + amount: number; + api_ref?: string; + [key: string]: unknown; +} + +export interface FundCheckoutPayload { + first_name: string; + last_name: string; + email: string; + host: string; + amount: number; + currency: string; + api_ref?: string; + [key: string]: unknown; +} diff --git a/src/wallets.js b/src/wallets.ts similarity index 56% rename from src/wallets.js rename to src/wallets.ts index 400ebf2..b5bd400 100644 --- a/src/wallets.js +++ b/src/wallets.ts @@ -1,15 +1,26 @@ -const RequestClient = require('./requests'); +import RequestClient from './requests'; +import { + CreateWalletPayload, + FundMpesaPayload, + FundCheckoutPayload, +} from './types'; class Wallet extends RequestClient { - list() { + list(): Promise { return this.send({}, '/api/v1/wallets/', 'GET'); } - create(payload) { + + create(payload: CreateWalletPayload): Promise { return this.send(payload, '/api/v1/wallets/', 'POST'); } - intraTransfer(sourceID, destinationID, amount, narrative) { - let payload = { + intraTransfer( + sourceID: string, + destinationID: string, + amount: number, + narrative: string + ): Promise { + const payload = { wallet_id: destinationID, amount: amount, narrative: narrative, @@ -21,24 +32,24 @@ class Wallet extends RequestClient { ); } - get(walletID) { + get(walletID: string): Promise { return this.send({}, `/api/v1/wallets/${walletID}/`, 'GET'); } - transactions(walletID) { + transactions(walletID: string): Promise { return this.send({}, `/api/v1/wallets/${walletID}/transactions/`, 'GET'); } - fundMPesa(payload) { + fundMPesa(payload: FundMpesaPayload): Promise { payload['method'] = 'M-PESA'; payload['currency'] = 'KES'; return this.send(payload, '/api/v1/payment/mpesa-stk-push/', 'POST'); } - fundCheckout(payload) { + fundCheckout(payload: FundCheckoutPayload): Promise { this.secret_key = ''; return this.send(payload, '/api/v1/checkout/', 'POST'); } } -module.exports = Wallet; +export default Wallet; diff --git a/tests/collection.test.ts b/tests/collection.test.ts new file mode 100644 index 0000000..cdbce35 --- /dev/null +++ b/tests/collection.test.ts @@ -0,0 +1,231 @@ +import { mockHttpsRequest, resetMocks } from './helpers'; +import Collection from '../src/collection'; + +describe('Collection', () => { + const pubKey = 'ISPubKey_test_abc123'; + const secretKey = 'ISSecretKey_test_secret'; + + afterEach(() => { + resetMocks(); + }); + + describe('charge()', () => { + it('POSTs to /api/v1/checkout/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { id: 'chk_123', url: 'https://sandbox.intasend.com/checkout/123/' }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.charge({ + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + host: 'https://example.com', + amount: 100, + currency: 'KES', + api_ref: 'test-ref', + }); + + expect(captured.options.path).toBe('/api/v1/checkout/'); + expect(captured.options.method).toBe('POST'); + }); + + it('clears secret_key for public endpoint', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { id: 'chk_123' }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.charge({ + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + host: 'https://example.com', + amount: 100, + currency: 'KES', + }); + + expect(captured.options.headers).not.toHaveProperty('Authorization'); + }); + + it('sends correct payload fields', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { id: 'chk_123' }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.charge({ + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + host: 'https://example.com', + amount: 500, + currency: 'KES', + api_ref: 'order-001', + }); + + const body = JSON.parse(captured.body); + expect(body.first_name).toBe('John'); + expect(body.last_name).toBe('Doe'); + expect(body.email).toBe('john@example.com'); + expect(body.host).toBe('https://example.com'); + expect(body.amount).toBe(500); + expect(body.currency).toBe('KES'); + expect(body.api_ref).toBe('order-001'); + }); + + it('returns parsed response', async () => { + mockHttpsRequest({ + statusCode: 200, + body: { id: 'chk_789', url: 'https://sandbox.intasend.com/checkout/789/' }, + }); + const collection = new Collection(pubKey, secretKey, true); + + const result = await collection.charge({ + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + host: 'https://example.com', + amount: 100, + currency: 'KES', + }); + + expect(result.id).toBe('chk_789'); + expect(result.url).toBe('https://sandbox.intasend.com/checkout/789/'); + }); + }); + + describe('mpesaStkPush()', () => { + it('POSTs to /api/v1/payment/mpesa-stk-push/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { invoice_id: 'inv_123' } }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.mpesaStkPush({ + phone_number: '254722000000', + name: 'John Doe', + email: 'john@example.com', + amount: 10, + api_ref: 'test', + }); + + expect(captured.options.path).toBe('/api/v1/payment/mpesa-stk-push/'); + expect(captured.options.method).toBe('POST'); + }); + + it('adds method M-PESA and currency KES to payload', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { invoice_id: 'inv_123' } }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.mpesaStkPush({ + phone_number: '254722000000', + name: 'John Doe', + email: 'john@example.com', + amount: 10, + }); + + const body = JSON.parse(captured.body); + expect(body.method).toBe('M-PESA'); + expect(body.currency).toBe('KES'); + expect(body.phone_number).toBe('254722000000'); + }); + + it('preserves auth header (authenticated endpoint)', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { invoice_id: 'inv_123' } }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.mpesaStkPush({ + phone_number: '254722000000', + name: 'John Doe', + email: 'john@example.com', + amount: 10, + }); + + expect(captured.options.headers).toHaveProperty( + 'Authorization', + `Bearer ${secretKey}` + ); + }); + }); + + describe('status()', () => { + it('POSTs to /api/v1/payment/status/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { state: 'COMPLETE' } }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.status('inv_123'); + + expect(captured.options.path).toBe('/api/v1/payment/status/'); + expect(captured.options.method).toBe('POST'); + }); + + it('sends invoice_id in payload', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { state: 'COMPLETE' } }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.status('inv_456'); + + const body = JSON.parse(captured.body); + expect(body.invoice_id).toBe('inv_456'); + }); + + it('clears secret_key for public endpoint', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { state: 'PENDING' } }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.status('inv_123'); + + expect(captured.options.headers).not.toHaveProperty('Authorization'); + }); + + it('includes checkout_id and signature when provided', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { state: 'COMPLETE' } }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.status('inv_123', 'chk_456', 'sig_789'); + + const body = JSON.parse(captured.body); + expect(body.invoice_id).toBe('inv_123'); + expect(body.checkout_id).toBe('chk_456'); + expect(body.signature).toBe('sig_789'); + }); + + it('omits checkout_id and signature when not provided', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { state: 'COMPLETE' } }, + }); + const collection = new Collection(pubKey, secretKey, true); + + await collection.status('inv_123'); + + const body = JSON.parse(captured.body); + expect(body.invoice_id).toBe('inv_123'); + expect(body).not.toHaveProperty('checkout_id'); + expect(body).not.toHaveProperty('signature'); + }); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..22eb6b0 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,126 @@ +import https from 'https'; +import { EventEmitter } from 'events'; + +jest.mock('https'); + +const mockedHttps = https as jest.Mocked; + +export interface MockResponseOptions { + statusCode: number; + body: any; +} + +export interface CapturedRequest { + options: https.RequestOptions; + body: string; +} + +/** + * Sets up https.request mock to return a controlled response. + * Returns a reference to the captured request for assertions. + */ +export function mockHttpsRequest( + responseOpts: MockResponseOptions +): { captured: CapturedRequest } { + const captured: CapturedRequest = { + options: {}, + body: '', + }; + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + captured.options = options; + + const response = new EventEmitter() as any; + response.statusCode = responseOpts.statusCode; + response.resume = jest.fn(); + + // Simulate async response + process.nextTick(() => { + callback(response); + const data = JSON.stringify(responseOpts.body); + response.emit('data', Buffer.from(data)); + response.emit('end'); + }); + + const req = new EventEmitter() as any; + req.write = jest.fn((data: string) => { + captured.body += data; + }); + req.end = jest.fn(); + + return req; + }); + + return { captured }; +} + +/** + * Sets up https.request mock to simulate an error response (non-200/201). + */ +export function mockHttpsError( + statusCode: number, + errorBody: any +): { captured: CapturedRequest } { + const captured: CapturedRequest = { + options: {}, + body: '', + }; + + mockedHttps.request.mockImplementation((options: any, callback: any) => { + captured.options = options; + + const response = new EventEmitter() as any; + response.statusCode = statusCode; + response.resume = jest.fn(); + + process.nextTick(() => { + callback(response); + const data = JSON.stringify(errorBody); + response.emit('data', Buffer.from(data)); + }); + + const req = new EventEmitter() as any; + req.write = jest.fn((data: string) => { + captured.body += data; + }); + req.end = jest.fn(); + + return req; + }); + + return { captured }; +} + +/** + * Sets up https.request mock to simulate a network error. + */ +export function mockHttpsNetworkError( + errorMessage: string +): { captured: CapturedRequest } { + const captured: CapturedRequest = { + options: {}, + body: '', + }; + + mockedHttps.request.mockImplementation((options: any, _callback: any) => { + captured.options = options; + + const req = new EventEmitter() as any; + req.write = jest.fn((data: string) => { + captured.body += data; + }); + req.end = jest.fn(() => { + process.nextTick(() => { + req.emit('error', new Error(errorMessage)); + }); + }); + + return req; + }); + + return { captured }; +} + +export function resetMocks(): void { + mockedHttps.request.mockReset(); +} diff --git a/tests/intasend.test.ts b/tests/intasend.test.ts new file mode 100644 index 0000000..7e704ab --- /dev/null +++ b/tests/intasend.test.ts @@ -0,0 +1,100 @@ +import IntaSend from '../src/intasend'; +import Wallet from '../src/wallets'; +import Collection from '../src/collection'; +import Payouts from '../src/payouts'; +import Refunds from '../src/refunds'; + +describe('IntaSend', () => { + const pubKey = 'ISPubKey_test_abc123'; + const secretKey = 'ISSecretKey_test_secret'; + + describe('constructor', () => { + it('stores publishable_key', () => { + const client = new IntaSend(pubKey, secretKey, true); + expect(client.publishable_key).toBe(pubKey); + }); + + it('stores secret_key', () => { + const client = new IntaSend(pubKey, secretKey, true); + expect(client.secret_key).toBe(secretKey); + }); + + it('stores test_mode', () => { + const client = new IntaSend(pubKey, secretKey, true); + expect(client.test_mode).toBe(true); + }); + }); + + describe('test mode', () => { + it('uses sandbox URL when test_mode is true', () => { + const client = new IntaSend(pubKey, secretKey, true); + expect(client.test_base_url).toBe('sandbox.intasend.com'); + }); + + it('uses production URL when test_mode is false', () => { + const client = new IntaSend(pubKey, secretKey, false); + expect(client.prod_base_url).toBe('payment.intasend.com'); + expect(client.test_mode).toBe(false); + }); + }); + + describe('service factories', () => { + it('wallets() returns a Wallet instance', () => { + const client = new IntaSend(pubKey, secretKey, true); + const wallets = client.wallets(); + expect(wallets).toBeInstanceOf(Wallet); + }); + + it('collection() returns a Collection instance', () => { + const client = new IntaSend(pubKey, secretKey, true); + const collection = client.collection(); + expect(collection).toBeInstanceOf(Collection); + }); + + it('payouts() returns a Payouts instance', () => { + const client = new IntaSend(pubKey, secretKey, true); + const payouts = client.payouts(); + expect(payouts).toBeInstanceOf(Payouts); + }); + + it('refunds() returns a Refunds instance', () => { + const client = new IntaSend(pubKey, secretKey, true); + const refunds = client.refunds(); + expect(refunds).toBeInstanceOf(Refunds); + }); + }); + + describe('service credential propagation', () => { + it('wallets() receives correct credentials', () => { + const client = new IntaSend(pubKey, secretKey, true); + const wallets = client.wallets(); + expect(wallets.publishable_key).toBe(pubKey); + expect(wallets.secret_key).toBe(secretKey); + expect(wallets.test_mode).toBe(true); + }); + + it('collection() receives correct credentials', () => { + const client = new IntaSend(pubKey, secretKey, false); + const collection = client.collection(); + expect(collection.publishable_key).toBe(pubKey); + expect(collection.secret_key).toBe(secretKey); + expect(collection.test_mode).toBe(false); + }); + + it('payouts() receives correct credentials', () => { + const client = new IntaSend(pubKey, secretKey, true); + const payouts = client.payouts(); + expect(payouts.publishable_key).toBe(pubKey); + expect(payouts.secret_key).toBe(secretKey); + expect(payouts.test_mode).toBe(true); + }); + + it('refunds() receives correct credentials', () => { + const client = new IntaSend(pubKey, secretKey, true); + const refunds = client.refunds(); + expect(refunds.publishable_key).toBe(pubKey); + expect(refunds.secret_key).toBe(secretKey); + expect(refunds.test_mode).toBe(true); + }); + }); +}); diff --git a/tests/payouts.test.ts b/tests/payouts.test.ts new file mode 100644 index 0000000..d26edce --- /dev/null +++ b/tests/payouts.test.ts @@ -0,0 +1,217 @@ +import { mockHttpsRequest, resetMocks } from './helpers'; +import Payouts from '../src/payouts'; + +describe('Payouts', () => { + const pubKey = 'ISPubKey_test_abc123'; + const secretKey = 'ISSecretKey_test_secret'; + + afterEach(() => { + resetMocks(); + }); + + const sampleTransactions = [ + { + name: 'John Doe', + account: '254722000000', + amount: '100', + }, + ]; + + describe('initiate()', () => { + it('POSTs to /api/v1/send-money/initiate/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { tracking_id: 'trk_123', status: 'Preview and approve' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.initiate({ + currency: 'KES', + transactions: sampleTransactions, + }); + + expect(captured.options.path).toBe('/api/v1/send-money/initiate/'); + expect(captured.options.method).toBe('POST'); + }); + + it('sends correct payload', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { tracking_id: 'trk_123' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.initiate({ + currency: 'KES', + transactions: sampleTransactions, + }); + + const body = JSON.parse(captured.body); + expect(body.currency).toBe('KES'); + expect(body.transactions).toEqual(sampleTransactions); + }); + + it('includes auth header', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { tracking_id: 'trk_123' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.initiate({ + currency: 'KES', + transactions: sampleTransactions, + }); + + expect(captured.options.headers).toHaveProperty( + 'Authorization', + `Bearer ${secretKey}` + ); + }); + }); + + describe('mpesa()', () => { + it('sets provider to MPESA-B2C', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { tracking_id: 'trk_mpesa' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.mpesa({ + currency: 'KES', + transactions: sampleTransactions, + }); + + const body = JSON.parse(captured.body); + expect(body.provider).toBe('MPESA-B2C'); + expect(captured.options.path).toBe('/api/v1/send-money/initiate/'); + }); + }); + + describe('mpesaB2B()', () => { + it('sets provider to MPESA-B2B', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { tracking_id: 'trk_b2b' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.mpesaB2B({ + currency: 'KES', + transactions: [ + { + name: 'ABC Corp', + account: 247247, + amount: '500', + account_type: 'PayBill', + account_reference: '11111111', + }, + ], + }); + + const body = JSON.parse(captured.body); + expect(body.provider).toBe('MPESA-B2B'); + expect(body.transactions[0].account_type).toBe('PayBill'); + }); + }); + + describe('bank()', () => { + it('sets provider to PESALINK', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { tracking_id: 'trk_bank' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.bank({ + currency: 'KES', + transactions: sampleTransactions, + }); + + const body = JSON.parse(captured.body); + expect(body.provider).toBe('PESALINK'); + }); + }); + + describe('intasend()', () => { + it('sets provider to INTASEND', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { tracking_id: 'trk_p2p' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.intasend({ + currency: 'KES', + transactions: sampleTransactions, + }); + + const body = JSON.parse(captured.body); + expect(body.provider).toBe('INTASEND'); + }); + }); + + describe('airtime()', () => { + it('sets provider to AIRTIME', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { tracking_id: 'trk_air' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.airtime({ + currency: 'KES', + transactions: sampleTransactions, + }); + + const body = JSON.parse(captured.body); + expect(body.provider).toBe('AIRTIME'); + }); + }); + + describe('approve()', () => { + it('POSTs to /api/v1/send-money/approve/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { status: 'Approved' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.approve({ tracking_id: 'trk_123', nonce: 'abc' }); + + expect(captured.options.path).toBe('/api/v1/send-money/approve/'); + expect(captured.options.method).toBe('POST'); + }); + + it('sends the approval payload', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { status: 'Approved' }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + const approvalPayload = { tracking_id: 'trk_123', nonce: 'nonce_456' }; + + await payouts.approve(approvalPayload); + + const body = JSON.parse(captured.body); + expect(body.tracking_id).toBe('trk_123'); + expect(body.nonce).toBe('nonce_456'); + }); + }); + + describe('status()', () => { + it('POSTs to /api/v1/send-money/status/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { status: 'Complete', transactions: [] }, + }); + const payouts = new Payouts(pubKey, secretKey, true); + + await payouts.status({ tracking_id: 'trk_123' }); + + expect(captured.options.path).toBe('/api/v1/send-money/status/'); + expect(captured.options.method).toBe('POST'); + }); + }); +}); diff --git a/tests/refunds.test.ts b/tests/refunds.test.ts new file mode 100644 index 0000000..2d9d0ab --- /dev/null +++ b/tests/refunds.test.ts @@ -0,0 +1,117 @@ +import { mockHttpsRequest, resetMocks } from './helpers'; +import Refunds from '../src/refunds'; + +describe('Refunds', () => { + const pubKey = 'ISPubKey_test_abc123'; + const secretKey = 'ISSecretKey_test_secret'; + + afterEach(() => { + resetMocks(); + }); + + describe('list()', () => { + it('GETs /api/v1/chargebacks/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: [{ id: 'cb_1', amount: '200.00', status: 'PENDING' }], + }); + const refunds = new Refunds(pubKey, secretKey, true); + + const result = await refunds.list(); + + expect(captured.options.path).toBe('/api/v1/chargebacks/'); + expect(captured.options.method).toBe('GET'); + expect(result).toEqual([{ id: 'cb_1', amount: '200.00', status: 'PENDING' }]); + }); + + it('includes auth header', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: [], + }); + const refunds = new Refunds(pubKey, secretKey, true); + + await refunds.list(); + + expect(captured.options.headers).toHaveProperty( + 'Authorization', + `Bearer ${secretKey}` + ); + }); + }); + + describe('create()', () => { + it('POSTs to /api/v1/chargebacks/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 201, + body: { id: 'cb_new', status: 'PENDING' }, + }); + const refunds = new Refunds(pubKey, secretKey, true); + + await refunds.create({ + invoice: 'INV-001', + amount: 200, + reason: 'UNAVAILABLE', + reason_details: 'Service was not available', + }); + + expect(captured.options.path).toBe('/api/v1/chargebacks/'); + expect(captured.options.method).toBe('POST'); + }); + + it('sends correct payload', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 201, + body: { id: 'cb_new' }, + }); + const refunds = new Refunds(pubKey, secretKey, true); + + await refunds.create({ + invoice: 'INV-002', + amount: 150, + reason: 'DUPLICATE', + reason_details: 'Duplicate charge', + }); + + const body = JSON.parse(captured.body); + expect(body.invoice).toBe('INV-002'); + expect(body.amount).toBe(150); + expect(body.reason).toBe('DUPLICATE'); + expect(body.reason_details).toBe('Duplicate charge'); + }); + + it('returns created refund', async () => { + mockHttpsRequest({ + statusCode: 201, + body: { id: 'cb_333', status: 'PENDING', amount: '200.00' }, + }); + const refunds = new Refunds(pubKey, secretKey, true); + + const result = await refunds.create({ + invoice: 'INV-003', + amount: 200, + reason: 'OTHER', + }); + + expect(result.id).toBe('cb_333'); + expect(result.status).toBe('PENDING'); + }); + }); + + describe('get()', () => { + it('GETs /api/v1/chargebacks/{id}/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { id: 'cb_456', amount: '100.00', status: 'APPROVED' }, + }); + const refunds = new Refunds(pubKey, secretKey, true); + + const result = await refunds.get('cb_456'); + + expect(captured.options.path).toBe('/api/v1/chargebacks/cb_456/'); + expect(captured.options.method).toBe('GET'); + expect(result.id).toBe('cb_456'); + expect(result.status).toBe('APPROVED'); + }); + }); +}); diff --git a/tests/requests.test.ts b/tests/requests.test.ts new file mode 100644 index 0000000..3d6bfbd --- /dev/null +++ b/tests/requests.test.ts @@ -0,0 +1,205 @@ +import { mockHttpsRequest, mockHttpsError, mockHttpsNetworkError, resetMocks } from './helpers'; +import RequestClient from '../src/requests'; + +describe('RequestClient', () => { + const pubKey = 'ISPubKey_test_abc123'; + const secretKey = 'ISSecretKey_test_secret'; + + afterEach(() => { + resetMocks(); + }); + + describe('authenticated requests', () => { + it('includes Authorization header with Bearer token', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: { ok: true } }); + const client = new RequestClient(pubKey, secretKey, true); + + await client.send({ foo: 'bar' }, '/api/v1/test/', 'POST'); + + expect(captured.options.headers).toHaveProperty('Authorization', `Bearer ${secretKey}`); + }); + + it('includes INTASEND_PUBLIC_API_KEY header', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: { ok: true } }); + const client = new RequestClient(pubKey, secretKey, true); + + await client.send({ foo: 'bar' }, '/api/v1/test/', 'POST'); + + expect(captured.options.headers).toHaveProperty('INTASEND_PUBLIC_API_KEY', pubKey); + }); + + it('includes Content-Type application/json header', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: { ok: true } }); + const client = new RequestClient(pubKey, secretKey, true); + + await client.send({}, '/api/v1/test/', 'POST'); + + expect(captured.options.headers).toHaveProperty('Content-Type', 'application/json'); + }); + + it('adds public_key to payload', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: {} }); + const client = new RequestClient(pubKey, secretKey, true); + + await client.send({ amount: 100 }, '/api/v1/test/', 'POST'); + + const sentBody = JSON.parse(captured.body); + expect(sentBody.public_key).toBe(pubKey); + }); + }); + + describe('public requests (no secret key)', () => { + it('does not include Authorization header when secret_key is empty', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: { ok: true } }); + const client = new RequestClient(pubKey, '', true); + + await client.send({}, '/api/v1/checkout/', 'POST'); + + expect(captured.options.headers).not.toHaveProperty('Authorization'); + }); + + it('still includes INTASEND_PUBLIC_API_KEY header', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: { ok: true } }); + const client = new RequestClient(pubKey, '', true); + + await client.send({}, '/api/v1/checkout/', 'POST'); + + expect(captured.options.headers).toHaveProperty('INTASEND_PUBLIC_API_KEY', pubKey); + }); + }); + + describe('request routing', () => { + it('uses sandbox hostname when test_mode is true', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: {} }); + const client = new RequestClient(pubKey, secretKey, true); + + await client.send({}, '/api/v1/test/', 'GET'); + + expect(captured.options.hostname).toBe('sandbox.intasend.com'); + }); + + it('uses production hostname when test_mode is false', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: {} }); + const client = new RequestClient(pubKey, secretKey, false); + + await client.send({}, '/api/v1/test/', 'GET'); + + expect(captured.options.hostname).toBe('payment.intasend.com'); + }); + + it('uses port 443', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: {} }); + const client = new RequestClient(pubKey, secretKey, true); + + await client.send({}, '/api/v1/test/', 'GET'); + + expect(captured.options.port).toBe(443); + }); + + it('sends to the correct path', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: {} }); + const client = new RequestClient(pubKey, secretKey, true); + + await client.send({}, '/api/v1/wallets/', 'GET'); + + expect(captured.options.path).toBe('/api/v1/wallets/'); + }); + + it('uses the specified HTTP method', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: {} }); + const client = new RequestClient(pubKey, secretKey, true); + + await client.send({}, '/api/v1/wallets/', 'GET'); + + expect(captured.options.method).toBe('GET'); + }); + + it('defaults to POST method', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: {} }); + const client = new RequestClient(pubKey, secretKey, true); + + await client.send({}, '/api/v1/test/'); + + expect(captured.options.method).toBe('POST'); + }); + }); + + describe('request body', () => { + it('serializes payload as JSON', async () => { + const { captured } = mockHttpsRequest({ statusCode: 200, body: {} }); + const client = new RequestClient(pubKey, secretKey, true); + const payload = { amount: 100, currency: 'KES' }; + + await client.send(payload, '/api/v1/test/', 'POST'); + + const sentBody = JSON.parse(captured.body); + expect(sentBody.amount).toBe(100); + expect(sentBody.currency).toBe('KES'); + }); + }); + + describe('response handling', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('resolves with parsed JSON on 200', async () => { + mockHttpsRequest({ statusCode: 200, body: { id: '123', status: 'COMPLETE' } }); + const client = new RequestClient(pubKey, secretKey, true); + + const result = await client.send({}, '/api/v1/test/', 'GET'); + + expect(result).toEqual({ id: '123', status: 'COMPLETE' }); + }); + + it('resolves with parsed JSON on 201', async () => { + mockHttpsRequest({ statusCode: 201, body: { id: 'new-456' } }); + const client = new RequestClient(pubKey, secretKey, true); + + const result = await client.send({}, '/api/v1/test/', 'POST'); + + expect(result).toEqual({ id: 'new-456' }); + }); + + it('rejects on 400 error', async () => { + mockHttpsError(400, { detail: 'Bad request' }); + const client = new RequestClient(pubKey, secretKey, true); + + await expect(client.send({}, '/api/v1/test/', 'POST')).rejects.toBeDefined(); + }); + + it('rejects on 401 error', async () => { + mockHttpsError(401, { detail: 'Unauthorized' }); + const client = new RequestClient(pubKey, secretKey, true); + + await expect(client.send({}, '/api/v1/test/', 'POST')).rejects.toBeDefined(); + }); + + it('rejects on 404 error', async () => { + mockHttpsError(404, { detail: 'Not found' }); + const client = new RequestClient(pubKey, secretKey, true); + + await expect(client.send({}, '/api/v1/test/', 'GET')).rejects.toBeDefined(); + }); + + it('rejects on 500 error', async () => { + mockHttpsError(500, { detail: 'Server error' }); + const client = new RequestClient(pubKey, secretKey, true); + + await expect(client.send({}, '/api/v1/test/', 'POST')).rejects.toBeDefined(); + }); + + it('rejects on network error', async () => { + mockHttpsNetworkError('ECONNREFUSED'); + const client = new RequestClient(pubKey, secretKey, true); + + await expect(client.send({}, '/api/v1/test/', 'POST')).rejects.toBe('ECONNREFUSED'); + }); + }); +}); diff --git a/tests/wallets.test.ts b/tests/wallets.test.ts new file mode 100644 index 0000000..6e9e00d --- /dev/null +++ b/tests/wallets.test.ts @@ -0,0 +1,223 @@ +import { mockHttpsRequest, resetMocks } from './helpers'; +import Wallet from '../src/wallets'; + +describe('Wallet', () => { + const pubKey = 'ISPubKey_test_abc123'; + const secretKey = 'ISSecretKey_test_secret'; + + afterEach(() => { + resetMocks(); + }); + + describe('list()', () => { + it('GETs /api/v1/wallets/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: [{ wallet_id: 'w1', label: 'Main' }], + }); + const wallet = new Wallet(pubKey, secretKey, true); + + const result = await wallet.list(); + + expect(captured.options.path).toBe('/api/v1/wallets/'); + expect(captured.options.method).toBe('GET'); + expect(result).toEqual([{ wallet_id: 'w1', label: 'Main' }]); + }); + + it('includes auth header', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: [], + }); + const wallet = new Wallet(pubKey, secretKey, true); + + await wallet.list(); + + expect(captured.options.headers).toHaveProperty( + 'Authorization', + `Bearer ${secretKey}` + ); + }); + }); + + describe('create()', () => { + it('POSTs to /api/v1/wallets/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 201, + body: { wallet_id: 'w_new', label: 'SDK-Test' }, + }); + const wallet = new Wallet(pubKey, secretKey, true); + + await wallet.create({ + label: 'SDK-Test', + wallet_type: 'WORKING', + currency: 'KES', + }); + + expect(captured.options.path).toBe('/api/v1/wallets/'); + expect(captured.options.method).toBe('POST'); + }); + + it('sends correct payload', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 201, + body: { wallet_id: 'w_new' }, + }); + const wallet = new Wallet(pubKey, secretKey, true); + + await wallet.create({ + label: 'My Wallet', + wallet_type: 'WORKING', + currency: 'KES', + }); + + const body = JSON.parse(captured.body); + expect(body.label).toBe('My Wallet'); + expect(body.wallet_type).toBe('WORKING'); + expect(body.currency).toBe('KES'); + }); + }); + + describe('get()', () => { + it('GETs /api/v1/wallets/{id}/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { wallet_id: 'w_123', label: 'Main', available_balance: '1000.00' }, + }); + const wallet = new Wallet(pubKey, secretKey, true); + + const result = await wallet.get('w_123'); + + expect(captured.options.path).toBe('/api/v1/wallets/w_123/'); + expect(captured.options.method).toBe('GET'); + expect(result.wallet_id).toBe('w_123'); + }); + }); + + describe('transactions()', () => { + it('GETs /api/v1/wallets/{id}/transactions/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: [{ id: 'txn_1', amount: '500.00' }], + }); + const wallet = new Wallet(pubKey, secretKey, true); + + const result = await wallet.transactions('w_123'); + + expect(captured.options.path).toBe('/api/v1/wallets/w_123/transactions/'); + expect(captured.options.method).toBe('GET'); + expect(result).toEqual([{ id: 'txn_1', amount: '500.00' }]); + }); + }); + + describe('intraTransfer()', () => { + it('POSTs to /api/v1/wallets/{sourceID}/intra_transfer/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { status: 'Complete' }, + }); + const wallet = new Wallet(pubKey, secretKey, true); + + await wallet.intraTransfer('w_source', 'w_dest', 500, 'Test transfer'); + + expect(captured.options.path).toBe('/api/v1/wallets/w_source/intra_transfer/'); + expect(captured.options.method).toBe('POST'); + }); + + it('sends wallet_id, amount, and narrative in payload', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { status: 'Complete' }, + }); + const wallet = new Wallet(pubKey, secretKey, true); + + await wallet.intraTransfer('w_src', 'w_dst', 250, 'Payment for services'); + + const body = JSON.parse(captured.body); + expect(body.wallet_id).toBe('w_dst'); + expect(body.amount).toBe(250); + expect(body.narrative).toBe('Payment for services'); + }); + }); + + describe('fundMPesa()', () => { + it('POSTs to /api/v1/payment/mpesa-stk-push/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { invoice_id: 'inv_fund' } }, + }); + const wallet = new Wallet(pubKey, secretKey, true); + + await wallet.fundMPesa({ + phone_number: '254722000000', + name: 'John', + email: 'john@example.com', + amount: 1000, + }); + + expect(captured.options.path).toBe('/api/v1/payment/mpesa-stk-push/'); + expect(captured.options.method).toBe('POST'); + }); + + it('adds method M-PESA and currency KES', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { invoice: { invoice_id: 'inv_fund' } }, + }); + const wallet = new Wallet(pubKey, secretKey, true); + + await wallet.fundMPesa({ + phone_number: '254722000000', + name: 'John', + email: 'john@example.com', + amount: 1000, + }); + + const body = JSON.parse(captured.body); + expect(body.method).toBe('M-PESA'); + expect(body.currency).toBe('KES'); + expect(body.phone_number).toBe('254722000000'); + }); + }); + + describe('fundCheckout()', () => { + it('POSTs to /api/v1/checkout/', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { id: 'chk_fund', url: 'https://sandbox.intasend.com/checkout/fund/' }, + }); + const wallet = new Wallet(pubKey, secretKey, true); + + await wallet.fundCheckout({ + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + host: 'https://example.com', + amount: 500, + currency: 'KES', + }); + + expect(captured.options.path).toBe('/api/v1/checkout/'); + expect(captured.options.method).toBe('POST'); + }); + + it('clears secret_key for public endpoint', async () => { + const { captured } = mockHttpsRequest({ + statusCode: 200, + body: { id: 'chk_fund' }, + }); + const wallet = new Wallet(pubKey, secretKey, true); + + await wallet.fundCheckout({ + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + host: 'https://example.com', + amount: 500, + currency: 'KES', + }); + + expect(captured.options.headers).not.toHaveProperty('Authorization'); + }); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..ae16a4f --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/tsconfig.json b/tsconfig.json index 412deb0..38af0b3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,13 @@ { - "include": ["src/**/*"], "compilerOptions": { - "allowJs": true, + "target": "ES2018", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, "declaration": true, + "declarationMap": true, + "sourceMap": true, "outDir": "dist", - "declarationMap": true + "types": ["node", "jest"] } }