@@ -2,104 +2,167 @@ import { Injectable, HttpException, Logger } from '@nestjs/common';
22import { CreateMpesaExpressDto } from './dto/create-mpesa-express.dto' ;
33import { AuthService } from 'src/services/auth.service' ;
44import { ConfigService } from '@nestjs/config' ;
5- import axios from 'axios' ;
6- import { RedisService , DEFAULT_REDIS } from '@liaoliaots/nestjs-redis' ;
5+ import { RedisService } from '@liaoliaots/nestjs-redis' ;
76import { Redis } from 'ioredis' ;
7+ import axios , { AxiosError } from 'axios' ;
8+
9+ interface MpesaConfig {
10+ shortcode : string ;
11+ passkey : string ;
12+ callbackUrl : string ;
13+ transactionType : string ;
14+ }
15+
16+ interface STKPushRequest {
17+ BusinessShortCode : string ;
18+ Password : string ;
19+ Timestamp : string ;
20+ TransactionType : string ;
21+ Amount : number ;
22+ PartyA : string ;
23+ PartyB : string ;
24+ PhoneNumber : string ;
25+ CallBackURL : string ;
26+ AccountReference : string ;
27+ TransactionDesc : string ;
28+ }
829
930@Injectable ( )
1031export class MpesaExpressService {
32+ private readonly logger = new Logger ( MpesaExpressService . name ) ;
33+ private readonly mpesaConfig : MpesaConfig ;
34+ private readonly redis : Redis ;
35+
1136 constructor (
12- private authService : AuthService ,
13- private configService : ConfigService ,
14- private readonly redisService : RedisService
15- ) { }
37+ private readonly authService : AuthService ,
38+ private readonly configService : ConfigService ,
39+ private readonly redisService : RedisService ,
40+ ) {
41+ this . mpesaConfig = {
42+ shortcode : '174379' ,
43+ passkey : this . configService . get < string > ( 'PASS_KEY' ) ,
44+ callbackUrl : 'https://goose-merry-mollusk.ngrok-free.app/api/mpesa/callback' ,
45+ transactionType : 'CustomerPayBillOnline' ,
46+ } ;
47+ this . redis = this . redisService . getOrThrow ( ) ;
48+ }
1649
17- private readonly redis : Redis | null ;
18- private logger = new Logger ( 'MpesaExpressService' ) ;
19-
50+ async stkPush ( dto : CreateMpesaExpressDto ) : Promise < any > {
51+ try {
52+ await this . validateDto ( dto ) ;
2053
21- private async generateTimestamp ( ) : Promise < string > {
22- const date = new Date ( ) ;
23- return date . getFullYear ( ) +
24- ( '0' + ( date . getMonth ( ) + 1 ) ) . slice ( - 2 ) +
25- ( '0' + date . getDate ( ) ) . slice ( - 2 ) +
26- ( '0' + date . getHours ( ) ) . slice ( - 2 ) +
27- ( '0' + date . getMinutes ( ) ) . slice ( - 2 ) +
28- ( '0' + date . getSeconds ( ) ) . slice ( - 2 ) ;
29- }
54+ const token = await this . getAuthToken ( ) ;
55+ const timestamp = this . generateTimestamp ( ) ;
56+ const password = this . generatePassword ( timestamp ) ;
3057
58+ const requestBody = this . createSTKPushRequest ( dto , timestamp , password ) ;
59+ const response = await this . sendSTKPushRequest ( requestBody , token ) ;
3160
32- async validateDto ( createMpesaExpressDto : CreateMpesaExpressDto ) : Promise < void > {
33- const obeysPhoneNum = createMpesaExpressDto . phoneNum . match ( / ^ 2 5 4 7 \d { 8 } $ / ) ;
34- if ( ! obeysPhoneNum ) {
35- this . logger . warn ( "The phone number does not obey the format" ) ;
36- throw new HttpException ( 'Phone number must be in the format 2547XXXXXXXX"' , 400 ) ;
37- }
61+ await this . cachePaymentDetails ( response . data ) ;
3862
39- const obeysAccountRef = createMpesaExpressDto . accountRef . match ( / ^ [ a - z A - Z 0 - 9 ] { 1 , 12 } $ / ) ;
40- if ( ! obeysAccountRef ) {
41- this . logger . warn ( "The account reference does not obey the format" ) ;
42- throw new HttpException ( 'Account reference must be alphanumeric and not more than 12 characters' , 400 ) ;
63+ return response . data ;
64+ } catch ( error ) {
65+ this . handleError ( error ) ;
4366 }
67+ }
4468
45- const obeysAmount = createMpesaExpressDto . amount > 0 ;
46- if ( ! obeysAmount ) {
47- this . logger . warn ( "The amount does not obey the format" ) ;
48- throw new HttpException ( 'Amount must be greater than 0' , 400 ) ;
69+ private validateDto ( dto : CreateMpesaExpressDto ) : void {
70+ const validations = [
71+ {
72+ condition : ! dto . phoneNum . match ( / ^ 2 5 4 7 \d { 8 } $ / ) ,
73+ message : 'Phone number must be in the format 2547XXXXXXXX' ,
74+ } ,
75+ {
76+ condition : ! dto . accountRef . match ( / ^ [ a - z A - Z 0 - 9 ] { 1 , 12 } $ / ) ,
77+ message : 'Account reference must be alphanumeric and not more than 12 characters' ,
78+ } ,
79+ {
80+ condition : dto . amount <= 0 ,
81+ message : 'Amount must be greater than 0' ,
82+ } ,
83+ ] ;
84+
85+ const failure = validations . find ( ( validation ) => validation . condition ) ;
86+ if ( failure ) {
87+ this . logger . warn ( `Validation failed: ${ failure . message } ` ) ;
88+ throw new HttpException ( failure . message , 400 ) ;
4989 }
50-
51- return ;
5290 }
53-
54- async stkPush ( createMpesaExpressDto : CreateMpesaExpressDto ) : Promise < void > {
5591
56- await this . validateDto ( createMpesaExpressDto ) ;
57-
58- const shortcode = "174379" ;
59- const passkey = this . configService . get ( 'PASS_KEY' ) ;
92+ private generateTimestamp ( ) : string {
93+ const date = new Date ( ) ;
94+ const pad = ( num : number ) => num . toString ( ) . padStart ( 2 , '0' ) ;
6095
61- const timestamp = await this . generateTimestamp ( ) ;
62- const password = Buffer . from ( `${ shortcode } ${ passkey } ${ timestamp } ` ) . toString ( 'base64' ) ;
96+ return (
97+ `${ date . getFullYear ( ) } ${ pad ( date . getMonth ( ) + 1 ) } ${ pad ( date . getDate ( ) ) } ` +
98+ `${ pad ( date . getHours ( ) ) } ${ pad ( date . getMinutes ( ) ) } ${ pad ( date . getSeconds ( ) ) } `
99+ ) ;
100+ }
101+
102+ private generatePassword ( timestamp : string ) : string {
103+ const { shortcode, passkey } = this . mpesaConfig ;
104+ return Buffer . from ( `${ shortcode } ${ passkey } ${ timestamp } ` ) . toString ( 'base64' ) ;
105+ }
63106
107+ private async getAuthToken ( ) : Promise < string > {
64108 const token = await this . authService . generateToken ( ) ;
65- this . logger . debug ( `Token: ${ token } ` ) ;
66109 if ( ! token ) {
67110 throw new HttpException ( 'Failed to generate token, please check your environment variables' , 401 ) ;
68111 }
112+ return token ;
113+ }
69114
70- this . logger . debug ( password )
115+ private createSTKPushRequest ( dto : CreateMpesaExpressDto , timestamp : string , password : string ) : STKPushRequest {
116+ const { shortcode, transactionType, callbackUrl } = this . mpesaConfig ;
71117
72- const bodyRequest = {
73- BusinessShortCode : '174379' ,
118+ return {
119+ BusinessShortCode : shortcode ,
74120 Password : password ,
75121 Timestamp : timestamp ,
76- TransactionType : 'CustomerPayBillOnline' ,
77- Amount : createMpesaExpressDto . amount ,
78- PartyA : createMpesaExpressDto . phoneNum ,
79- PartyB : '174379' ,
80- PhoneNumber : createMpesaExpressDto . phoneNum ,
81- CallBackURL : 'https://mydomain.com/ytr' ,
82- AccountReference : createMpesaExpressDto . accountRef ,
122+ TransactionType : transactionType ,
123+ Amount : dto . amount ,
124+ PartyA : dto . phoneNum ,
125+ PartyB : shortcode ,
126+ PhoneNumber : dto . phoneNum ,
127+ CallBackURL : callbackUrl ,
128+ AccountReference : dto . accountRef ,
83129 TransactionDesc : 'szken' ,
84130 } ;
131+ }
85132
86- try {
87- const response = await axios . post ( 'https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest' , bodyRequest , {
88- headers : {
89- Authorization : `Bearer ${ token } ` ,
90- 'Content-Type' : 'application/json' ,
91- } ,
92- } ) ;
93- const checkoutRequestID = response . data . CheckoutRequestID ;
94- const redisClient = this . redisService . getOrThrow ( ) ;
133+ private async sendSTKPushRequest ( requestBody : STKPushRequest , token : string ) {
134+ return axios . post ( 'https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest' , requestBody , {
135+ headers : {
136+ Authorization : `Bearer ${ token } ` ,
137+ 'Content-Type' : 'application/json' ,
138+ } ,
139+ } ) ;
140+ }
95141
96- await redisClient . setex ( checkoutRequestID , 3600 , JSON . stringify ( { ...response . data , status : 'PENDING' } ) ) ;
142+ private async cachePaymentDetails ( paymentData : any ) : Promise < void > {
143+ try {
144+ await this . redis . setex (
145+ paymentData . CheckoutRequestID ,
146+ 3600 ,
147+ JSON . stringify ( { ...paymentData , status : 'PENDING' } ) ,
148+ ) ;
149+ } catch ( error ) {
150+ this . logger . error ( `Error during caching: ${ error } ` ) ;
151+ throw new HttpException ( 'Failed to cache payment' , 500 ) ;
152+ }
153+ }
97154
98- return response . data ;
155+ private handleError ( error : unknown ) : never {
156+ if ( error instanceof HttpException ) {
157+ throw error ;
158+ }
99159
100- } catch ( error ) {
101- this . logger . error ( `Error during STK Push : ${ error } ` ) ;
102- throw new HttpException ( ' Failed to initiate STK Push' , 500 ) ;
160+ if ( error instanceof AxiosError ) {
161+ this . logger . error ( `API Error : ${ error . message } ` , error . response ?. data ) ;
162+ throw new HttpException ( ` Failed to process payment: ${ error . message } ` , error . response ?. status || 500 ) ;
103163 }
164+
165+ this . logger . error ( `Unexpected error: ${ error } ` ) ;
166+ throw new HttpException ( 'Internal server error' , 500 ) ;
104167 }
105168}
0 commit comments