Skip to content

Commit

Permalink
(feat) : Add MPESA STK push functionality (#241)
Browse files Browse the repository at this point in the history
* git eol sequence settings

* feat: real time payment status notification

* refactor: new mpesa backend url

* feat: loading indicator on stk-push

* Update packages/esm-billing-app/src/m-pesa/mpesa-resource.tsx

refactor: removed comments for local test api

Co-authored-by: Donald Kibet <[email protected]>

* refactor: used translation for error message return

Co-authored-by: Donald Kibet <[email protected]>

* refactor: better error types for getting custom error

* refactor: use custom hook instead of effect in component

* refactor: return only poll trigger

* feat: reading URL from configuration

* feat: disable button to avoid recalling the API for pending requests

* refactor: auto generated translation

* refactor: Request status type on correct module

---------

Co-authored-by: Amoh Prince <[email protected]>
Co-authored-by: Donald Kibet <[email protected]>
  • Loading branch information
3 people authored Jun 27, 2024
1 parent c8aa8e6 commit c1e8f28
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 70 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,5 @@ dist
e2e/storageState.json
.env

#vscode
.vscode/settings.json
6 changes: 6 additions & 0 deletions packages/esm-billing-app/src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface BillingConfig {
authorizationUrl: string;
initiateUrl: string;
billingStatusQueryUrl: string;
mpesaAPIBaseUrl: string;
}

export const configSchema = {
Expand All @@ -27,6 +28,11 @@ export const configSchema = {
_description: 'The visit type uuid for in-patient',
_default: 'a73e2ac6-263b-47fc-99fc-e0f2c09fc914',
},
mpesaAPIBaseUrl: {
_type: Type.String,
_description: 'The base url that will be used to make any backend calls related to mpesa.',
_default: 'https://billing.kenyahmis.org',
},
visitAttributeTypes: {
isPatientExempted: {
_type: Type.String,
Expand Down
55 changes: 55 additions & 0 deletions packages/esm-billing-app/src/hooks/useRequestStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useState, useEffect, SetStateAction } from 'react';
import { getRequestStatus, readableStatusMap, getErrorMessage } from '../m-pesa/mpesa-resource';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@openmrs/esm-framework';
import { BillingConfig } from '../config-schema';
import { RequestStatus } from '../types';

type RequestData = { requestId: string; requestStatus: RequestStatus | null };

/**
* useRequestStatus
* @param setNotification a function to call with the appropriate notification type
* @returns a function to trigger the polling.
*/
export const useRequestStatus = (
setNotification: React.Dispatch<SetStateAction<{ type: 'error' | 'success'; message: string } | null>>,
): [RequestData, React.Dispatch<React.SetStateAction<RequestData | null>>] => {
const { t } = useTranslation();
const { mpesaAPIBaseUrl } = useConfig<BillingConfig>();

const [requestData, setRequestData] = useState<{ requestId: string; requestStatus: RequestStatus | null }>({
requestId: null,
requestStatus: null,
});

useEffect(() => {
let interval: NodeJS.Timeout;

if (requestData.requestId && !['COMPLETE', 'FAILED', 'NOT-FOUND'].includes(requestData.requestStatus)) {
const fetchStatus = async () => {
try {
const status = await getRequestStatus(requestData.requestId, mpesaAPIBaseUrl);
if (status === 'COMPLETE' || status === 'FAILED' || status === 'NOT-FOUND') {
clearInterval(interval);
}
if (status === 'COMPLETE' || status === 'INITIATED') {
setNotification({ type: 'success', message: readableStatusMap.get(status) });
}
if (status === 'FAILED' || status === 'NOT-FOUND') {
setNotification({ type: 'error', message: readableStatusMap.get(status) });
}
} catch (error) {
clearInterval(interval);
setNotification({ type: 'error', message: getErrorMessage(error, t) });
}
};

interval = setInterval(fetchStatus, 2000);

return () => clearInterval(interval);
}
}, [mpesaAPIBaseUrl, requestData.requestId, requestData.requestStatus, setNotification, t]);

return [requestData, setRequestData];
};
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, ModalBody, ModalHeader, TextInput, Layer, InlineNotification } from '@carbon/react';
import { Button, Form, ModalBody, ModalHeader, TextInput, Layer, InlineNotification, Loading } from '@carbon/react';
import styles from './initiate-payment.scss';
import { Controller, useForm } from 'react-hook-form';
import { MappedBill } from '../../../types';
import { showSnackbar, useConfig } from '@openmrs/esm-framework';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { formatPhoneNumber } from '../utils';
import { Buffer } from 'buffer';
import { useSystemSetting } from '../../../hooks/getMflCode';
import { initiateStkPush } from '../../../m-pesa/mpesa-resource';
import { useRequestStatus } from '../../../hooks/useRequestStatus';
import { useConfig } from '@openmrs/esm-framework';
import { BillingConfig } from '../../../config-schema';

const InitiatePaymentSchema = z.object({
phoneNumber: z
Expand All @@ -27,9 +28,11 @@ export interface InitiatePaymentDialogProps {

const InitiatePaymentDialog: React.FC<InitiatePaymentDialogProps> = ({ closeModal, bill }) => {
const { t } = useTranslation();
const { mpesaCallbackUrl, passKey, shortCode, authorizationUrl, initiateUrl } = useConfig();
const { mpesaAPIBaseUrl } = useConfig<BillingConfig>();
const { mflCodeValue } = useSystemSetting('facility.mflcode');
const [notification, setNotification] = useState<string | null>(null);
const [notification, setNotification] = useState<{ type: 'error' | 'success'; message: string } | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [{ requestStatus }, pollingTrigger] = useRequestStatus(setNotification);

const {
control,
Expand All @@ -43,41 +46,21 @@ const InitiatePaymentDialog: React.FC<InitiatePaymentDialogProps> = ({ closeModa
resolver: zodResolver(InitiatePaymentSchema),
});

const onSubmit = async (data) => {
const timeStamp = new Date()
.toISOString()
.replace(/[^0-9]/g, '')
.slice(0, -3);
const onSubmit = async (data: { phoneNumber: any; billAmount: any }) => {
const phoneNumber = formatPhoneNumber(data.phoneNumber);
const amountBilled = data.billAmount;
const password = shortCode + passKey + timeStamp;
const callBackUrl = mpesaCallbackUrl;
const Password = Buffer.from(password).toString('base64');
const accountReference = `${mflCodeValue}#${bill.receiptNumber}`;

const payload = {
BusinessShortCode: shortCode,
Password: Password,
Timestamp: timeStamp,
TransactionType: 'CustomerPayBillOnline',
PartyA: phoneNumber,
PartyB: shortCode,
PhoneNumber: phoneNumber,
CallBackURL: callBackUrl,
AccountReference: accountReference,
TransactionDesc: 'KenyaEMRPay',
PhoneNumber: phoneNumber,
Amount: amountBilled,
};

await initiateStkPush(payload, initiateUrl, authorizationUrl, setNotification);
showSnackbar({
title: t('stkPush', 'STK Push'),
subtitle: t('stkPushSucess', 'STK Push send successfully'),
kind: 'success',
timeoutInMs: 3500,
isLowContrast: true,
});
closeModal();
setIsLoading(true);
const requestId = await initiateStkPush(payload, setNotification, mpesaAPIBaseUrl);
setIsLoading(false);
pollingTrigger({ requestId, requestStatus: 'INITIATED' });
};

return (
Expand All @@ -88,9 +71,8 @@ const InitiatePaymentDialog: React.FC<InitiatePaymentDialogProps> = ({ closeModa
<h4>{t('paymentPayment', 'Bill Payment')}</h4>
{notification && (
<InlineNotification
kind="error"
title={t('mpesaError', 'Mpesa Error')}
subtitle={notification}
kind={notification.type}
title={notification.message}
onCloseButtonClick={() => setNotification(null)}
/>
)}
Expand Down Expand Up @@ -134,8 +116,19 @@ const InitiatePaymentDialog: React.FC<InitiatePaymentDialogProps> = ({ closeModa
<Button kind="secondary" className={styles.buttonLayout} onClick={closeModal}>
{t('cancel', 'Cancel')}
</Button>
<Button type="submit" className={styles.button} onClick={handleSubmit(onSubmit)} disabled={!isValid}>
{t('initiatePay', 'Initiate Payment')}
<Button
type="submit"
className={styles.button}
onClick={handleSubmit(onSubmit)}
disabled={!isValid || isLoading || requestStatus === 'INITIATED'}>
{isLoading ? (
<>
<Loading className={styles.button_spinner} withOverlay={false} small />{' '}
{t('processingPayment', 'Processing Payment')}
</>
) : (
t('initiatePay', 'Initiate Payment')
)}
</Button>
</section>
</Form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,8 @@
margin-top: layout.$spacing-05;
margin-bottom: layout.$spacing-01;
}

.button_spinner {
padding: 0;
margin-right: 12px;
}
102 changes: 72 additions & 30 deletions packages/esm-billing-app/src/m-pesa/mpesa-resource.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,81 @@
import { Buffer } from 'buffer';
import { RequestStatus } from '../types';

export const generateStkAccessToken = async (authorizationUrl: string, setNotification) => {
try {
const consumerKey = '';
const consumerSecret = '';
const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64');
const headers = {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`,
};
const response = await fetch(authorizationUrl, { method: 'GET', headers: headers });
const { access_token } = await response.json();
return access_token;
} catch (error) {
setNotification('Unable to reach the MPESA server, please try again later.');
throw error;
}
};
export const readableStatusMap = new Map<RequestStatus, string>();
readableStatusMap.set('COMPLETE', 'Complete');
readableStatusMap.set('FAILED', 'Failed');
readableStatusMap.set('INITIATED', 'Waiting for user...');
readableStatusMap.set('NOT-FOUND', 'Request not found');

export const initiateStkPush = async (payload, initiateUrl: string, authorizationUrl: string, setNotification) => {
export const initiateStkPush = async (
payload,
setNotification: (notification: { type: 'error' | 'success'; message: string }) => void,
MPESA_PAYMENT_API_BASE_URL: string,
): Promise<string> => {
try {
const access_token = await generateStkAccessToken(authorizationUrl, setNotification);
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${access_token}`,
};
const response = await fetch(initiateUrl, {
const url = `${MPESA_PAYMENT_API_BASE_URL}/api/mpesa/stk-push`;

const res = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
phoneNumber: payload.PhoneNumber,
amount: payload.Amount,
accountReference: payload.AccountReference,
}),
});

return await response.json();
if (!res.ok && res.status === 403) {
const error = new Error('Health facility M-PESA data not configured.');
throw error;
}

const response: { requestId: string } = await res.json();

setNotification({ message: 'STK Push sent successfully', type: 'success' });
return response.requestId;
} catch (err) {
setNotification('Unable to initiate Lipa Na Mpesa, please try again later.');
throw err;
const error = err as Error;
setNotification({
message: error.message ?? 'Unable to initiate Lipa Na Mpesa, please try again later.',
type: 'error',
});
}
};

export const getRequestStatus = async (
requestId: string,
MPESA_PAYMENT_API_BASE_URL: string,
): Promise<RequestStatus> => {
const requestResponse = await fetch(`${MPESA_PAYMENT_API_BASE_URL}/api/mpesa/check-payment-state`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
requestId,
}),
});

if (!requestResponse.ok) {
const error = new Error(`HTTP error! status: ${requestResponse.status}`);

if (requestResponse.statusText) {
error.message = requestResponse.statusText;
}
throw error;
}

const requestStatus: { status: RequestStatus } = await requestResponse.json();

return requestStatus.status;
};

export const getErrorMessage = (err: { message: string }, t) => {
if (err.message) {
return err.message;
}

return t('unKnownErrorMsg', 'An unknown error occurred');
};
2 changes: 2 additions & 0 deletions packages/esm-billing-app/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,5 @@ export type QueueEntry = {
queueComingFrom: OpenmrsResource;
};
};

export type RequestStatus = 'INITIATED' | 'COMPLETE' | 'FAILED' | 'NOT-FOUND';
4 changes: 1 addition & 3 deletions packages/esm-billing-app/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@
"loadingDescription": "Loading",
"makeclaims": "Make Claims",
"manageBillableServices": "Manage billable services",
"mpesaError": "Mpesa Error",
"name": "Name",
"navigateBack": "Navigate back",
"nextPage": "Next page",
Expand Down Expand Up @@ -107,6 +106,7 @@
"printReceipt": "Print receipt",
"proceedToCare": "Proceed to care",
"processClaim": "Process Claim",
"processingPayment": "Processing Payment",
"processPayment": "Process Payment",
"provider_name": "Provider Name",
"providerMessage": "By clicking Proceed to care, you acknowledge that you have advised the patient to settle the bill.",
Expand Down Expand Up @@ -134,8 +134,6 @@
"shaNumber": "SHA Number",
"shortName": "Short Name",
"status": "Service Status",
"stkPush": "STK Push",
"stkPushSucess": "STK Push send successfully",
"stockItem": "Stock Item",
"total": "Total",
"totalAmount": "Total Amount",
Expand Down

0 comments on commit c1e8f28

Please sign in to comment.