Skip to content

Commit a6e76ea

Browse files
authored
Merge pull request #88 from Domains18/nest-branch
Refactor MpesaExpressController to enhance STK Push and callback hand…
2 parents 0eb5a35 + 7466ab9 commit a6e76ea

File tree

3 files changed

+228
-101
lines changed

3 files changed

+228
-101
lines changed

contributors.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 97 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,112 @@
1-
import { Controller, Get, Post, Body, Patch, Param, Delete, Logger } from '@nestjs/common';
1+
import { Controller, Post, Body, Logger, HttpException, HttpStatus } from '@nestjs/common';
22
import { MpesaExpressService } from './mpesa-express.service';
33
import { CreateMpesaExpressDto } from './dto/create-mpesa-express.dto';
4-
import Redis from 'ioredis';
4+
import { Redis } from 'ioredis';
55
import { RedisService } from '@liaoliaots/nestjs-redis';
66

7+
interface STKCallback {
8+
Body: {
9+
stkCallback: {
10+
MerchantRequestID: string;
11+
CheckoutRequestID: string;
12+
ResultCode: number;
13+
ResultDesc: string;
14+
};
15+
};
16+
}
17+
18+
interface PaymentStatus {
19+
status: 'PENDING' | 'COMPLETED' | 'FAILED';
20+
[key: string]: any;
21+
}
22+
723
@Controller('mpesa')
824
export class MpesaExpressController {
9-
private readonly redis: Redis | null;
10-
private logger = new Logger('MpesaExpressController');
25+
private readonly logger = new Logger(MpesaExpressController.name);
26+
private readonly redis: Redis;
27+
1128
constructor(
12-
private mpesaExpressService: MpesaExpressService,
13-
private readonly redisService: RedisService
14-
) {}
29+
private readonly mpesaExpressService: MpesaExpressService,
30+
private readonly redisService: RedisService,
31+
) {
32+
this.redis = this.redisService.getOrThrow();
33+
}
1534

1635
@Post('/stkpush')
17-
create(@Body() createMpesaExpressDto: CreateMpesaExpressDto) {
18-
return this.mpesaExpressService.stkPush(createMpesaExpressDto);
36+
async initiateSTKPush(@Body() createMpesaExpressDto: CreateMpesaExpressDto) {
37+
try {
38+
const result = await this.mpesaExpressService.stkPush(createMpesaExpressDto);
39+
return {
40+
success: true,
41+
data: result,
42+
};
43+
} catch (error) {
44+
this.logger.error(`STK Push failed: ${error.message}`);
45+
throw new HttpException('Failed to initiate payment', HttpStatus.INTERNAL_SERVER_ERROR);
46+
}
1947
}
2048

2149
@Post('/callback')
22-
async handleCallback(@Body() callBackData: any) {
23-
this.logger.debug(`Callback data: ${JSON.stringify(callBackData)}`);
24-
const redisClient = this.redisService.getOrThrow();
25-
26-
const { Body: { stkCallback } } = callBackData;
27-
28-
const { MerchantRequestID, CheckoutRequestID, ResultCode, ResultDesc } = stkCallback;
29-
30-
const payment = await redisClient.get(MerchantRequestID);
31-
// if (!payment || payment === null || payment === undefined) {
32-
// this.logger.error('Payment not found, was it cached?');
33-
// }
34-
35-
if (payment) {
36-
this.logger.debug(`Payment found: ${payment}`);
37-
const parsedData = JSON.parse(payment);
38-
39-
if(ResultCode === 0) {
40-
parsedData.status = 'COMPLETED';
41-
} else {
42-
parsedData.status = 'FAILED';
43-
}
44-
await redisClient.set(MerchantRequestID, JSON.stringify(parsedData));
45-
} else {
46-
this.logger.error('Payment not found, was it cached?');
50+
async handleCallback(@Body() callbackData: STKCallback) {
51+
try {
52+
this.logger.debug('Processing callback data:', JSON.stringify(callbackData));
53+
54+
const {
55+
Body: {
56+
stkCallback: { MerchantRequestID, CheckoutRequestID, ResultCode, ResultDesc },
57+
},
58+
} = callbackData;
59+
60+
await this.updatePaymentStatus(CheckoutRequestID, MerchantRequestID, ResultCode, ResultDesc);
61+
62+
return {
63+
success: true,
64+
message: 'Callback processed successfully',
65+
};
66+
} catch (error) {
67+
this.logger.error(`Callback processing failed: ${error.message}`);
68+
throw new HttpException('Failed to process callback', HttpStatus.INTERNAL_SERVER_ERROR);
4769
}
4870
}
71+
72+
private async updatePaymentStatus(
73+
checkoutRequestId: string,
74+
merchantRequestId: string,
75+
resultCode: number,
76+
resultDesc: string,
77+
): Promise<void> {
78+
const paymentJson = await this.redis.get(checkoutRequestId);
79+
80+
if (!paymentJson) {
81+
this.logger.error(`Payment not found for CheckoutRequestID: ${checkoutRequestId}`);
82+
throw new HttpException('Payment record not found', HttpStatus.NOT_FOUND);
83+
}
84+
85+
try {
86+
const payment: PaymentStatus = JSON.parse(paymentJson);
87+
88+
const updatedPayment: PaymentStatus = {
89+
...payment,
90+
status: this.determinePaymentStatus(resultCode),
91+
resultDescription: resultDesc,
92+
lastUpdated: new Date().toISOString(),
93+
};
94+
95+
await this.redis.set(
96+
merchantRequestId,
97+
JSON.stringify(updatedPayment),
98+
'EX',
99+
3600, // 1 hour expiry
100+
);
101+
102+
this.logger.debug(`Payment status updated: ${JSON.stringify(updatedPayment)}`);
103+
} catch (error) {
104+
this.logger.error(`Failed to update payment status: ${error.message}`);
105+
throw new HttpException('Failed to update payment status', HttpStatus.INTERNAL_SERVER_ERROR);
106+
}
107+
}
108+
109+
private determinePaymentStatus(resultCode: number): PaymentStatus['status'] {
110+
return resultCode === 0 ? 'COMPLETED' : 'FAILED';
111+
}
49112
}

src/mpesa-express/mpesa-express.service.ts

Lines changed: 130 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,104 +2,167 @@ import { Injectable, HttpException, Logger } from '@nestjs/common';
22
import { CreateMpesaExpressDto } from './dto/create-mpesa-express.dto';
33
import { AuthService } from 'src/services/auth.service';
44
import { 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';
76
import { 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()
1031
export 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(/^2547\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-zA-Z0-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(/^2547\d{8}$/),
73+
message: 'Phone number must be in the format 2547XXXXXXXX',
74+
},
75+
{
76+
condition: !dto.accountRef.match(/^[a-zA-Z0-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

Comments
 (0)