blaaiz-nodejs-sdk 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,12 +13,17 @@ npm install blaaiz-nodejs-sdk
13
13
  ```javascript
14
14
  const { Blaaiz } = require('blaaiz-nodejs-sdk');
15
15
 
16
- // Initialize the SDK
16
+ // Initialize the SDK (defaults to development environment)
17
17
  const blaaiz = new Blaaiz('your-api-key-here', {
18
18
  baseURL: 'https://api-dev.blaaiz.com', // Optional: defaults to dev environment
19
19
  timeout: 30000 // Optional: request timeout in milliseconds
20
20
  });
21
21
 
22
+ // For production, change the baseURL:
23
+ // const blaaiz = new Blaaiz('your-prod-api-key', {
24
+ // baseURL: 'https://api.blaaiz.com'
25
+ // });
26
+
22
27
  // Test the connection
23
28
  const isConnected = await blaaiz.testConnection();
24
29
  console.log('API Connected:', isConnected);
@@ -594,16 +599,55 @@ app.post('/webhooks/collection', (req, res) => {
594
599
 
595
600
  ## Environment Configuration
596
601
 
602
+ The SDK defaults to the development environment. To use different environments:
603
+
597
604
  ```javascript
598
- // Development
605
+ // Development (default)
606
+ const blaaizDev = new Blaaiz('dev-api-key');
607
+ // OR explicitly specify dev URL
599
608
  const blaaizDev = new Blaaiz('dev-api-key', {
600
609
  baseURL: 'https://api-dev.blaaiz.com'
601
610
  });
602
611
 
603
- // Production (when available)
612
+ // Production
604
613
  const blaaizProd = new Blaaiz('prod-api-key', {
605
614
  baseURL: 'https://api.blaaiz.com'
606
615
  });
