@katorymnd/pawapay-node-sdk 2.1.1 → 2.6.2

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
@@ -123,8 +123,13 @@ Easily switch between sandbox and production environments using environment vari
123
123
  * [Configuration (.env)](#configuration-env)
124
124
  * [Usage](#usage)
125
125
 
126
- * [Initializing the SDK](#initializing-the-sdk)
127
- * [MNO Configuration & Version Switching](#mno-configuration--version-switching)
126
+ * [Initializing the SDK(The brain)](#initializing-the-sdk)
127
+ * [The SDK heart](#the-sdk-heart)
128
+ * [Deposit Senario(MNO)](#deposit-senariomno)
129
+ * [Deposit Senario (Hosted Page)](#deposit-senario-hosted-page)
130
+ * [Payout Senario](#payout-senario)
131
+ * [Refund Senario](#refund-senario)
132
+ * [MNO Configuration & Version Switching](#mno-configuration--version-switching)
128
133
 
129
134
  * [Support](#support)
130
135
 
@@ -207,6 +212,1144 @@ On first initialization, the SDK securely binds:
207
212
 
208
213
  This binding is permanent for that license and prevents unauthorized reuse across domains.
209
214
 
215
+ ---
216
+ ### The SDK heart
217
+
218
+ At this stage i assume that the user has arleady purchased the premium packge and also installed the SDK to there work space.
219
+
220
+ You need to call the sdk (`@katorymnd/pawapay-node-sdk`) to your project so that you can use it for `deposit`,`hosted deposit`,`refund`,`payout`,`MNO Config` and also to confirm transactions.
221
+
222
+ Create a page `pawapayService.js` for example and add this code
223
+
224
+ ```typescript
225
+ //pawapayService.js
226
+
227
+ const path = require('path');
228
+ const winston = require('winston');
229
+ require('dotenv').config();
230
+
231
+ // Load the SDK and destructure the public exports directly
232
+ // We use 'ApiClient' because that's the class name export
233
+ const { ApiClient, Helpers, FailureCodeHelper } = require('@katorymnd/pawapay-node-sdk');
234
+
235
+ // ========== LOGGING SETUP ==========
236
+ const logsDir = path.resolve(__dirname, '../logs');
237
+
238
+ const logger = winston.createLogger({
239
+ format: winston.format.combine(
240
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
241
+ winston.format.errors({ stack: true }),
242
+ winston.format.json()
243
+ ),
244
+ transports: [
245
+ new winston.transports.File({
246
+ filename: path.join(logsDir, 'payment_success.log'),
247
+ level: 'info',
248
+ format: winston.format.combine(
249
+ winston.format.timestamp(),
250
+ winston.format.json()
251
+ )
252
+ }),
253
+ new winston.transports.File({
254
+ filename: path.join(logsDir, 'payment_failed.log'),
255
+ level: 'error',
256
+ format: winston.format.combine(
257
+ winston.format.timestamp(),
258
+ winston.format.json()
259
+ )
260
+ })
261
+ ]
262
+ });
263
+
264
+ if (process.env.NODE_ENV !== 'production') {
265
+ logger.add(new winston.transports.Console({
266
+ format: winston.format.combine(
267
+ winston.format.colorize(),
268
+ winston.format.simple()
269
+ )
270
+ }));
271
+ }
272
+ // ========== END LOGGING SETUP ==========
273
+
274
+ class PawaPayService {
275
+ /**
276
+ * @param {Object} config - Optional configuration
277
+ * @param {string} config.token - Custom API Token to override .env
278
+ */
279
+ constructor(config = {}) {
280
+ // Prioritize ENV
281
+ const activeToken = process.env.PAWAPAY_SANDBOX_API_TOKEN;
282
+
283
+ // Debug log to confirm token is being used (masked)
284
+ const maskedToken = activeToken ? `${activeToken.substring(0, 5)}...` : 'NONE';
285
+ console.log(`[PawaPayService] Initializing with token: ${maskedToken}`);
286
+
287
+ this.pawapay = new ApiClient({
288
+ apiToken: activeToken,
289
+ environment: 'sandbox', //production/sandbox
290
+ licenseKey: process.env.KATORYMND_PAWAPAY_SDK_LICENSE_KEY, //required
291
+ sslVerify: false // true -> production
292
+ });
293
+ }
294
+ /**
295
+ * Deposit money to a mobile money account
296
+ * @param {Object} depositData - Deposit details
297
+ * @param {string} apiVersion - 'v1' or 'v2'
298
+ */
299
+ async deposit(depositData, apiVersion = 'v1') {
300
+ // Use the Helper directly from the SDK
301
+ const depositId = Helpers.generateUniqueId();
302
+
303
+ try {
304
+ const {
305
+ amount,
306
+ currency,
307
+ mno,
308
+ payerMsisdn,
309
+ description,
310
+ metadata = []
311
+ } = depositData;
312
+
313
+ // 1. STRICT VALIDATION
314
+ if (!amount || !mno || !payerMsisdn || !description || !currency) {
315
+ const missingMsg = 'Validation failed - Missing required fields';
316
+ logger.error(missingMsg, depositData);
317
+ return { success: false, error: missingMsg };
318
+ }
319
+
320
+ // Validate Amount
321
+ const amountRegex = /^\d+(\.\d{1,2})?$/;
322
+ if (!amountRegex.test(amount) || parseFloat(amount) <= 0) {
323
+ const msg = 'Invalid amount. Must be positive with max 2 decimals.';
324
+ logger.error(msg, { amount });
325
+ return { success: false, error: msg };
326
+ }
327
+
328
+ // Validate Description
329
+ const descriptionRegex = /^[A-Za-z0-9 ]{1,22}$/;
330
+ if (!descriptionRegex.test(description)) {
331
+ const msg = 'Invalid description. Max 22 chars, alphanumeric only.';
332
+ logger.error(msg, { description });
333
+ return { success: false, error: msg };
334
+ }
335
+
336
+ logger.info('Initiating deposit', {
337
+ depositId,
338
+ amount,
339
+ currency,
340
+ mno,
341
+ apiVersion
342
+ });
343
+
344
+ // 2. PROCESS DEPOSIT
345
+ let response;
346
+
347
+ if (apiVersion === 'v2') {
348
+ response = await this.pawapay.initiateDepositV2(
349
+ depositId,
350
+ amount,
351
+ currency,
352
+ payerMsisdn,
353
+ mno, // provider
354
+ description, // customerMessage
355
+ null, // clientReferenceId
356
+ null, // preAuthorisationCode
357
+ metadata
358
+ );
359
+ } else {
360
+ response = await this.pawapay.initiateDeposit(
361
+ depositId,
362
+ amount,
363
+ currency,
364
+ mno, // correspondent
365
+ payerMsisdn,
366
+ description, // statementDescription
367
+ metadata
368
+ );
369
+ }
370
+
371
+ // 3. HANDLE RESPONSE
372
+ if (response.status === 200 || response.status === 201) {
373
+ logger.info('Deposit initiated successfully', { depositId, status: response.status });
374
+
375
+ const statusCheck = await this.checkTransactionStatus(depositId, apiVersion);
376
+
377
+ return {
378
+ success: true,
379
+ depositId,
380
+ transactionId: depositId,
381
+ reference: depositId,
382
+ status: statusCheck.status || 'SUBMITTED',
383
+ message: 'Deposit initiated successfully',
384
+ rawResponse: response,
385
+ statusCheck: statusCheck
386
+ };
387
+ } else {
388
+ // 4. HANDLE ERRORS
389
+ let errorMessage = 'Deposit initiation failed';
390
+ let failureCode = 'UNKNOWN';
391
+
392
+ if (response.response?.rejectionReason?.rejectionMessage) {
393
+ errorMessage = response.response.rejectionReason.rejectionMessage;
394
+ } else if (response.response?.failureReason?.failureCode) {
395
+ failureCode = response.response.failureReason.failureCode;
396
+ // Use the SDK's built-in error helper
397
+ errorMessage = FailureCodeHelper.getFailureMessage(failureCode);
398
+ } else if (response.response?.message) {
399
+ errorMessage = response.response.message;
400
+ }
401
+
402
+ logger.error('Deposit initiation failed', {
403
+ depositId,
404
+ error: errorMessage,
405
+ failureCode,
406
+ response: response.response
407
+ });
408
+
409
+ return {
410
+ success: false,
411
+ error: errorMessage,
412
+ depositId,
413
+ statusCode: response.status,
414
+ rawResponse: response
415
+ };
416
+ }
417
+
418
+ } catch (error) {
419
+ logger.error('System Error during deposit', {
420
+ depositId,
421
+ error: error.message,
422
+ stack: error.stack
423
+ });
424
+
425
+ return {
426
+ success: false,
427
+ error: error.message || 'Internal processing error',
428
+ depositId
429
+ };
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Check Status of ANY transaction (Deposit, Payout, Refund)
435
+ * @param {string} transactionId - The ID to check
436
+ * @param {string} apiVersion - 'v1' or 'v2'
437
+ * @param {string} type - 'deposit', 'payout', 'refund', 'remittance'
438
+ */
439
+ async checkTransactionStatus(transactionId, apiVersion = 'v1', type = 'deposit') {
440
+ try {
441
+ let response;
442
+
443
+ // Pass the 'type' to the SDK so it hits the correct endpoint (e.g., /payouts vs /deposits)
444
+ if (apiVersion === 'v2') {
445
+ response = await this.pawapay.checkTransactionStatusV2(transactionId, type);
446
+ } else {
447
+ response = await this.pawapay.checkTransactionStatus(transactionId, type);
448
+ }
449
+
450
+ logger.info(`Checking ${type} status`, { transactionId, status: response.status });
451
+
452
+ if (response.status === 200) {
453
+ let data;
454
+ let status;
455
+
456
+ // Normalize V1 (Array/Object) vs V2 (Object wrapper)
457
+ if (apiVersion === 'v2') {
458
+ if (response.response?.status !== 'FOUND') {
459
+ return {
460
+ success: true,
461
+ status: 'PROCESSING',
462
+ transactionId,
463
+ message: 'Transaction processing'
464
+ };
465
+ }
466
+ data = response.response.data;
467
+ status = data?.status || 'UNKNOWN';
468
+ } else {
469
+ // V1 legacy can be array [ { ... } ] or object
470
+ const raw = response.response;
471
+ data = Array.isArray(raw) ? raw[0] : raw;
472
+ status = data?.status || 'UNKNOWN';
473
+ }
474
+
475
+ return {
476
+ success: true,
477
+ status: status,
478
+ transactionId: transactionId,
479
+ data: data,
480
+ rawResponse: response
481
+ };
482
+ } else {
483
+ return {
484
+ success: false,
485
+ error: `Status check failed with code ${response.status}`,
486
+ statusCode: response.status
487
+ };
488
+ }
489
+ } catch (error) {
490
+ logger.error('Status check error', { error: error.message });
491
+ return {
492
+ success: false,
493
+ error: error.message || 'Status check failed'
494
+ };
495
+ }
496
+ }
497
+
498
+ validateToken(token) {
499
+ if (!token || token.trim() === '') return { isValid: false, error: 'Token is required' };
500
+ return { isValid: true, type: 'JWT', message: 'Valid token format' };
501
+ }
502
+
503
+ /**
504
+ * Create a Payment Page Session
505
+ * @param {Object} pageData - Payment details
506
+ * @param {string} apiVersion - 'v1' or 'v2'
507
+ */
508
+ async initiatePaymentPage(pageData, apiVersion = 'v1') {
509
+ const depositId = Helpers.generateUniqueId();
510
+
511
+ try {
512
+ const {
513
+ amount,
514
+ currency,
515
+ payerMsisdn,
516
+ description,
517
+ returnUrl,
518
+ metadata = [],
519
+ country = 'UGA', // Default to Uganda for testing
520
+ reason = 'Payment'
521
+ } = pageData;
522
+
523
+ // 1. STRICT VALIDATION
524
+ if (!amount || !description || !currency || !returnUrl) {
525
+ const missingMsg = 'Validation failed - Missing required fields (returnUrl is mandatory)';
526
+ logger.error(missingMsg, pageData);
527
+ return { success: false, error: missingMsg };
528
+ }
529
+
530
+ logger.info('Initiating Payment Page', {
531
+ depositId,
532
+ amount,
533
+ apiVersion
534
+ });
535
+
536
+ // 2. PREPARE PAYLOAD & CALL SDK
537
+ let response;
538
+
539
+ // Normalize phone (remove +)
540
+ const cleanMsisdn = payerMsisdn ? payerMsisdn.replace(/\D/g, '') : null;
541
+
542
+ if (apiVersion === 'v2') {
543
+ // V2 Payload Construction
544
+ const v2Params = {
545
+ depositId,
546
+ returnUrl,
547
+ customerMessage: description,
548
+ amountDetails: {
549
+ amount: String(amount),
550
+ currency: currency
551
+ },
552
+ phoneNumber: cleanMsisdn,
553
+ country,
554
+ reason,
555
+ metadata
556
+ };
557
+ response = await this.pawapay.createPaymentPageSessionV2(v2Params);
558
+ } else {
559
+ // V1 Payload Construction
560
+ const v1Params = {
561
+ depositId,
562
+ returnUrl,
563
+ amount: String(amount),
564
+ currency,
565
+ msisdn: cleanMsisdn,
566
+ statementDescription: description,
567
+ country,
568
+ reason,
569
+ metadata
570
+ };
571
+ response = await this.pawapay.createPaymentPageSession(v1Params);
572
+ }
573
+
574
+ // 3. HANDLE RESPONSE
575
+ // Note: API returns 200/201 for success
576
+ if (response.status >= 200 && response.status < 300) {
577
+ const redirectUrl = response.response?.redirectUrl || response.response?.url;
578
+
579
+ logger.info('Payment Page created', { depositId, redirectUrl });
580
+
581
+ return {
582
+ success: true,
583
+ depositId,
584
+ redirectUrl,
585
+ message: 'Session created successfully',
586
+ rawResponse: response
587
+ };
588
+ } else {
589
+ // 4. HANDLE ERRORS
590
+ const errorMsg = response.response?.message || 'Failed to create payment session';
591
+
592
+ logger.error('Payment Page creation failed', {
593
+ depositId,
594
+ error: errorMsg,
595
+ response: response.response
596
+ });
597
+
598
+ return {
599
+ success: false,
600
+ error: errorMsg,
601
+ depositId,
602
+ statusCode: response.status
603
+ };
604
+ }
605
+
606
+ } catch (error) {
607
+ logger.error('System Error during payment page creation', {
608
+ depositId,
609
+ error: error.message,
610
+ stack: error.stack
611
+ });
612
+
613
+ return {
614
+ success: false,
615
+ error: error.message || 'Internal processing error',
616
+ depositId
617
+ };
618
+ }
619
+ }
620
+
621
+
622
+ /**
623
+ * Payout money to a mobile money account (Disbursement)
624
+ * @param {Object} payoutData - Payout details
625
+ * @param {string} apiVersion - 'v1' or 'v2'
626
+ */
627
+ async payout(payoutData, apiVersion = 'v1') {
628
+ const payoutId = Helpers.generateUniqueId();
629
+
630
+ try {
631
+ // 1. EXTRACT DATA WITH FALLBACKS
632
+ let {
633
+ amount,
634
+ currency,
635
+ mno, // Logic might send this
636
+ provider, // V2 logic might send this
637
+ correspondent, // V1 logic might send this
638
+ recipientMsisdn,
639
+ description, // Direct description
640
+ statementDescription, // V1 alternative
641
+ customerMessage, // V2 alternative
642
+ reason, // Another possible field
643
+ metadata = []
644
+ } = payoutData;
645
+
646
+ // 🛠️ FIX: Normalize the operator code
647
+ // If 'mno' is undefined, use 'provider' (V2) or 'correspondent' (V1)
648
+ const resolvedMno = mno || provider || correspondent;
649
+
650
+ // ============================================================
651
+ // Ensure 'description' is never missing
652
+ // PawaPay API requires this field for both V1 and V2.
653
+ // ============================================================
654
+ const resolvedDescription = description
655
+ || statementDescription
656
+ || customerMessage
657
+ || reason
658
+ || 'Transaction Processing'; // Ultimate fallback
659
+
660
+ // 2. STRICT VALIDATION & DEBUG LOGGING
661
+ // We check specific fields to give a precise error message
662
+ const missingFields = [];
663
+ if (!amount) missingFields.push('amount');
664
+ if (!resolvedMno) missingFields.push(`mno (looked for: mno, provider, correspondent)`);
665
+ if (!recipientMsisdn) missingFields.push('recipientMsisdn');
666
+ if (!resolvedDescription) missingFields.push('description');
667
+ if (!currency) missingFields.push('currency');
668
+
669
+ if (missingFields.length > 0) {
670
+ const missingMsg = `Validation failed - Missing fields: [${missingFields.join(', ')}]`;
671
+
672
+ // 🔍 DEBUG: Construct a detailed log entry for the error.log
673
+ const debugPayload = {
674
+ ERROR_TYPE: 'PAYOUT_VALIDATION_ERROR',
675
+ PAYOUT_ID: payoutId,
676
+ API_VERSION: apiVersion,
677
+ MISSING: missingFields,
678
+ RESOLVED_MNO: resolvedMno || 'UNDEFINED (This is likely the issue)',
679
+ RESOLVED_DESCRIPTION: resolvedDescription || 'UNDEFINED',
680
+ RAW_RECEIVED: JSON.stringify(payoutData, null, 2) // Pretty print the full object
681
+ };
682
+
683
+ // Log to your system logger
684
+ logger.error(missingMsg, debugPayload);
685
+
686
+ // Return failure with details
687
+ return {
688
+ success: false,
689
+ error: missingMsg,
690
+ debug: debugPayload // Return this so the frontend/controller can see it too
691
+ };
692
+ }
693
+
694
+ // Assign the resolved values back to variables used in logic
695
+ mno = resolvedMno;
696
+ description = resolvedDescription; // CRITICAL: Update the description variable
697
+
698
+ // Validate Amount Format
699
+ const amountRegex = /^\d+(\.\d{1,2})?$/;
700
+ if (!amountRegex.test(amount) || parseFloat(amount) <= 0) {
701
+ const msg = 'Invalid amount. Must be positive with max 2 decimals.';
702
+ logger.error(msg, { amount, payoutId });
703
+ return { success: false, error: msg };
704
+ }
705
+
706
+ logger.info('Initiating payout', {
707
+ payoutId,
708
+ amount,
709
+ currency,
710
+ mno,
711
+ description, // Log the resolved description
712
+ apiVersion
713
+ });
714
+
715
+ // 3. PROCESS PAYOUT
716
+ let response;
717
+
718
+ if (apiVersion === 'v2') {
719
+ // V2 Payout
720
+ response = await this.pawapay.initiatePayoutV2(
721
+ payoutId,
722
+ amount,
723
+ currency,
724
+ recipientMsisdn,
725
+ mno, // provider
726
+ description, // customerMessage - using the resolved description
727
+ metadata
728
+ );
729
+ } else {
730
+ // V1 Payout
731
+ response = await this.pawapay.initiatePayout(
732
+ payoutId,
733
+ amount,
734
+ currency,
735
+ mno, // correspondent
736
+ recipientMsisdn, // recipient address
737
+ description, // statementDescription - using the resolved description
738
+ metadata
739
+ );
740
+ }
741
+
742
+ // 4. HANDLE RESPONSE
743
+ if (response.status === 200 || response.status === 201 || response.status === 202) {
744
+ logger.info('Payout initiated successfully', {
745
+ payoutId,
746
+ status: response.status,
747
+ description // Log successful description
748
+ });
749
+
750
+ const statusCheck = await this.checkTransactionStatus(payoutId, apiVersion);
751
+
752
+ return {
753
+ success: true,
754
+ payoutId,
755
+ transactionId: payoutId,
756
+ status: statusCheck.status || 'SUBMITTED',
757
+ message: 'Payout initiated successfully',
758
+ rawResponse: response,
759
+ statusCheck: statusCheck
760
+ };
761
+ } else {
762
+ // 5. HANDLE ERRORS
763
+ let errorMessage = 'Payout initiation failed';
764
+ let failureCode = 'UNKNOWN';
765
+
766
+ if (response.response?.rejectionReason?.rejectionMessage) {
767
+ errorMessage = response.response.rejectionReason.rejectionMessage;
768
+ } else if (response.response?.failureReason?.failureCode) {
769
+ failureCode = response.response.failureReason.failureCode;
770
+ errorMessage = FailureCodeHelper.getFailureMessage(failureCode);
771
+ } else if (response.response?.message) {
772
+ errorMessage = response.response.message;
773
+ }
774
+
775
+ logger.error('Payout initiation failed', {
776
+ payoutId,
777
+ error: errorMessage,
778
+ failureCode,
779
+ response: response.response,
780
+ description // Log description even on failure
781
+ });
782
+
783
+ return {
784
+ success: false,
785
+ error: errorMessage,
786
+ payoutId,
787
+ statusCode: response.status,
788
+ rawResponse: response
789
+ };
790
+ }
791
+
792
+ } catch (error) {
793
+ logger.error('System Error during payout', {
794
+ payoutId,
795
+ error: error.message,
796
+ stack: error.stack,
797
+ inputData: JSON.stringify(payoutData) // Log input on crash too
798
+ });
799
+
800
+ return {
801
+ success: false,
802
+ error: error.message || 'Internal processing error',
803
+ payoutId
804
+ };
805
+ }
806
+ }
807
+
808
+
809
+ /**
810
+ * Initiate a Refund (Partial or Full)
811
+ * @param {Object} refundData - Refund details
812
+ * @param {string} apiVersion - 'v1' or 'v2'
813
+ */
814
+ async refund(refundData, apiVersion = 'v1') {
815
+ const refundId = Helpers.generateUniqueId();
816
+
817
+ try {
818
+ const {
819
+ depositId,
820
+ amount,
821
+ currency, // Required for V2
822
+ reason,
823
+ metadata = []
824
+ } = refundData;
825
+
826
+ // 1. STRICT VALIDATION
827
+ if (!depositId || !amount) {
828
+ const missingMsg = 'Validation failed - Missing required fields (depositId, amount)';
829
+ logger.error(missingMsg, refundData);
830
+ return { success: false, error: missingMsg };
831
+ }
832
+
833
+ // V2 Specific Validation
834
+ if (apiVersion === 'v2' && !currency) {
835
+ const msg = 'Validation failed - V2 Refunds require a currency code';
836
+ logger.error(msg, refundData);
837
+ return { success: false, error: msg };
838
+ }
839
+
840
+ // Validate Amount
841
+ const amountRegex = /^\d+(\.\d{1,2})?$/;
842
+ if (!amountRegex.test(amount) || parseFloat(amount) <= 0) {
843
+ const msg = 'Invalid amount. Must be positive with max 2 decimals.';
844
+ logger.error(msg, { amount });
845
+ return { success: false, error: msg };
846
+ }
847
+
848
+ logger.info('Initiating refund', {
849
+ refundId,
850
+ depositId,
851
+ amount,
852
+ currency,
853
+ apiVersion
854
+ });
855
+
856
+ // 2. PROCESS REFUND
857
+ let response;
858
+
859
+ if (apiVersion === 'v2') {
860
+ // V2 Refund
861
+ response = await this.pawapay.initiateRefundV2(
862
+ refundId,
863
+ depositId,
864
+ amount,
865
+ currency,
866
+ metadata
867
+ );
868
+ } else {
869
+ // V1 Refund
870
+ response = await this.pawapay.initiateRefund(
871
+ refundId,
872
+ depositId,
873
+ amount,
874
+ metadata
875
+ );
876
+ }
877
+
878
+ // 3. HANDLE RESPONSE
879
+ // Refunds typically return 200/201/202
880
+ if (response.status >= 200 && response.status < 300) {
881
+ logger.info('Refund initiated successfully', { refundId, status: response.status });
882
+
883
+ // Check status immediately (passing 'refund' as type is critical)
884
+ const statusCheck = await this.checkTransactionStatus(refundId, apiVersion, 'refund');
885
+
886
+ return {
887
+ success: true,
888
+ refundId,
889
+ transactionId: refundId,
890
+ depositId: depositId,
891
+ status: statusCheck.status || 'SUBMITTED',
892
+ message: 'Refund initiated successfully',
893
+ rawResponse: response,
894
+ statusCheck: statusCheck
895
+ };
896
+ } else {
897
+ // 4. HANDLE ERRORS
898
+ let errorMessage = 'Refund initiation failed';
899
+ let failureCode = 'UNKNOWN';
900
+
901
+ if (response.response?.rejectionReason?.rejectionMessage) {
902
+ errorMessage = response.response.rejectionReason.rejectionMessage;
903
+ } else if (response.response?.failureReason?.failureCode) {
904
+ failureCode = response.response.failureReason.failureCode;
905
+ errorMessage = FailureCodeHelper.getFailureMessage(failureCode);
906
+ } else if (response.response?.message) {
907
+ errorMessage = response.response.message;
908
+ }
909
+
910
+ logger.error('Refund initiation failed', {
911
+ refundId,
912
+ depositId,
913
+ error: errorMessage,
914
+ failureCode,
915
+ response: response.response
916
+ });
917
+
918
+ return {
919
+ success: false,
920
+ error: errorMessage,
921
+ refundId,
922
+ statusCode: response.status,
923
+ rawResponse: response
924
+ };
925
+ }
926
+
927
+ } catch (error) {
928
+ logger.error('System Error during refund', {
929
+ refundId,
930
+ depositId: refundData.depositId,
931
+ error: error.message,
932
+ stack: error.stack
933
+ });
934
+
935
+ return {
936
+ success: false,
937
+ error: error.message || 'Internal processing error',
938
+ refundId
939
+ };
940
+ }
941
+ }
942
+
943
+ }
944
+
945
+ module.exports = PawaPayService;
946
+ ```
947
+ what our `pawapayService.js` has is all what the SDK needs to make any process possible.
948
+
949
+ **Lets demostrate how to use the heart with examples**
950
+
951
+ ---
952
+ ### Deposit Senario(MNO)
953
+
954
+ ```typescript
955
+ //test-deposit.js
956
+ const path = require('path');
957
+ // Ensure strict loading of the root .env file
958
+ require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
959
+
960
+ const PawaPayService = require('./pawapayService');
961
+
962
+ /**
963
+ * Run a full test of the PawaPay SDK integration (Deposits)
964
+ */
965
+ async function testSDKConnection() {
966
+ console.log('\n========================================');
967
+ console.log('🧪 PAWAPAY SDK DEPOSIT TEST (With Status Check)');
968
+ console.log('========================================\n');
969
+
970
+ const service = new PawaPayService();
971
+
972
+ // 1. Validate Token Format
973
+ console.log('🔹 Step 1: Validating API Token...');
974
+ const testToken = process.env.PAWAPAY_SANDBOX_API_TOKEN;
975
+ const validation = service.validateToken(testToken);
976
+
977
+ if (!validation.isValid) {
978
+ console.error('❌ Token Validation Failed:', validation);
979
+ return;
980
+ }
981
+ console.log('✅ Token looks valid:', validation.type);
982
+
983
+ // Common Test Data (Uganda MTN Sandbox)
984
+ const commonData = {
985
+ amount: '1000',
986
+ currency: 'UGX',
987
+ mno: 'MTN_MOMO_UGA',
988
+ payerMsisdn: '256783456789', // Valid Sandbox Payer
989
+ description: 'SDK Integration Test'
990
+ };
991
+
992
+ // --- 2. Test V1 Deposit ---
993
+ console.log('\n🔹 Step 2: Testing V1 Deposit...');
994
+ try {
995
+ const v1Result = await service.deposit({
996
+ ...commonData,
997
+ description: 'V1 Test Payment'
998
+ }, 'v1');
999
+
1000
+ if (v1Result.success) {
1001
+ console.log('✅ V1 Initiation Success:', {
1002
+ depositId: v1Result.depositId,
1003
+ status: v1Result.status
1004
+ });
1005
+
1006
+ // WAIT AND CHECK
1007
+ console.log('⏳ Waiting 5 seconds for V1 propagation...');
1008
+ await new Promise(r => setTimeout(r, 5000));
1009
+
1010
+ console.log('🔍 Checking V1 Status...');
1011
+ const statusCheck = await service.checkTransactionStatus(
1012
+ v1Result.depositId,
1013
+ 'v1',
1014
+ 'deposit'
1015
+ );
1016
+
1017
+ console.log(`📊 Final V1 Status: [ ${statusCheck.status} ]`);
1018
+ if (statusCheck.status === 'FAILED') {
1019
+ console.warn(` Reason: ${statusCheck.data?.failureReason?.failureMessage || 'Unknown'}`);
1020
+ }
1021
+
1022
+ } else {
1023
+ console.error('❌ V1 Failed:', v1Result.error);
1024
+ }
1025
+ } catch (error) {
1026
+ console.error('❌ V1 Exception:', error.message);
1027
+ }
1028
+
1029
+ // --- 3. Test V2 Deposit ---
1030
+ console.log('\n🔹 Step 3: Testing V2 Deposit...');
1031
+ try {
1032
+ const v2Result = await service.deposit({
1033
+ ...commonData,
1034
+ description: 'V2 Test Payment',
1035
+ metadata: [
1036
+ { orderId: "ORD-SDK-TEST" },
1037
+ { customerId: "test-user@example.com", isPII: true }
1038
+ ]
1039
+ }, 'v2');
1040
+
1041
+ if (v2Result.success) {
1042
+ console.log('✅ V2 Initiation Success:', {
1043
+ depositId: v2Result.depositId,
1044
+ status: v2Result.status
1045
+ });
1046
+
1047
+ // WAIT AND CHECK
1048
+ console.log('⏳ Waiting 5 seconds for V2 propagation...');
1049
+ await new Promise(r => setTimeout(r, 5000));
1050
+
1051
+ console.log('🔍 Checking V2 Status...');
1052
+ const statusCheck = await service.checkTransactionStatus(
1053
+ v2Result.depositId,
1054
+ 'v2',
1055
+ 'deposit'
1056
+ );
1057
+
1058
+ console.log(`📊 Final V2 Status: [ ${statusCheck.status} ]`);
1059
+ if (statusCheck.status === 'FAILED') {
1060
+ // V2 failure messages are nested in 'data' usually
1061
+ const msg = statusCheck.data?.failureReason?.failureMessage || 'Unknown';
1062
+ console.warn(` Reason: ${msg}`);
1063
+ }
1064
+
1065
+ } else {
1066
+ console.error('❌ V2 Failed:', v2Result.error);
1067
+ }
1068
+ } catch (error) {
1069
+ console.error('❌ V2 Exception:', error.message);
1070
+ }
1071
+
1072
+ console.log('\n🏁 SDK Testing Complete');
1073
+ process.exit(0);
1074
+ }
1075
+
1076
+ // Run test if called directly
1077
+ if (require.main === module) {
1078
+ testSDKConnection().catch(console.error);
1079
+ }
1080
+
1081
+ module.exports = { testSDKConnection };
1082
+ ```
1083
+ ---
1084
+
1085
+ ### Deposit Senario (Hosted Page)
1086
+
1087
+ ```typescript
1088
+
1089
+ // test-payment-page.js
1090
+ require('dotenv').config();
1091
+ const PawaPayService = require('./pawapayService');
1092
+
1093
+ /**
1094
+ * Run a full test of the PawaPay Payment Page Integration
1095
+ */
1096
+ async function testPaymentPage() {
1097
+ console.log('🔗 Starting PawaPay Payment Page Test...\n');
1098
+
1099
+ const service = new PawaPayService();
1100
+
1101
+ // 1. Validate Token Format
1102
+ console.log('🔹 Step 1: Validating API Token...');
1103
+ const testToken = process.env.PAWAPAY_SANDBOX_API_TOKEN;
1104
+ const validation = service.validateToken(testToken);
1105
+
1106
+ if (!validation.isValid) {
1107
+ console.error('❌ Token Validation Failed:', validation);
1108
+ return;
1109
+ }
1110
+ console.log('✅ Token looks valid:', validation.type);
1111
+
1112
+ // Common Test Data
1113
+ const commonData = {
1114
+ amount: '500',
1115
+ currency: '51345789',
1116
+ payerMsisdn: '22951345789', // Optional for V2, but good for V1
1117
+ description: 'Page Test',
1118
+ // IMPORTANT: You need a return URL
1119
+ returnUrl: 'https://example.com/payment-success',
1120
+ country: 'UGA'
1121
+ };
1122
+
1123
+ // 2. Test V1 Payment Page
1124
+ console.log('\n🔹 Step 2: Testing V1 Payment Page Generation...');
1125
+ try {
1126
+ const v1Result = await service.initiatePaymentPage({
1127
+ ...commonData,
1128
+ description: 'V1 Page Test'
1129
+ }, 'v1');
1130
+
1131
+ if (v1Result.success) {
1132
+ console.log('✅ V1 Success!');
1133
+ console.log(' Deposit ID:', v1Result.depositId);
1134
+ console.log(' 👉 CLICK TO PAY:', v1Result.redirectUrl);
1135
+ } else {
1136
+ console.error('❌ V1 Failed:', v1Result.error);
1137
+ }
1138
+ } catch (error) {
1139
+ console.error('❌ V1 Exception:', error.message);
1140
+ }
1141
+
1142
+ // 3. Test V2 Payment Page
1143
+ console.log('\n🔹 Step 3: Testing V2 Payment Page Generation...');
1144
+ try {
1145
+ const v2Result = await service.initiatePaymentPage({
1146
+ ...commonData,
1147
+ description: 'V2 Page Test',
1148
+ // V2 specific metadata
1149
+ metadata: [
1150
+ { fieldName: "product_id", fieldValue: "PROD-999" },
1151
+ { fieldName: "email", fieldValue: "user@test.com", isPII: true }
1152
+ ]
1153
+ }, 'v2');
1154
+
1155
+ if (v2Result.success) {
1156
+ console.log('✅ V2 Success!');
1157
+ console.log(' Deposit ID:', v2Result.depositId);
1158
+ console.log(' 👉 CLICK TO PAY:', v2Result.redirectUrl);
1159
+ } else {
1160
+ console.error('❌ V2 Failed:', v2Result.error);
1161
+ }
1162
+ } catch (error) {
1163
+ console.error('❌ V2 Exception:', error.message);
1164
+ }
1165
+
1166
+ console.log('\n🏁 Payment Page Testing Complete');
1167
+ }
1168
+
1169
+ // Run test if called directly
1170
+ if (require.main === module) {
1171
+ testPaymentPage().catch(console.error);
1172
+ }
1173
+
1174
+ module.exports = { testPaymentPage };
1175
+ ```
1176
+ ---
1177
+
1178
+ ### Payout Senario
1179
+
1180
+ ```typescript
1181
+
1182
+ // test-payout.js
1183
+ const path = require('path');
1184
+ require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
1185
+
1186
+ const PawaPayService = require('./pawapayService');
1187
+
1188
+ async function runPayoutTest() {
1189
+ console.log('\n========================================');
1190
+ console.log('🚀 PAWAPAY SDK PAYOUT TEST (With Status Check)');
1191
+ console.log('========================================\n');
1192
+
1193
+ const service = new PawaPayService({});
1194
+
1195
+ const testData = {
1196
+ amount: "1000",
1197
+ currency: "UGX",
1198
+ recipientMsisdn: "256783456789",
1199
+ mno: "MTN_MOMO_UGA",
1200
+ description: "SDK Payout Test",
1201
+ metadata: [
1202
+ { fieldName: "test_run", fieldValue: "true" }
1203
+ ]
1204
+ };
1205
+
1206
+ const API_VERSION = 'v1';
1207
+
1208
+ console.log(`Initiating Payout [${API_VERSION}]...`);
1209
+
1210
+ try {
1211
+ // --- STEP 1: INITIATE ---
1212
+ const result = await service.payout(testData, API_VERSION);
1213
+
1214
+ if (result.success) {
1215
+ console.log('\n Payout Submitted Successfully!');
1216
+ console.log(` ID: ${result.payoutId}`);
1217
+
1218
+ // --- STEP 2: THE WAIT (Crucial for logical testing) ---
1219
+ console.log('\n⏳ Waiting 5 seconds for Sandbox propagation...');
1220
+ await new Promise(resolve => setTimeout(resolve, 5000));
1221
+
1222
+ // --- STEP 3: CHECK STATUS ---
1223
+ console.log('🔍 Checking Payout Status...');
1224
+
1225
+ // We specifically pass 'payout' as the 3rd argument here
1226
+ const statusCheck = await service.checkTransactionStatus(
1227
+ result.payoutId,
1228
+ API_VERSION,
1229
+ 'payout'
1230
+ );
1231
+
1232
+ if (statusCheck.success) {
1233
+ const status = statusCheck.status;
1234
+ const failureMsg = statusCheck.data?.failureReason?.failureMessage;
1235
+
1236
+ console.log(`\n FINAL STATUS: [ ${status} ]`);
1237
+
1238
+ if (status === 'COMPLETED') {
1239
+ console.log(' SUCCESS: Money sent.');
1240
+ } else if (status === 'FAILED') {
1241
+ console.log(` FAILED: ${failureMsg || 'Unknown reason'}`);
1242
+ } else {
1243
+ console.log(' PENDING: Still processing.');
1244
+ }
1245
+
1246
+ // console.log('Debug Data:', JSON.stringify(statusCheck.data, null, 2));
1247
+ } else {
1248
+ console.error(' Could not fetch status:', statusCheck.error);
1249
+ }
1250
+
1251
+ } else {
1252
+ console.error('\n Payout Initiation Failed');
1253
+ console.error(`Error: ${result.error}`);
1254
+ }
1255
+
1256
+ } catch (e) {
1257
+ console.error(' Script Error:', e);
1258
+ } finally {
1259
+ process.exit(0);
1260
+ }
1261
+ }
1262
+
1263
+ runPayoutTest();
1264
+
1265
+ ```
1266
+ ---
1267
+ ### Refund Senario
1268
+
1269
+ ```typescript
1270
+
1271
+ // test-refund.js
1272
+ const path = require('path');
1273
+ require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
1274
+
1275
+ const PawaPayService = require('./pawapayService');
1276
+
1277
+ async function runRefundTest() {
1278
+ console.log('\n========================================');
1279
+ console.log('💸 PAWAPAY SDK REFUND TEST');
1280
+ console.log('========================================\n');
1281
+
1282
+ const service = new PawaPayService({});
1283
+
1284
+ // DATA FROM YOUR LOGS
1285
+ const EXISTING_DEPOSIT_ID = "e6abef2a-7b54-4d5b-9afb-8ccc831a228b";//test deposit id - example
1286
+
1287
+ // We refund a partial amount to be safe/realistic
1288
+ const testData = {
1289
+ depositId: EXISTING_DEPOSIT_ID,
1290
+ amount: "500", // Refund half of the original 1000
1291
+ currency: "UGX",
1292
+ reason: "Customer requested partial refund",
1293
+ metadata: [
1294
+ { fieldName: "reason", fieldValue: "sdk_test_script" },
1295
+ { fieldName: "original_order", fieldValue: "ORD-SDK-TEST" }
1296
+ ]
1297
+ };
1298
+
1299
+ // Toggle this to test V1 vs V2
1300
+ const API_VERSION = 'v1';
1301
+
1302
+ console.log(` Initiating Refund [${API_VERSION}] for Deposit: ${EXISTING_DEPOSIT_ID}...`);
1303
+
1304
+ try {
1305
+ // --- STEP 1: INITIATE REFUND ---
1306
+ const result = await service.refund(testData, API_VERSION);
1307
+
1308
+ if (result.success) {
1309
+ console.log('\n Refund Submitted Successfully!');
1310
+ console.log(` Refund ID: ${result.refundId}`);
1311
+ console.log(` Status: ${result.status}`);
1312
+
1313
+ // --- STEP 2: WAIT ---
1314
+ console.log('\n Waiting 5 seconds for processing...');
1315
+ await new Promise(resolve => setTimeout(resolve, 5000));
1316
+
1317
+ // --- STEP 3: CHECK STATUS ---
1318
+ console.log(' Checking Final Refund Status...');
1319
+
1320
+ // Critical: Pass 'refund' as the 3rd argument
1321
+ const statusCheck = await service.checkTransactionStatus(
1322
+ result.refundId,
1323
+ API_VERSION,
1324
+ 'refund'
1325
+ );
1326
+
1327
+ if (statusCheck.success) {
1328
+ console.log(`\n FINAL STATUS: [ ${statusCheck.status} ]`);
1329
+ console.log('Data:', JSON.stringify(statusCheck.data, null, 2));
1330
+ } else {
1331
+ console.error(' Could not fetch status:', statusCheck.error);
1332
+ }
1333
+
1334
+ } else {
1335
+ console.error('\n Refund Initiation Failed');
1336
+ console.error(`Error: ${result.error}`);
1337
+ if (result.rawResponse) {
1338
+ console.error('Raw:', JSON.stringify(result.rawResponse.response || result.rawResponse, null, 2));
1339
+ }
1340
+ }
1341
+
1342
+ } catch (e) {
1343
+ console.error(' Script Error:', e);
1344
+ } finally {
1345
+ process.exit(0);
1346
+ }
1347
+ }
1348
+
1349
+ runRefundTest();
1350
+
1351
+ ```
1352
+
210
1353
  ---
211
1354
 
212
1355
  ### MNO Configuration & Version Switching
@@ -230,6 +1373,128 @@ You may toggle **V1 or V2** behavior through these configuration files.
230
1373
  > **Important**
231
1374
  > You should configure a cron job to periodically refresh these files to ensure MNO availability and configuration data remain current.
232
1375
 
1376
+ Here is a sample code too using the installed SDK
1377
+
1378
+ ```typescript
1379
+
1380
+ /**
1381
+ * pawapayFetchConfig.js
1382
+ * * AUTOMATED CONFIGURATION UPDATER
1383
+ * Uses the PawaPayService to fetch the latest MNO definitions and Active Configurations
1384
+ * from the Sandbox environment and updates the static JSON files.
1385
+ *
1386
+ */
1387
+
1388
+ const fs = require('fs');
1389
+ const path = require('path');
1390
+
1391
+ // 🔒 HARD LOCK THE WORKING DIRECTORY (CRON-PROOF)
1392
+ process.chdir('path/to/nodejs/dir');
1393
+
1394
+ // Load environment variables explicitly
1395
+ require('dotenv').config({
1396
+ path: 'path/to/nodejs/dir/.env'
1397
+ });
1398
+
1399
+ // (Optional debug – safe to remove once satisfied)
1400
+ // console.log({
1401
+ // cwd: process.cwd(),
1402
+ // home: process.env.HOME
1403
+ // });
1404
+
1405
+ // Load PawaPay service AFTER cwd + env are stable
1406
+ const PawaPayService = require('../pawapayService');
1407
+
1408
+ // CONFIGURATION
1409
+ // Use __dirname to ensure data directory is script-relative
1410
+ const OUTPUT_DIR = path.join(__dirname, '../data');
1411
+
1412
+ // Ensure directory exists
1413
+ if (!fs.existsSync(OUTPUT_DIR)) {
1414
+ console.log(`[Config] Directory not found, creating: ${OUTPUT_DIR}`);
1415
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
1416
+ }
1417
+
1418
+ // Initialize Service (Automatically loads ENV tokens and Sandbox mode)
1419
+ const service = new PawaPayService();
1420
+
1421
+ /**
1422
+ * Helper to write JSON files surgically
1423
+ */
1424
+ const saveJson = (filename, data) => {
1425
+ const filePath = path.join(OUTPUT_DIR, filename);
1426
+ try {
1427
+ const content = JSON.stringify(data, null, 2);
1428
+ fs.writeFileSync(filePath, content, 'utf8');
1429
+ console.log(` [SUCCESS] Updated: ${filename} (${content.length} bytes)`);
1430
+ } catch (err) {
1431
+ console.error(` [ERROR] Failed to write ${filename}:`, err.message);
1432
+ }
1433
+ };
1434
+
1435
+ /**
1436
+ * Main Execution Function
1437
+ */
1438
+ const updateConfigurations = async () => {
1439
+ console.log(' [Config] Starting PawaPay Configuration Update (Sandbox)...');
1440
+ console.log(`TB: ${OUTPUT_DIR}`);
1441
+
1442
+ try {
1443
+ // ==========================================
1444
+ // 1. FETCH V1 CONFIGURATIONS
1445
+ // ==========================================
1446
+
1447
+ console.log(' [V1] Fetching Active Conf...');
1448
+ const activeConfV1 = await service.pawapay.checkActiveConf();
1449
+ if (activeConfV1.status === 200) {
1450
+ saveJson('active_conf_v1.json', activeConfV1.response);
1451
+ } else {
1452
+ console.error(` [V1] Active Conf Failed: ${activeConfV1.status}`);
1453
+ }
1454
+
1455
+ console.log(' [V1] Fetching MNO Availability...');
1456
+ const availabilityV1 = await service.pawapay.checkMNOAvailability();
1457
+ if (availabilityV1.status === 200) {
1458
+ saveJson('mno_availability_v1.json', availabilityV1.response);
1459
+ } else {
1460
+ console.error(` [V1] Availability Failed: ${availabilityV1.status}`);
1461
+ }
1462
+
1463
+ // ==========================================
1464
+ // 2. FETCH V2 CONFIGURATIONS
1465
+ // ==========================================
1466
+
1467
+ console.log(' [V2] Fetching Active Conf...');
1468
+ const activeConfV2 = await service.pawapay.checkActiveConfV2();
1469
+ if (activeConfV2.status === 200) {
1470
+ saveJson('active_conf_v2.json', activeConfV2.response);
1471
+ } else {
1472
+ console.error(` [V2] Active Conf Failed: ${activeConfV2.status}`);
1473
+ }
1474
+
1475
+ console.log(' [V2] Fetching MNO Availability...');
1476
+ const availabilityV2 = await service.pawapay.checkMNOAvailabilityV2();
1477
+ if (availabilityV2.status === 200) {
1478
+ saveJson('mno_availability_v2.json', availabilityV2.response);
1479
+ } else {
1480
+ console.error(` [V2] Availability Failed: ${availabilityV2.status}`);
1481
+ }
1482
+
1483
+ console.log(' [DONE] All configurations updated successfully.');
1484
+ process.exit(0);
1485
+
1486
+ } catch (error) {
1487
+ console.error(' [CRITICAL] Script failed:', error.message);
1488
+ process.exit(1);
1489
+ }
1490
+ };
1491
+
1492
+ // Run
1493
+ updateConfigurations();
1494
+
1495
+
1496
+ ```
1497
+
233
1498
  This approach keeps runtime fast, predictable, and independent of unnecessary API calls.
234
1499
 
235
1500
  ## Support