616
+
617
+ // Staging (if available)
618
+ const blaaizStaging = new Blaaiz('staging-api-key', {
619
+ baseURL: 'https://api-staging.blaaiz.com'
620
+ });
621
+ ```
622
+
623
+ ### Environment Variables Approach (Recommended)
624
+
625
+ ```javascript
626
+ const { Blaaiz } = require('blaaiz-nodejs-sdk');
627
+
628
+ const blaaiz = new Blaaiz(process.env.BLAAIZ_API_KEY, {
629
+ baseURL: process.env.BLAAIZ_API_URL || 'https://api-dev.blaaiz.com'
630
+ });
631
+ ```
632
+
633
+ **Environment Variables:**
634
+ - `BLAAIZ_API_KEY` - Your API key
635
+ - `BLAAIZ_API_URL` - API base URL (optional, defaults to dev)
636
+ - `BLAAIZ_WEBHOOK_SECRET` - Webhook secret for signature verification
637
+ - `BLAAIZ_TEST_WALLET_ID` - Test wallet ID for integration tests (optional)
638
+
639
+ **.env file example:**
640
+ ```bash
641
+ # Development
642
+ BLAAIZ_API_KEY=your-dev-api-key
643
+ BLAAIZ_API_URL=https://api-dev.blaaiz.com
644
+ BLAAIZ_WEBHOOK_SECRET=your-webhook-secret
645
+ BLAAIZ_TEST_WALLET_ID=your-test-wallet-id
646
+
647
+ # Production
648
+ # BLAAIZ_API_KEY=your-prod-api-key
649
+ # BLAAIZ_API_URL=https://api.blaaiz.com
650
+ # BLAAIZ_WEBHOOK_SECRET=your-prod-webhook-secret
607
651
  ```
608
652
 
609
653
  ## Best Practices
package/index.d.ts CHANGED
@@ -216,9 +216,10 @@ export interface FileUploadData {
216
216
  }
217
217
 
218
218
  export interface PreSignedUrlResponse {
219
- url: string;
219
+ message: string;
220
220
  file_id: string;
221
- headers: { [key: string]: string };
221
+ url: string;
222
+ headers: any[];
222
223
  }
223
224
 
224
225
  // Webhook Types
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blaaiz-nodejs-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "Official Node.js SDK for Blaaiz RaaS (Remittance as a Service) API",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -8,10 +8,12 @@
8
8
  "test": "jest",
9
9
  "test:watch": "jest --watch",
10
10
  "test:coverage": "jest --coverage",
11
+ "test:integration": "jest --testPathPattern=integration",
11
12
  "lint": "eslint src/**/*.js",
12
13
  "lint:fix": "eslint src/**/*.js --fix",
14
+ "audit": "npm audit --audit-level=moderate",
13
15
  "docs": "jsdoc -d docs src/**/*.js",
14
- "build": "npm run lint && npm test",
16
+ "build": "npm run lint",
15
17
  "prepublishOnly": "npm run build"
16
18
  },
17
19
  "keywords": [
@@ -55,11 +57,13 @@
55
57
  "eslint-plugin-n": "^15.7.0",
56
58
  "eslint-plugin-promise": "^6.1.1",
57
59
  "jest": "^29.5.0",
58
- "jsdoc": "^4.0.2"
60
+ "jsdoc": "^4.0.2",
61
+ "audit-ci": "^7.0.1"
59
62
  },
60
63
  "files": [
61
64
  "index.js",
62
65
  "index.d.ts",
66
+ "src/",
63
67
  "README.md",
64
68
  "LICENSE"
65
69
  ],
package/src/client.js ADDED
@@ -0,0 +1,96 @@
1
+ const https = require('https')
2
+ const http = require('http')
3
+ const { URL } = require('url')
4
+ const BlaaizError = require('./error')
5
+
6
+ class BlaaizAPIClient {
7
+ constructor (apiKey, options = {}) {
8
+ if (!apiKey) {
9
+ throw new Error('API key is required')
10
+ }
11
+
12
+ this.apiKey = apiKey
13
+ this.baseURL = options.baseURL || 'https://api-dev.blaaiz.com'
14
+ this.timeout = options.timeout || 30000
15
+ this.defaultHeaders = {
16
+ 'x-blaaiz-api-key': this.apiKey,
17
+ Accept: 'application/json',
18
+ 'Content-Type': 'application/json',
19
+ 'User-Agent': 'Blaaiz-NodeJS-SDK/1.0.0'
20
+ }
21
+ }
22
+
23
+ async makeRequest (method, endpoint, data = null, headers = {}) {
24
+ return new Promise((resolve, reject) => {
25
+ const url = new URL(endpoint, this.baseURL)
26
+ const options = {
27
+ hostname: url.hostname,
28
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
29
+ path: url.pathname + url.search,
30
+ method: method.toUpperCase(),
31
+ headers: { ...this.defaultHeaders, ...headers },
32
+ timeout: this.timeout
33
+ }
34
+
35
+ const client = url.protocol === 'https:' ? https : http
36
+ const req = client.request(options, (res) => {
37
+ let responseData = ''
38
+
39
+ res.on('data', (chunk) => {
40
+ responseData += chunk
41
+ })
42
+
43
+ res.on('end', () => {
44
+ try {
45
+ const parsedData = JSON.parse(responseData)
46
+
47
+ if (res.statusCode >= 200 && res.statusCode < 300) {
48
+ resolve({
49
+ data: parsedData,
50
+ status: res.statusCode,
51
+ headers: res.headers
52
+ })
53
+ } else {
54
+ reject(new BlaaizError(
55
+ parsedData.message || 'API request failed',
56
+ res.statusCode,
57
+ parsedData.code
58
+ ))
59
+ }
60
+ } catch (error) {
61
+ reject(new BlaaizError(
62
+ 'Failed to parse API response',
63
+ res.statusCode,
64
+ 'PARSE_ERROR'
65
+ ))
66
+ }
67
+ })
68
+ })
69
+
70
+ req.on('error', (error) => {
71
+ reject(new BlaaizError(
72
+ `Request failed: ${error.message}`,
73
+ null,
74
+ 'REQUEST_ERROR'
75
+ ))
76
+ })
77
+
78
+ req.on('timeout', () => {
79
+ req.destroy()
80
+ reject(new BlaaizError(
81
+ 'Request timeout',
82
+ null,
83
+ 'TIMEOUT_ERROR'
84
+ ))
85
+ })
86
+
87
+ if (data && method.toUpperCase() !== 'GET') {
88
+ req.write(JSON.stringify(data))
89
+ }
90
+
91
+ req.end()
92
+ })
93
+ }
94
+ }
95
+
96
+ module.exports = BlaaizAPIClient
package/src/error.js ADDED
@@ -0,0 +1,10 @@
1
+ class BlaaizError extends Error {
2
+ constructor(message, status, code) {
3
+ super(message)
4
+ this.name = 'BlaaizError'
5
+ this.status = status
6
+ this.code = code
7
+ }
8
+ }
9
+
10
+ module.exports = BlaaizError
package/src/index.js ADDED
@@ -0,0 +1,132 @@
1
+ const BlaaizError = require('./error')
2
+ const BlaaizAPIClient = require('./client')
3
+
4
+ const CustomerService = require('./services/CustomerService')
5
+ const CollectionService = require('./services/CollectionService')
6
+ const PayoutService = require('./services/PayoutService')
7
+ const WalletService = require('./services/WalletService')
8
+ const VirtualBankAccountService = require('./services/VirtualBankAccountService')
9
+ const TransactionService = require('./services/TransactionService')
10
+ const BankService = require('./services/BankService')
11
+ const CurrencyService = require('./services/CurrencyService')
12
+ const FeesService = require('./services/FeesService')
13
+ const FileService = require('./services/FileService')
14
+ const WebhookService = require('./services/WebhookService')
15
+
16
+ class Blaaiz {
17
+ constructor (apiKey, options = {}) {
18
+ this.client = new BlaaizAPIClient(apiKey, options)
19
+
20
+ this.customers = new CustomerService(this.client)
21
+ this.collections = new CollectionService(this.client)
22
+ this.payouts = new PayoutService(this.client)
23
+ this.wallets = new WalletService(this.client)
24
+ this.virtualBankAccounts = new VirtualBankAccountService(this.client)
25
+ this.transactions = new TransactionService(this.client)
26
+ this.banks = new BankService(this.client)
27
+ this.currencies = new CurrencyService(this.client)
28
+ this.fees = new FeesService(this.client)
29
+ this.files = new FileService(this.client)
30
+ this.webhooks = new WebhookService(this.client)
31
+ }
32
+
33
+ async testConnection () {
34
+ try {
35
+ await this.currencies.list()
36
+ return true
37
+ } catch (error) {
38
+ return false
39
+ }
40
+ }
41
+
42
+ async createCompleteePayout (payoutConfig) {
43
+ const { customerData, payoutData } = payoutConfig
44
+
45
+ try {
46
+ let customerId = payoutData.customer_id
47
+ if (!customerId && customerData) {
48
+ const customerResult = await this.customers.create(customerData)
49
+ customerId = customerResult.data.data.id
50
+ }
51
+
52
+ const feeBreakdown = await this.fees.getBreakdown({
53
+ from_currency_id: payoutData.from_currency_id,
54
+ to_currency_id: payoutData.to_currency_id,
55
+ from_amount: payoutData.from_amount
56
+ })
57
+
58
+ const payoutResult = await this.payouts.initiate({
59
+ ...payoutData,
60
+ customer_id: customerId
61
+ })
62
+
63
+ return {
64
+ customer_id: customerId,
65
+ payout: payoutResult.data,
66
+ fees: feeBreakdown.data
67
+ }
68
+ } catch (error) {
69
+ throw new BlaaizError(
70
+ `Complete payout failed: ${error.message}`,
71
+ error.status,
72
+ error.code
73
+ )
74
+ }
75
+ }
76
+
77
+ async createCompleteCollection (collectionConfig) {
78
+ const { customerData, collectionData, createVBA = false } = collectionConfig
79
+
80
+ try {
81
+ let customerId = collectionData.customer_id
82
+ if (!customerId && customerData) {
83
+ const customerResult = await this.customers.create(customerData)
84
+ customerId = customerResult.data.data.id
85
+ }
86
+
87
+ let vbaData = null
88
+ if (createVBA) {
89
+ const vbaResult = await this.virtualBankAccounts.create({
90
+ wallet_id: collectionData.wallet_id,
91
+ account_name: customerData ? `${customerData.first_name} ${customerData.last_name}` : 'Customer Account'
92
+ })
93
+ vbaData = vbaResult.data
94
+ }
95
+
96
+ const collectionResult = await this.collections.initiate({
97
+ ...collectionData,
98
+ customer_id: customerId
99
+ })
100
+
101
+ return {
102
+ customer_id: customerId,
103
+ collection: collectionResult.data,
104
+ virtual_account: vbaData
105
+ }
106
+ } catch (error) {
107
+ throw new BlaaizError(
108
+ `Complete collection failed: ${error.message}`,
109
+ error.status,
110
+ error.code
111
+ )
112
+ }
113
+ }
114
+ }
115
+
116
+ module.exports = {
117
+ Blaaiz,
118
+ BlaaizError,
119
+ CustomerService,
120
+ CollectionService,
121
+ PayoutService,
122
+ WalletService,
123
+ VirtualBankAccountService,
124
+ TransactionService,
125
+ BankService,
126
+ CurrencyService,
127
+ FeesService,
128
+ FileService,
129
+ WebhookService
130
+ }
131
+
132
+ module.exports.default = Blaaiz
@@ -0,0 +1,22 @@
1
+ class BankService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async list () {
7
+ return this.client.makeRequest('GET', '/api/external/bank')
8
+ }
9
+
10
+ async lookupAccount (lookupData) {
11
+ const requiredFields = ['account_number', 'bank_id']
12
+ for (const field of requiredFields) {
13
+ if (!lookupData[field]) {
14
+ throw new Error(`${field} is required`)
15
+ }
16
+ }
17
+
18
+ return this.client.makeRequest('POST', '/api/external/bank/account-lookup', lookupData)
19
+ }
20
+ }
21
+
22
+ module.exports = BankService
@@ -0,0 +1,37 @@
1
+ class CollectionService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async initiate (collectionData) {
7
+ const requiredFields = ['method', 'amount', 'wallet_id']
8
+ for (const field of requiredFields) {
9
+ if (!collectionData[field]) {
10
+ throw new Error(`${field} is required`)
11
+ }
12
+ }
13
+
14
+ return this.client.makeRequest('POST', '/api/external/collection', collectionData)
15
+ }
16
+
17
+ async initiateCrypto (cryptoData) {
18
+ return this.client.makeRequest('POST', '/api/external/collection/crypto', cryptoData)
19
+ }
20
+
21
+ async attachCustomer (attachData) {
22
+ const requiredFields = ['customer_id', 'transaction_id']
23
+ for (const field of requiredFields) {
24
+ if (!attachData[field]) {
25
+ throw new Error(`${field} is required`)
26
+ }
27
+ }
28
+
29
+ return this.client.makeRequest('POST', '/api/external/collection/attach-customer', attachData)
30
+ }
31
+
32
+ async getCryptoNetworks () {
33
+ return this.client.makeRequest('GET', '/api/external/collection/crypto/networks')
34
+ }
35
+ }
36
+
37
+ module.exports = CollectionService
@@ -0,0 +1,11 @@
1
+ class CurrencyService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async list () {
7
+ return this.client.makeRequest('GET', '/api/external/currency')
8
+ }
9
+ }
10
+
11
+ module.exports = CurrencyService
@@ -0,0 +1,351 @@
1
+ class CustomerService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async create (customerData) {
7
+ const requiredFields = ['first_name', 'last_name', 'type', 'email', 'country', 'id_type', 'id_number']
8
+ for (const field of requiredFields) {
9
+ if (!customerData[field]) {
10
+ throw new Error(`${field} is required`)
11
+ }
12
+ }
13
+
14
+ if (customerData.type === 'business' && !customerData.business_name) {
15
+ throw new Error('business_name is required when type is business')
16
+ }
17
+
18
+ return this.client.makeRequest('POST', '/api/external/customer', customerData)
19
+ }
20
+
21
+ async list () {
22
+ return this.client.makeRequest('GET', '/api/external/customer')
23
+ }
24
+
25
+ async get (customerId) {
26
+ if (!customerId) {
27
+ throw new Error('Customer ID is required')
28
+ }
29
+ return this.client.makeRequest('GET', `/api/external/customer/${customerId}`)
30
+ }
31
+
32
+ async update (customerId, updateData) {
33
+ if (!customerId) {
34
+ throw new Error('Customer ID is required')
35
+ }
36
+ return this.client.makeRequest('PUT', `/api/external/customer/${customerId}`, updateData)
37
+ }
38
+
39
+ async addKYC (customerId, kycData) {
40
+ if (!customerId) {
41
+ throw new Error('Customer ID is required')
42
+ }
43
+ return this.client.makeRequest('POST', `/api/external/customer/${customerId}/kyc-data`, kycData)
44
+ }
45
+
46
+ async uploadFiles (customerId, fileData) {
47
+ if (!customerId) {
48
+ throw new Error('Customer ID is required')
49
+ }
50
+ return this.client.makeRequest('PUT', `/api/external/customer/${customerId}/files`, fileData)
51
+ }
52
+
53
+ async uploadFileComplete (customerId, fileOptions) {
54
+ if (!customerId) {
55
+ throw new Error('Customer ID is required')
56
+ }
57
+
58
+ if (!fileOptions) {
59
+ throw new Error('File options are required')
60
+ }
61
+
62
+ const { file, file_category } = fileOptions // eslint-disable-line camelcase
63
+ let { filename, contentType } = fileOptions
64
+
65
+ if (!file) {
66
+ throw new Error('File is required')
67
+ }
68
+
69
+ if (!file_category) { // eslint-disable-line camelcase
70
+ throw new Error('file_category is required')
71
+ }
72
+
73
+ if (!['identity', 'proof_of_address', 'liveness_check'].includes(file_category)) { // eslint-disable-line camelcase
74
+ throw new Error('file_category must be one of: identity, proof_of_address, liveness_check')
75
+ }
76
+
77
+ try {
78
+ const presignedResponse = await this.client.makeRequest('POST', '/api/external/file/get-presigned-url', {
79
+ customer_id: customerId, // eslint-disable-line camelcase
80
+ file_category // eslint-disable-line camelcase
81
+ })
82
+
83
+ // Handle API response structure: {"data": {"message": "...", "file_id": "...", "url": "..."}}
84
+ let presignedUrl, file_id // eslint-disable-line camelcase
85
+ if (presignedResponse.data && presignedResponse.data.url && presignedResponse.data.file_id) {
86
+ // Structure: {"data": {"message": "...", "file_id": "...", "url": "..."}}
87
+ presignedUrl = presignedResponse.data.url
88
+ file_id = presignedResponse.data.file_id // eslint-disable-line camelcase
89
+ } else if (presignedResponse.url && presignedResponse.file_id) {
90
+ // Direct structure from API docs
91
+ presignedUrl = presignedResponse.url
92
+ file_id = presignedResponse.file_id // eslint-disable-line camelcase
93
+ } else if (presignedResponse.data && presignedResponse.data.data) {
94
+ // Nested structure: {"data": {"data": {"url": "...", "file_id": "..."}}}
95
+ presignedUrl = presignedResponse.data.data.url
96
+ file_id = presignedResponse.data.data.file_id // eslint-disable-line camelcase
97
+ } else {
98
+ throw new Error(`Invalid presigned URL response structure. Expected 'url' and 'file_id' keys. Got: ${JSON.stringify(presignedResponse)}`)
99
+ }
100
+
101
+ let fileBuffer
102
+ if (Buffer.isBuffer(file)) {
103
+ fileBuffer = file
104
+ } else if (typeof file === 'string') {
105
+ if (file.startsWith('data:')) {
106
+ // Handle data URL format: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=
107
+ const base64Data = file.split(',')[1]
108
+ fileBuffer = Buffer.from(base64Data, 'base64')
109
+
110
+ // Extract content type from data URL if not provided
111
+ if (!contentType) {
112
+ const mimeMatch = file.match(/data:([^;]+)/)
113
+ if (mimeMatch) {
114
+ contentType = mimeMatch[1]
115
+ }
116
+ }
117
+ } else if (file.startsWith('http://') || file.startsWith('https://')) {
118
+ // Handle URL - download the file
119
+ const downloadResult = await this._downloadFile(file)
120
+ fileBuffer = downloadResult.buffer
121
+
122
+ // Use detected content type if not provided
123
+ if (!contentType && downloadResult.contentType) {
124
+ contentType = downloadResult.contentType
125
+ }
126
+
127
+ // Use filename from URL if not provided
128
+ if (!filename && downloadResult.filename) {
129
+ filename = downloadResult.filename
130
+ }
131
+ } else {
132
+ // Handle plain base64 string
133
+ fileBuffer = Buffer.from(file, 'base64')
134
+ }
135
+ } else {
136
+ // Handle Uint8Array or other array-like structures
137
+ fileBuffer = Buffer.from(file)
138
+ }
139
+
140
+ await this._uploadToS3(presignedUrl, fileBuffer, contentType, filename)
141
+
142
+ // Map file category to the correct field name expected by Laravel API
143
+ const fileFieldMapping = {
144
+ identity: 'id_file',
145
+ liveness_check: 'liveness_check_file',
146
+ proof_of_address: 'proof_of_address_file'
147
+ }
148
+
149
+ const fileFieldName = fileFieldMapping[file_category] // eslint-disable-line camelcase
150
+ if (!fileFieldName) {
151
+ throw new Error(`Unknown file category: ${file_category}`) // eslint-disable-line camelcase
152
+ }
153
+
154
+ const fileAssociation = await this.client.makeRequest('POST', `/api/external/customer/${customerId}/files`, {
155
+ [fileFieldName]: file_id // eslint-disable-line camelcase
156
+ })
157
+
158
+ return {
159
+ ...fileAssociation,
160
+ file_id, // eslint-disable-line camelcase
161
+ presigned_url: presignedUrl
162
+ }
163
+ } catch (error) {
164
+ // Provide better error information for debugging
165
+ if (error.message && error.message.includes('File upload failed:')) {
166
+ // Re-throw if already wrapped
167
+ throw error
168
+ } else {
169
+ // Wrap other exceptions with context
170
+ throw new Error(`File upload failed: ${error.message}`)
171
+ }
172
+ }
173
+ }
174
+
175
+ async _uploadToS3 (presignedUrl, fileBuffer, contentType, filename) {
176
+ const https = require('https')
177
+ const { URL } = require('url')
178
+
179
+ return new Promise((resolve, reject) => {
180
+ const url = new URL(presignedUrl)
181
+
182
+ const headers = {
183
+ 'Content-Length': fileBuffer.length
184
+ }
185
+
186
+ if (contentType) {
187
+ headers['Content-Type'] = contentType
188
+ }
189
+
190
+ if (filename) {
191
+ headers['Content-Disposition'] = `attachment; filename="${filename}"`
192
+ }
193
+
194
+ const options = {
195
+ hostname: url.hostname,
196
+ port: url.port || 443,
197
+ path: url.pathname + url.search,
198
+ method: 'PUT',
199
+ headers
200
+ }
201
+
202
+ const req = https.request(options, (res) => {
203
+ let responseData = ''
204
+
205
+ res.on('data', (chunk) => {
206
+ responseData += chunk
207
+ })
208
+
209
+ res.on('end', () => {
210
+ if (res.statusCode >= 200 && res.statusCode < 300) {
211
+ // Verify S3 upload success by checking for ETag
212
+ const etag = res.headers.etag || res.headers.ETag
213
+ if (!etag) {
214
+ reject(new Error('S3 upload failed: No ETag received from S3'))
215
+ return
216
+ }
217
+
218
+ resolve({
219
+ status: res.statusCode,
220
+ data: responseData,
221
+ etag
222
+ })
223
+ } else {
224
+ reject(new Error(`S3 upload failed with status ${res.statusCode}: ${responseData}`))
225
+ }
226
+ })
227
+ })
228
+
229
+ req.on('error', (error) => {
230
+ reject(new Error(`S3 upload request failed: ${error.message}`))
231
+ })
232
+
233
+ req.write(fileBuffer)
234
+ req.end()
235
+ })
236
+ }
237
+
238
+ async _downloadFile (url) {
239
+ const https = require('https')
240
+ const http = require('http')
241
+ const { URL } = require('url')
242
+ const path = require('path')
243
+
244
+ return new Promise((resolve, reject) => {
245
+ const urlObj = new URL(url)
246
+ const client = urlObj.protocol === 'https:' ? https : http
247
+
248
+ const options = {
249
+ hostname: urlObj.hostname,
250
+ port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
251
+ path: urlObj.pathname + urlObj.search,
252
+ method: 'GET',
253
+ headers: {
254
+ 'User-Agent': 'Blaaiz-NodeJS-SDK/1.0.0'
255
+ }
256
+ }
257
+
258
+ const req = client.request(options, (res) => {
259
+ // Handle redirects
260
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
261
+ return this._downloadFile(res.headers.location)
262
+ .then(resolve)
263
+ .catch(reject)
264
+ }
265
+
266
+ if (res.statusCode < 200 || res.statusCode >= 300) {
267
+ reject(new Error(`Failed to download file: HTTP ${res.statusCode}`))
268
+ return
269
+ }
270
+
271
+ const chunks = []
272
+ let totalLength = 0
273
+
274
+ res.on('data', (chunk) => {
275
+ chunks.push(chunk)
276
+ totalLength += chunk.length
277
+ })
278
+
279
+ res.on('end', () => {
280
+ const buffer = Buffer.concat(chunks, totalLength)
281
+
282
+ // Extract content type from response headers
283
+ const contentType = res.headers['content-type']
284
+
285
+ // Extract filename from URL path or Content-Disposition header
286
+ let filename = null
287
+
288
+ // Try Content-Disposition header first
289
+ const contentDisposition = res.headers['content-disposition']
290
+ if (contentDisposition) {
291
+ const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
292
+ if (filenameMatch) {
293
+ filename = filenameMatch[1].replace(/['"]/g, '')
294
+ }
295
+ }
296
+
297
+ // Fallback to URL path
298
+ if (!filename) {
299
+ const urlPath = urlObj.pathname
300
+ filename = path.basename(urlPath)
301
+
302
+ // If no extension, try to add one based on content type
303
+ if (!path.extname(filename) && contentType) {
304
+ const ext = this._getExtensionFromContentType(contentType)
305
+ if (ext) {
306
+ filename += ext
307
+ }
308
+ }
309
+ }
310
+
311
+ resolve({
312
+ buffer,
313
+ contentType,
314
+ filename
315
+ })
316
+ })
317
+ })
318
+
319
+ req.on('error', (error) => {
320
+ reject(new Error(`File download failed: ${error.message}`))
321
+ })
322
+
323
+ req.setTimeout(30000, () => {
324
+ req.destroy()
325
+ reject(new Error('File download timeout'))
326
+ })
327
+
328
+ req.end()
329
+ })
330
+ }
331
+
332
+ _getExtensionFromContentType (contentType) {
333
+ const mimeToExt = {
334
+ 'image/jpeg': '.jpg',
335
+ 'image/jpg': '.jpg',
336
+ 'image/png': '.png',
337
+ 'image/gif': '.gif',
338
+ 'image/webp': '.webp',
339
+ 'image/bmp': '.bmp',
340
+ 'image/tiff': '.tiff',
341
+ 'application/pdf': '.pdf',
342
+ 'text/plain': '.txt',
343
+ 'application/msword': '.doc',
344
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx'
345
+ }
346
+
347
+ return mimeToExt[contentType.split(';')[0]] || null
348
+ }
349
+ }
350
+
351
+ module.exports = CustomerService
@@ -0,0 +1,18 @@
1
+ class FeesService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async getBreakdown (feeData) {
7
+ const requiredFields = ['from_currency_id', 'to_currency_id', 'from_amount']
8
+ for (const field of requiredFields) {
9
+ if (!feeData[field]) {
10
+ throw new Error(`${field} is required`)
11
+ }
12
+ }
13
+
14
+ return this.client.makeRequest('POST', '/api/external/fees/breakdown', feeData)
15
+ }
16
+ }
17
+
18
+ module.exports = FeesService
@@ -0,0 +1,18 @@
1
+ class FileService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async getPresignedUrl (fileData) {
7
+ const requiredFields = ['customer_id', 'file_category']
8
+ for (const field of requiredFields) {
9
+ if (!fileData[field]) {
10
+ throw new Error(`${field} is required`)
11
+ }
12
+ }
13
+
14
+ return this.client.makeRequest('POST', '/api/external/file/get-presigned-url', fileData)
15
+ }
16
+ }
17
+
18
+ module.exports = FileService
@@ -0,0 +1,31 @@
1
+ class PayoutService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async initiate (payoutData) {
7
+ const requiredFields = ['wallet_id', 'method', 'from_amount', 'from_currency_id', 'to_currency_id']
8
+ for (const field of requiredFields) {
9
+ if (!payoutData[field]) {
10
+ throw new Error(`${field} is required`)
11
+ }
12
+ }
13
+
14
+ if (payoutData.method === 'bank_transfer' && !payoutData.account_number) {
15
+ throw new Error('account_number is required for bank_transfer method')
16
+ }
17
+
18
+ if (payoutData.method === 'interac') {
19
+ const interacFields = ['email', 'interac_first_name', 'interac_last_name']
20
+ for (const field of interacFields) {
21
+ if (!payoutData[field]) {
22
+ throw new Error(`${field} is required for interac method`)
23
+ }
24
+ }
25
+ }
26
+
27
+ return this.client.makeRequest('POST', '/api/external/payout', payoutData)
28
+ }
29
+ }
30
+
31
+ module.exports = PayoutService
@@ -0,0 +1,18 @@
1
+ class TransactionService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async list (filters = {}) {
7
+ return this.client.makeRequest('POST', '/api/external/transaction', filters)
8
+ }
9
+
10
+ async get (transactionId) {
11
+ if (!transactionId) {
12
+ throw new Error('Transaction ID is required')
13
+ }
14
+ return this.client.makeRequest('GET', `/api/external/transaction/${transactionId}`)
15
+ }
16
+ }
17
+
18
+ module.exports = TransactionService
@@ -0,0 +1,30 @@
1
+ class VirtualBankAccountService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async create (vbaData) {
7
+ const requiredFields = ['wallet_id']
8
+ for (const field of requiredFields) {
9
+ if (!vbaData[field]) {
10
+ throw new Error(`${field} is required`)
11
+ }
12
+ }
13
+
14
+ return this.client.makeRequest('POST', '/api/external/virtual-bank-account', vbaData)
15
+ }
16
+
17
+ async list (walletId) {
18
+ const params = walletId ? `?wallet_id=${walletId}` : ''
19
+ return this.client.makeRequest('GET', `/api/external/virtual-bank-account${params}`)
20
+ }
21
+
22
+ async get (vbaId) {
23
+ if (!vbaId) {
24
+ throw new Error('Virtual bank account ID is required')
25
+ }
26
+ return this.client.makeRequest('GET', `/api/external/virtual-bank-account/${vbaId}`)
27
+ }
28
+ }
29
+
30
+ module.exports = VirtualBankAccountService
@@ -0,0 +1,18 @@
1
+ class WalletService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async list () {
7
+ return this.client.makeRequest('GET', '/api/external/wallet')
8
+ }
9
+
10
+ async get (walletId) {
11
+ if (!walletId) {
12
+ throw new Error('Wallet ID is required')
13
+ }
14
+ return this.client.makeRequest('GET', `/api/external/wallet/${walletId}`)
15
+ }
16
+ }
17
+
18
+ module.exports = WalletService
@@ -0,0 +1,96 @@
1
+ class WebhookService {
2
+ constructor (client) {
3
+ this.client = client
4
+ }
5
+
6
+ async register (webhookData) {
7
+ const requiredFields = ['collection_url', 'payout_url']
8
+ for (const field of requiredFields) {
9
+ if (!webhookData[field]) {
10
+ throw new Error(`${field} is required`)
11
+ }
12
+ }
13
+
14
+ return this.client.makeRequest('POST', '/api/external/webhook', webhookData)
15
+ }
16
+
17
+ async get () {
18
+ return this.client.makeRequest('GET', '/api/external/webhook')
19
+ }
20
+
21
+ async update (webhookData) {
22
+ return this.client.makeRequest('PUT', '/api/external/webhook', webhookData)
23
+ }
24
+
25
+ async replay (replayData) {
26
+ const requiredFields = ['transaction_id']
27
+ for (const field of requiredFields) {
28
+ if (!replayData[field]) {
29
+ throw new Error(`${field} is required`)
30
+ }
31
+ }
32
+
33
+ return this.client.makeRequest('POST', '/api/external/webhook/replay', replayData)
34
+ }
35
+
36
+ verifySignature (payload, signature, secret) {
37
+ if (!payload) {
38
+ throw new Error('Payload is required for signature verification')
39
+ }
40
+
41
+ if (!signature) {
42
+ throw new Error('Signature is required for signature verification')
43
+ }
44
+
45
+ if (!secret) {
46
+ throw new Error('Webhook secret is required for signature verification')
47
+ }
48
+
49
+ const crypto = require('crypto')
50
+
51
+ // Convert payload to string if it's an object
52
+ const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload)
53
+
54
+ // Remove 'sha256=' prefix if present
55
+ const cleanSignature = signature.replace(/^sha256=/, '')
56
+
57
+ // Create HMAC signature
58
+ const expectedSignature = crypto
59
+ .createHmac('sha256', secret)
60
+ .update(payloadString, 'utf8')
61
+ .digest('hex')
62
+
63
+ // Use timingSafeEqual to prevent timing attacks
64
+ try {
65
+ const signatureBuffer = Buffer.from(cleanSignature, 'hex')
66
+ const expectedBuffer = Buffer.from(expectedSignature, 'hex')
67
+
68
+ if (signatureBuffer.length !== expectedBuffer.length) {
69
+ return false
70
+ }
71
+
72
+ return crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
73
+ } catch (error) {
74
+ return false
75
+ }
76
+ }
77
+
78
+ constructEvent (payload, signature, secret) {
79
+ if (!this.verifySignature(payload, signature, secret)) {
80
+ throw new Error('Invalid webhook signature')
81
+ }
82
+
83
+ try {
84
+ const event = typeof payload === 'string' ? JSON.parse(payload) : payload
85
+ return {
86
+ ...event,
87
+ verified: true,
88
+ timestamp: new Date().toISOString()
89
+ }
90
+ } catch (error) {
91
+ throw new Error('Invalid webhook payload: unable to parse JSON')
92
+ }
93
+ }
94
+ }
95
+
96
+ module.exports = WebhookService