@kenyaemr/esm-billing-app 5.4.2-pre.2805 → 5.4.2-pre.2806
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/.turbo/turbo-build.log +4 -4
- package/dist/84.js +1 -1
- package/dist/84.js.map +1 -1
- package/dist/kenyaemr-esm-billing-app.js.buildmanifest.json +6 -6
- package/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/routes.json +1 -1
- package/package.json +1 -1
- package/src/claims/dashboard/form/claims-form.component.tsx +20 -6
- package/src/hooks/useOTP.ts +363 -41
- package/src/types/index.ts +4 -0
package/dist/routes.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"kenyaemr":"^19.0.0"},"pages":[{"component":"billableServicesHome","route":"billable-services"},{"component":"requirePaymentModal","routeRegex":"^patient/.+/chart","online":true,"offline":false},{"component":"billingAdmin","route":"billing-admin"}],"extensions":[{"component":"accountingDashboardLink","name":"accounting-dashboard-link","slots":["homepage-dashboard-slot"],"meta":{"name":"accounting","slot":"accounting-dashboard-slot","title":"Accounting"}},{"name":"billing-dashboard","component":"billingDashboard","slot":"accounting-dashboard-slot"},{"component":"benefitsPackageDashboardLink","name":"benefits-package-dashboard-link","meta":{"name":"benefits-package","slot":"patient-chart-benefits-dashboard-slot","path":"insurance-benefits","columns":1,"columnSpan":1},"featureFlag":"healthInformationExchange"},{"component":"benefitsPackage","name":"benefits-package","slot":"patient-chart-benefits-dashboard-slot"},{"component":"root","name":"billing-dashboard-root","slot":"billing-dashboard-slot"},{"component":"benefitsEligibilyRequestForm","name":"benefits-eligibility-request-form"},{"component":"benefitsPreAuthForm","name":"benefits-pre-auth-form"},{"name":"billing-patient-summary","component":"billingPatientSummary","slot":"patient-chart-billing-dashboard-slot","order":10,"meta":{"columnSpan":4}},{"name":"billing-summary-dashboard-link","component":"billingSummaryDashboardLink","order":11,"meta":{"columns":1,"columnSpan":1,"slot":"patient-chart-billing-dashboard-slot","path":"Billing","layoutMode":"anchored"}},{"name":"billing-check-in-form","slot":"extra-visit-attribute-slot","component":"billingCheckInForm"},{"name":"patient-banner-billing-tags","component":"visitAttributeTags","slot":"patient-banner-tags-slot","order":2},{"name":"initiate-payment-modal","component":"initiatePaymentDialog"},{"name":"delete-billableservice-modal","component":"deleteBillableServiceModal"},{"name":"refund-bill-modal","component":"refundBillModal"},{"name":"delete-bill-modal","component":"deleteBillModal"},{"name":"lab-order-billable-item","component":"labOrder","slot":"top-of-lab-order-form-slot"},{"name":"procedure-order-billable-item","component":"procedureOrder","slot":"top-of-procedure-order-form-slot"},{"name":"imaging-order-billable-item","component":"imagingOrder","slot":"top-of-imaging-order-form-slot"},{"name":"price-info-order","component":"priceInfoOrder"},{"name":"drug-order-billable-item","component":"drugOrder","slot":"medication-info-slot"},{"name":"order-action-button","component":"orderActionButton","slots":["prescription-action-button-slot","imaging-orders-action","procedure-orders-action","tests-ordered-actions-slot"],"order":0},{"component":"claimsManagementOverviewDashboardLink","name":"claims-management-overview-link","slots":["claims-management-dashboard-link-slot"]},{"component":"preAuthRequestsDashboardLink","name":"preauthrequest-overview-link","slots":["claims-management-dashboard-link-slot"]},{"component":"claimsOverview","name":"claims-overview-dashboard-link","slot":"claims-management-overview-slot"},{"component":"waiveBillActionButton","name":"waive-bill-action-button","slot":"bill-actions-slot"},{"component":"deleteBillActionButton","name":"delete-bill-action-button","slot":"bill-actions-slot"},{"component":"refundLineItem","name":"refund-line-item","slot":"bill-actions-overflow-menu-slot"},{"name":"edit-line-item","component":"editLineItem","slot":"bill-actions-overflow-menu-slot"},{"name":"cancel-line-item","component":"cancelLineItem","slot":"bill-actions-overflow-menu-slot"},{"name":"patient-info-sha-status","component":"patientBannerShaStatus","slot":"patient-banner-tags-slot"},{"name":"payments-points-dashboard-link","component":"paymentPointDashboardLink","slot":"billing-admin-dashboard-slot","meta":{"name":"cashier-points","title":"Payment Point","slot":"payment-point-dashboard-slot"}},{"name":"payment-points-dashboard","component":"paymentPoints","slot":"payment-point-dashboard-slot"},{"name":"payment-mode-dashboard-link","component":"paymentModeLink","slot":"billing-admin-dashboard-slot","meta":{"name":"payment-modes","title":"Payment Mode","slot":"payment-mode-dashboard-slot"}},{"name":"payment-mode-dashboard","component":"paymentModeDashboard","slot":"payment-mode-dashboard-slot"},{"name":"bill-deposit-dashboard-link","component":"billDepositDashboardLink","slot":"billing-admin-dashboard-slot","meta":{"name":"bill-deposit","title":"Bill Deposit","slot":"billing-deposit-dashboard-slot"}},{"name":"bill-deposit-dashboard","component":"billDepositDashboard","slot":"billing-deposit-dashboard-slot"},{"name":"billable-exemptions-dashboard-link","component":"billableExemptionsLink","slot":"billing-admin-dashboard-slot","meta":{"name":"billable-exemptions","title":"Billable Exemptions","slot":"billing-exemptions-dashboard-slot"}},{"name":"billable-exemptions-dashboard","component":"billableExemptions","slot":"billing-exemptions-dashboard-slot"},{"name":"clinical-charges-dashboard-link","component":"clinicalChargesLink","slot":"billing-admin-dashboard-slot","meta":{"name":"clinical-charges","title":"Clinical Charges","slot":"clinical-charges-dashboard-slot"}},{"name":"clinical-charges-dashboard","component":"clinicalCharges","slot":"clinical-charges-dashboard-slot"},{"name":"payment-history-dashboard-link","component":"paymentHistoryLink","slot":"billing-admin-dashboard-slot","meta":{"name":"payment-history","title":"Payment History","slot":"payment-history-dashboard-slot"}},{"name":"payment-history-dashboard","component":"paymentHistoryDashboard","slot":"payment-history-dashboard-slot"},{"name":"patient-billing-dashboard-link","component":"patientBillingLink","slot":"billing-admin-dashboard-slot","meta":{"name":"patient-billing","title":"Patient Billing","slot":"patient-billing-dashboard-slot"}},{"name":"patient-billing-dashboard","component":"patientBilling","slot":"patient-billing-dashboard-slot"}],"workspaces":[{"name":"create-bill-workspace","component":"createBillWorkspace","title":"Create Bill Workspace","type":"other-form"},{"name":"waive-bill-form","component":"waiveBillForm","title":"Waive Bill Form","type":"other-form"},{"name":"edit-bill-form","component":"editBillForm","title":"Edit Bill Form","type":"other-form"},{"name":"billable-service-form","component":"addServiceForm","title":"Create Charge Item Form","type":"other-form"},{"name":"commodity-form","component":"commodityForm","title":"Charge Commodity Form","type":"other-form"},{"name":"billing-form","component":"billingForm","title":"Billing Form","type":"other-form","width":"extra-wide"},{"name":"payment-mode-workspace","component":"paymentModeWorkspace","title":"Payment Mode Workspace","type":"other-form"},{"name":"cancel-bill-workspace","component":"cancelBillWorkspace","title":"Cancel Bill Workspace","type":"other-form"},{"name":"add-deposit-workspace","component":"addDepositWorkspace","title":"Add Deposit","type":"other-form"},{"name":"deposit-transaction-workspace","component":"depositTransactionWorkspace","title":"Deposit Transaction","type":"other-form"},{"name":"payment-workspace","component":"paymentWorkspace","title":"Payment Workspace","type":"other-form"}],"modals":[{"name":"create-payment-point","component":"createPaymentPoint"},{"name":"clock-out-modal","component":"clockOut"},{"name":"bulk-import-billable-services-modal","component":"bulkImportBillableServicesModal"},{"name":"delete-payment-mode-modal","component":"deletePaymentModeModal"},{"name":"manage-claim-request-modal","component":"manageClaimRequestModal"},{"name":"clock-in-modal","component":"clockIn"},{"name":"create-bill-item-modal","component":"createBillItemModal"},{"name":"delete-deposit-modal","component":"deleteDepositModal"},{"name":"reverse-transaction-modal","component":"reverseTransactionModal"},{"name":"print-preview-modal","component":"printPreviewModal"},{"name":"bill-action-modal","component":"billActionModal"},{"name":"require-billing-modal","component":"requirePaymentModal"}],"version":"5.4.2-pre.
|
|
1
|
+
{"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"kenyaemr":"^19.0.0"},"pages":[{"component":"billableServicesHome","route":"billable-services"},{"component":"requirePaymentModal","routeRegex":"^patient/.+/chart","online":true,"offline":false},{"component":"billingAdmin","route":"billing-admin"}],"extensions":[{"component":"accountingDashboardLink","name":"accounting-dashboard-link","slots":["homepage-dashboard-slot"],"meta":{"name":"accounting","slot":"accounting-dashboard-slot","title":"Accounting"}},{"name":"billing-dashboard","component":"billingDashboard","slot":"accounting-dashboard-slot"},{"component":"benefitsPackageDashboardLink","name":"benefits-package-dashboard-link","meta":{"name":"benefits-package","slot":"patient-chart-benefits-dashboard-slot","path":"insurance-benefits","columns":1,"columnSpan":1},"featureFlag":"healthInformationExchange"},{"component":"benefitsPackage","name":"benefits-package","slot":"patient-chart-benefits-dashboard-slot"},{"component":"root","name":"billing-dashboard-root","slot":"billing-dashboard-slot"},{"component":"benefitsEligibilyRequestForm","name":"benefits-eligibility-request-form"},{"component":"benefitsPreAuthForm","name":"benefits-pre-auth-form"},{"name":"billing-patient-summary","component":"billingPatientSummary","slot":"patient-chart-billing-dashboard-slot","order":10,"meta":{"columnSpan":4}},{"name":"billing-summary-dashboard-link","component":"billingSummaryDashboardLink","order":11,"meta":{"columns":1,"columnSpan":1,"slot":"patient-chart-billing-dashboard-slot","path":"Billing","layoutMode":"anchored"}},{"name":"billing-check-in-form","slot":"extra-visit-attribute-slot","component":"billingCheckInForm"},{"name":"patient-banner-billing-tags","component":"visitAttributeTags","slot":"patient-banner-tags-slot","order":2},{"name":"initiate-payment-modal","component":"initiatePaymentDialog"},{"name":"delete-billableservice-modal","component":"deleteBillableServiceModal"},{"name":"refund-bill-modal","component":"refundBillModal"},{"name":"delete-bill-modal","component":"deleteBillModal"},{"name":"lab-order-billable-item","component":"labOrder","slot":"top-of-lab-order-form-slot"},{"name":"procedure-order-billable-item","component":"procedureOrder","slot":"top-of-procedure-order-form-slot"},{"name":"imaging-order-billable-item","component":"imagingOrder","slot":"top-of-imaging-order-form-slot"},{"name":"price-info-order","component":"priceInfoOrder"},{"name":"drug-order-billable-item","component":"drugOrder","slot":"medication-info-slot"},{"name":"order-action-button","component":"orderActionButton","slots":["prescription-action-button-slot","imaging-orders-action","procedure-orders-action","tests-ordered-actions-slot"],"order":0},{"component":"claimsManagementOverviewDashboardLink","name":"claims-management-overview-link","slots":["claims-management-dashboard-link-slot"]},{"component":"preAuthRequestsDashboardLink","name":"preauthrequest-overview-link","slots":["claims-management-dashboard-link-slot"]},{"component":"claimsOverview","name":"claims-overview-dashboard-link","slot":"claims-management-overview-slot"},{"component":"waiveBillActionButton","name":"waive-bill-action-button","slot":"bill-actions-slot"},{"component":"deleteBillActionButton","name":"delete-bill-action-button","slot":"bill-actions-slot"},{"component":"refundLineItem","name":"refund-line-item","slot":"bill-actions-overflow-menu-slot"},{"name":"edit-line-item","component":"editLineItem","slot":"bill-actions-overflow-menu-slot"},{"name":"cancel-line-item","component":"cancelLineItem","slot":"bill-actions-overflow-menu-slot"},{"name":"patient-info-sha-status","component":"patientBannerShaStatus","slot":"patient-banner-tags-slot"},{"name":"payments-points-dashboard-link","component":"paymentPointDashboardLink","slot":"billing-admin-dashboard-slot","meta":{"name":"cashier-points","title":"Payment Point","slot":"payment-point-dashboard-slot"}},{"name":"payment-points-dashboard","component":"paymentPoints","slot":"payment-point-dashboard-slot"},{"name":"payment-mode-dashboard-link","component":"paymentModeLink","slot":"billing-admin-dashboard-slot","meta":{"name":"payment-modes","title":"Payment Mode","slot":"payment-mode-dashboard-slot"}},{"name":"payment-mode-dashboard","component":"paymentModeDashboard","slot":"payment-mode-dashboard-slot"},{"name":"bill-deposit-dashboard-link","component":"billDepositDashboardLink","slot":"billing-admin-dashboard-slot","meta":{"name":"bill-deposit","title":"Bill Deposit","slot":"billing-deposit-dashboard-slot"}},{"name":"bill-deposit-dashboard","component":"billDepositDashboard","slot":"billing-deposit-dashboard-slot"},{"name":"billable-exemptions-dashboard-link","component":"billableExemptionsLink","slot":"billing-admin-dashboard-slot","meta":{"name":"billable-exemptions","title":"Billable Exemptions","slot":"billing-exemptions-dashboard-slot"}},{"name":"billable-exemptions-dashboard","component":"billableExemptions","slot":"billing-exemptions-dashboard-slot"},{"name":"clinical-charges-dashboard-link","component":"clinicalChargesLink","slot":"billing-admin-dashboard-slot","meta":{"name":"clinical-charges","title":"Clinical Charges","slot":"clinical-charges-dashboard-slot"}},{"name":"clinical-charges-dashboard","component":"clinicalCharges","slot":"clinical-charges-dashboard-slot"},{"name":"payment-history-dashboard-link","component":"paymentHistoryLink","slot":"billing-admin-dashboard-slot","meta":{"name":"payment-history","title":"Payment History","slot":"payment-history-dashboard-slot"}},{"name":"payment-history-dashboard","component":"paymentHistoryDashboard","slot":"payment-history-dashboard-slot"},{"name":"patient-billing-dashboard-link","component":"patientBillingLink","slot":"billing-admin-dashboard-slot","meta":{"name":"patient-billing","title":"Patient Billing","slot":"patient-billing-dashboard-slot"}},{"name":"patient-billing-dashboard","component":"patientBilling","slot":"patient-billing-dashboard-slot"}],"workspaces":[{"name":"create-bill-workspace","component":"createBillWorkspace","title":"Create Bill Workspace","type":"other-form"},{"name":"waive-bill-form","component":"waiveBillForm","title":"Waive Bill Form","type":"other-form"},{"name":"edit-bill-form","component":"editBillForm","title":"Edit Bill Form","type":"other-form"},{"name":"billable-service-form","component":"addServiceForm","title":"Create Charge Item Form","type":"other-form"},{"name":"commodity-form","component":"commodityForm","title":"Charge Commodity Form","type":"other-form"},{"name":"billing-form","component":"billingForm","title":"Billing Form","type":"other-form","width":"extra-wide"},{"name":"payment-mode-workspace","component":"paymentModeWorkspace","title":"Payment Mode Workspace","type":"other-form"},{"name":"cancel-bill-workspace","component":"cancelBillWorkspace","title":"Cancel Bill Workspace","type":"other-form"},{"name":"add-deposit-workspace","component":"addDepositWorkspace","title":"Add Deposit","type":"other-form"},{"name":"deposit-transaction-workspace","component":"depositTransactionWorkspace","title":"Deposit Transaction","type":"other-form"},{"name":"payment-workspace","component":"paymentWorkspace","title":"Payment Workspace","type":"other-form"}],"modals":[{"name":"create-payment-point","component":"createPaymentPoint"},{"name":"clock-out-modal","component":"clockOut"},{"name":"bulk-import-billable-services-modal","component":"bulkImportBillableServicesModal"},{"name":"delete-payment-mode-modal","component":"deletePaymentModeModal"},{"name":"manage-claim-request-modal","component":"manageClaimRequestModal"},{"name":"clock-in-modal","component":"clockIn"},{"name":"create-bill-item-modal","component":"createBillItemModal"},{"name":"delete-deposit-modal","component":"deleteDepositModal"},{"name":"reverse-transaction-modal","component":"reverseTransactionModal"},{"name":"print-preview-modal","component":"printPreviewModal"},{"name":"bill-action-modal","component":"billActionModal"},{"name":"require-billing-modal","component":"requirePaymentModal"}],"version":"5.4.2-pre.2806"}
|
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ import { Controller, FormProvider, useForm } from 'react-hook-form';
|
|
|
20
20
|
import { useTranslation } from 'react-i18next';
|
|
21
21
|
import { useParams } from 'react-router-dom';
|
|
22
22
|
import { z } from 'zod';
|
|
23
|
+
import debounce from 'lodash-es/debounce';
|
|
23
24
|
import SHABenefitPackangesAndInterventions from '../../../benefits-package/forms/packages-and-interventions-form.component';
|
|
24
25
|
import { BillingConfig } from '../../../config-schema';
|
|
25
26
|
import { useSystemSetting } from '../../../hooks/getMflCode';
|
|
@@ -29,9 +30,7 @@ import useProviderList from '../../../hooks/useProviderList';
|
|
|
29
30
|
import { ClaimSummary, LineItem, MappedBill, OTPVerificationModalOptions } from '../../../types';
|
|
30
31
|
import ClaimExplanationAndJusificationInput from './claims-explanation-and-justification-form-input.component';
|
|
31
32
|
import { ClaimsFormSchema, ClaimsFormSchemaBase, processClaims, useVisit } from './claims-form.resource';
|
|
32
|
-
|
|
33
|
-
import debounce from 'lodash-es/debounce';
|
|
34
|
-
import { otpManager } from '../../../hooks/useOTP';
|
|
33
|
+
import { otpManager, useOtpSource } from '../../../hooks/useOTP';
|
|
35
34
|
import { usePhoneNumberAttribute } from '../../../hooks/usePhoneNumber';
|
|
36
35
|
import { formatDateTime } from '../../utils';
|
|
37
36
|
import styles from './claims-form.scss';
|
|
@@ -63,6 +62,7 @@ const ClaimsForm: React.FC<ClaimsFormProps> = ({ bill, selectedLineItems }) => {
|
|
|
63
62
|
const { providers, providersLoading } = useProviderList();
|
|
64
63
|
const { phoneNumber } = usePhoneNumberAttribute(patientUuid);
|
|
65
64
|
const { nationalId, isLoading: isLoadingNationalId, error: errorNationalId } = usePatientIdentifier(patientUuid);
|
|
65
|
+
const { otpSource, isLoading: isLoadingOtpSource } = useOtpSource();
|
|
66
66
|
|
|
67
67
|
const [otpState, setOtpState] = useState<OTPState>(OTPState.NOT_STARTED);
|
|
68
68
|
const [pendingClaimData, setPendingClaimData] = useState<z.infer<typeof ClaimsFormSchema> | null>(null);
|
|
@@ -77,6 +77,12 @@ const ClaimsForm: React.FC<ClaimsFormProps> = ({ bill, selectedLineItems }) => {
|
|
|
77
77
|
const patientName = `${bill.patientName}`;
|
|
78
78
|
const otpExpiryMinutes = 5;
|
|
79
79
|
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (otpSource) {
|
|
82
|
+
otpManager.setOtpSource(otpSource);
|
|
83
|
+
}
|
|
84
|
+
}, [otpSource]);
|
|
85
|
+
|
|
80
86
|
const form = useForm<z.infer<typeof ClaimsFormSchema>>({
|
|
81
87
|
mode: 'onTouched',
|
|
82
88
|
resolver: zodResolver(validationEnabled ? ClaimsFormSchema : ClaimsFormSchemaBase),
|
|
@@ -207,7 +213,14 @@ const ClaimsForm: React.FC<ClaimsFormProps> = ({ bill, selectedLineItems }) => {
|
|
|
207
213
|
throw new Error('No claim data available for OTP request');
|
|
208
214
|
}
|
|
209
215
|
|
|
210
|
-
await otpManager.requestOTP(
|
|
216
|
+
await otpManager.requestOTP(
|
|
217
|
+
phoneNumber,
|
|
218
|
+
patientName,
|
|
219
|
+
currentSummary,
|
|
220
|
+
otpExpiryMinutes,
|
|
221
|
+
nationalId || null,
|
|
222
|
+
t,
|
|
223
|
+
);
|
|
211
224
|
},
|
|
212
225
|
onVerify: async (otp: string): Promise<void> => {
|
|
213
226
|
const phoneForVerification = currentPhoneRef.current;
|
|
@@ -444,6 +457,7 @@ const ClaimsForm: React.FC<ClaimsFormProps> = ({ bill, selectedLineItems }) => {
|
|
|
444
457
|
|
|
445
458
|
const isFormValid = isValid && packages?.length > 0 && interventions?.length > 0 && selectedLineItems?.length > 0;
|
|
446
459
|
const displayPhoneNumber = currentOtpPhoneNumber || phoneNumber;
|
|
460
|
+
const isOtpDisabled = !isFormValid || !displayPhoneNumber || isLoadingOtpSource;
|
|
447
461
|
|
|
448
462
|
return (
|
|
449
463
|
<FormProvider {...form}>
|
|
@@ -667,10 +681,10 @@ const ClaimsForm: React.FC<ClaimsFormProps> = ({ bill, selectedLineItems }) => {
|
|
|
667
681
|
className={styles.button}
|
|
668
682
|
kind="primary"
|
|
669
683
|
onClick={handleSubmit(handleInitiateOTPVerification)}
|
|
670
|
-
disabled={
|
|
684
|
+
disabled={isOtpDisabled}
|
|
671
685
|
tooltipPosition="top"
|
|
672
686
|
tooltipAlignment="center">
|
|
673
|
-
{t('sendOtp', 'Send OTP')}
|
|
687
|
+
{isLoadingOtpSource ? t('loading', 'Loading...') : t('sendOtp', 'Send OTP')}
|
|
674
688
|
</Button>
|
|
675
689
|
)}
|
|
676
690
|
|
package/src/hooks/useOTP.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
-
import
|
|
1
|
+
import { FetchResponse, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
import useSWR from 'swr';
|
|
3
|
+
import { ClaimSummary, OTPSource } from '../types';
|
|
3
4
|
import { formatKenyanPhoneNumber } from '../invoice/payments/utils';
|
|
5
|
+
import { TFunction } from 'i18next';
|
|
6
|
+
import { getCurrencyForLocale } from '../helpers/currency';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generates a random OTP of a specified length.
|
|
10
|
+
*/
|
|
11
|
+
export function generateOTP(length = 5): string {
|
|
12
|
+
let otpNumbers = '0123456789';
|
|
13
|
+
let OTP = '';
|
|
14
|
+
const len = otpNumbers.length;
|
|
15
|
+
for (let i = 0; i < length; i++) {
|
|
16
|
+
OTP += otpNumbers[Math.floor(Math.random() * len)];
|
|
17
|
+
}
|
|
18
|
+
return OTP;
|
|
19
|
+
}
|
|
4
20
|
|
|
5
21
|
/**
|
|
6
22
|
* Replaces placeholders in a template string with values from a given context.
|
|
@@ -42,62 +58,218 @@ function validateOtpInputs(receiver: string, patientName: string): void {
|
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
/**
|
|
45
|
-
*
|
|
61
|
+
* Hook to get OTP source configuration
|
|
62
|
+
*/
|
|
63
|
+
export const useOtpSource = () => {
|
|
64
|
+
const url = `${restBaseUrl}/kenyaemr/checkotpsource`;
|
|
65
|
+
|
|
66
|
+
const { data, error, isLoading } = useSWR<FetchResponse<OTPSource>>(url, openmrsFetch);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
otpSource: data?.data?.otpSource,
|
|
70
|
+
data,
|
|
71
|
+
error,
|
|
72
|
+
isLoading,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sends OTP via SMS for KEHMIS workflow (client generates OTP)
|
|
46
78
|
*/
|
|
47
|
-
|
|
79
|
+
async function sendOtpKehmis(
|
|
80
|
+
otp: string,
|
|
81
|
+
receiver: string,
|
|
82
|
+
patientName: string,
|
|
83
|
+
claimSummary: ClaimSummary,
|
|
84
|
+
expiryMinutes: number = 5,
|
|
85
|
+
nationalId: string | null = null,
|
|
86
|
+
t: TFunction,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
validateOtpInputs(receiver, patientName);
|
|
89
|
+
|
|
90
|
+
// Get currency based on current locale
|
|
91
|
+
const currency = getCurrencyForLocale();
|
|
92
|
+
|
|
93
|
+
const context = {
|
|
94
|
+
patientName: patientName,
|
|
95
|
+
claimAmount: `${currency} ${claimSummary.totalAmount.toLocaleString()}`,
|
|
96
|
+
servicesSummary:
|
|
97
|
+
claimSummary.services.length > 100 ? claimSummary.services.substring(0, 97) + '...' : claimSummary.services,
|
|
98
|
+
startDate: claimSummary.startDate,
|
|
99
|
+
endDate: claimSummary.endDate,
|
|
100
|
+
facility: claimSummary.facility,
|
|
101
|
+
expiryTime: expiryMinutes,
|
|
102
|
+
otp: otp,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const claimConsentTemplate =
|
|
106
|
+
'Dear {{patientName}}, ' +
|
|
107
|
+
'We are submitting a claim to your insurance for services provided from {{startDate}} to {{endDate}} at {{facility}}. ' +
|
|
108
|
+
'Total claim amount: {{claimAmount}}. ' +
|
|
109
|
+
'Services: {{servicesSummary}}. ' +
|
|
110
|
+
'Your OTP for consent is {{otp}} (valid {{expiryTime}} mins). ';
|
|
111
|
+
|
|
48
112
|
try {
|
|
49
|
-
const
|
|
113
|
+
const message = parseMessage(context, claimConsentTemplate);
|
|
114
|
+
const url = buildSmsUrl(message, receiver, nationalId);
|
|
50
115
|
|
|
51
116
|
const response = await openmrsFetch(url, {
|
|
52
117
|
method: 'POST',
|
|
53
|
-
|
|
54
|
-
'Content-Type': 'application/json',
|
|
55
|
-
},
|
|
56
|
-
body: JSON.stringify({
|
|
57
|
-
id: otpId,
|
|
58
|
-
otp: otp.trim(),
|
|
59
|
-
}),
|
|
118
|
+
redirect: 'follow',
|
|
60
119
|
});
|
|
61
120
|
|
|
62
121
|
if (!response.ok) {
|
|
63
122
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
64
123
|
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const errorMessage =
|
|
126
|
+
error instanceof Error
|
|
127
|
+
? error.message
|
|
128
|
+
: t?.('unknownErrorOccurred', 'Unknown error occurred') || 'Unknown error occurred';
|
|
129
|
+
throw new Error(`Failed to send OTP: ${errorMessage}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
65
132
|
|
|
66
|
-
|
|
133
|
+
class KehmisOTPManager {
|
|
134
|
+
private otpStore: Map<
|
|
135
|
+
string,
|
|
136
|
+
{ otp: string; timestamp: number; attempts: number; expiryTime: number; claimSummary: ClaimSummary }
|
|
137
|
+
> = new Map();
|
|
138
|
+
private readonly MAX_ATTEMPTS = 3;
|
|
67
139
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
140
|
+
async requestOTP(
|
|
141
|
+
phoneNumber: string,
|
|
142
|
+
patientName: string,
|
|
143
|
+
claimSummary: ClaimSummary,
|
|
144
|
+
expiryMinutes: number = 5,
|
|
145
|
+
nationalId: string | null = null,
|
|
146
|
+
t: TFunction,
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
const otp = generateOTP(5);
|
|
149
|
+
const expiryTime = expiryMinutes * 60 * 1000;
|
|
150
|
+
const formattedPhone = formatKenyanPhoneNumber(phoneNumber);
|
|
151
|
+
|
|
152
|
+
const otpData = {
|
|
153
|
+
otp,
|
|
154
|
+
timestamp: Date.now(),
|
|
155
|
+
attempts: 0,
|
|
156
|
+
expiryTime,
|
|
157
|
+
claimSummary,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
this.otpStore.set(formattedPhone, otpData);
|
|
161
|
+
|
|
162
|
+
await sendOtpKehmis(otp, formattedPhone, patientName, claimSummary, expiryMinutes, nationalId, t);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async verifyOTP(phoneNumber: string, inputOtp: string): Promise<boolean> {
|
|
166
|
+
const formattedPhone = formatKenyanPhoneNumber(phoneNumber);
|
|
167
|
+
const storedData = this.otpStore.get(formattedPhone);
|
|
168
|
+
|
|
169
|
+
if (!storedData) {
|
|
170
|
+
throw new Error('No OTP found for this phone number. Please request a new OTP.');
|
|
73
171
|
}
|
|
74
172
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
data = JSON.parse(parsedResponse.response);
|
|
79
|
-
} catch (e) {
|
|
80
|
-
throw new Error('Invalid nested response from server');
|
|
81
|
-
}
|
|
173
|
+
if (Date.now() - storedData.timestamp > storedData.expiryTime) {
|
|
174
|
+
this.otpStore.delete(formattedPhone);
|
|
175
|
+
throw new Error('OTP has expired. Please request a new OTP.');
|
|
82
176
|
}
|
|
83
177
|
|
|
84
|
-
|
|
178
|
+
storedData.attempts++;
|
|
179
|
+
|
|
180
|
+
if (storedData.attempts > this.MAX_ATTEMPTS) {
|
|
181
|
+
this.otpStore.delete(formattedPhone);
|
|
182
|
+
throw new Error('Maximum OTP attempts exceeded. Please request a new OTP.');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (storedData.otp === inputOtp.trim()) {
|
|
186
|
+
this.otpStore.delete(formattedPhone);
|
|
85
187
|
return true;
|
|
86
188
|
} else {
|
|
87
|
-
|
|
88
|
-
throw new Error(
|
|
189
|
+
this.otpStore.set(formattedPhone, storedData);
|
|
190
|
+
throw new Error(`Invalid OTP. ${this.MAX_ATTEMPTS - storedData.attempts} attempts remaining.`);
|
|
89
191
|
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
clearOTP(phoneNumber: string): void {
|
|
195
|
+
const formattedPhone = formatKenyanPhoneNumber(phoneNumber);
|
|
196
|
+
this.otpStore.delete(formattedPhone);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
clearAllOTPs(): void {
|
|
200
|
+
this.otpStore.clear();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
cleanupExpiredOTPs(): void {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
for (const [phoneNumber, data] of this.otpStore.entries()) {
|
|
206
|
+
if (now - data.timestamp > data.expiryTime) {
|
|
207
|
+
this.otpStore.delete(phoneNumber);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
hasValidOTP(phoneNumber: string): boolean {
|
|
213
|
+
const formattedPhone = formatKenyanPhoneNumber(phoneNumber);
|
|
214
|
+
const storedData = this.otpStore.get(formattedPhone);
|
|
215
|
+
if (!storedData) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
return Date.now() - storedData.timestamp <= storedData.expiryTime;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
getRemainingTimeMinutes(phoneNumber: string): number {
|
|
222
|
+
const formattedPhone = formatKenyanPhoneNumber(phoneNumber);
|
|
223
|
+
const storedData = this.otpStore.get(formattedPhone);
|
|
224
|
+
if (!storedData) {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
const elapsed = Date.now() - storedData.timestamp;
|
|
228
|
+
const remaining = Math.max(0, storedData.expiryTime - elapsed);
|
|
229
|
+
return Math.ceil(remaining / (60 * 1000));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getRemainingAttempts(phoneNumber: string): number {
|
|
233
|
+
const formattedPhone = formatKenyanPhoneNumber(phoneNumber);
|
|
234
|
+
const storedData = this.otpStore.get(formattedPhone);
|
|
235
|
+
if (!storedData) {
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
return Math.max(0, this.MAX_ATTEMPTS - storedData.attempts);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
transferOTP(oldPhoneNumber: string, newPhoneNumber: string): boolean {
|
|
242
|
+
const oldFormatted = formatKenyanPhoneNumber(oldPhoneNumber);
|
|
243
|
+
const newFormatted = formatKenyanPhoneNumber(newPhoneNumber);
|
|
244
|
+
|
|
245
|
+
if (oldFormatted === newFormatted) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const sessionData = this.otpStore.get(oldFormatted);
|
|
250
|
+
if (!sessionData) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.otpStore.set(newFormatted, sessionData);
|
|
255
|
+
this.otpStore.delete(oldFormatted);
|
|
256
|
+
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
formatPhone(phoneNumber: string): string {
|
|
261
|
+
return formatKenyanPhoneNumber(phoneNumber);
|
|
93
262
|
}
|
|
94
263
|
}
|
|
95
264
|
|
|
265
|
+
// ============================================================================
|
|
266
|
+
// HIE STRATEGY (Server-side OTP generation and validation)
|
|
267
|
+
// ============================================================================
|
|
268
|
+
|
|
96
269
|
/**
|
|
97
|
-
*
|
|
98
|
-
* Returns the OTP session ID needed for verification.
|
|
270
|
+
* Requests OTP from server (server generates and sends OTP)
|
|
99
271
|
*/
|
|
100
|
-
|
|
272
|
+
async function requestOtpFromServer(
|
|
101
273
|
receiver: string,
|
|
102
274
|
patientName: string,
|
|
103
275
|
claimSummary: ClaimSummary,
|
|
@@ -108,9 +280,12 @@ export async function requestOtpFromServer(
|
|
|
108
280
|
|
|
109
281
|
const formattedPhone = formatKenyanPhoneNumber(receiver);
|
|
110
282
|
|
|
283
|
+
// Get currency based on current locale
|
|
284
|
+
const currency = getCurrencyForLocale();
|
|
285
|
+
|
|
111
286
|
const context = {
|
|
112
287
|
patientName: patientName,
|
|
113
|
-
claimAmount:
|
|
288
|
+
claimAmount: `${currency} ${claimSummary.totalAmount.toLocaleString()}`,
|
|
114
289
|
servicesSummary:
|
|
115
290
|
claimSummary.services.length > 100 ? claimSummary.services.substring(0, 97) + '...' : claimSummary.services,
|
|
116
291
|
startDate: claimSummary.startDate,
|
|
@@ -177,12 +352,63 @@ export async function requestOtpFromServer(
|
|
|
177
352
|
}
|
|
178
353
|
throw new Error(`Failed to send OTP: ${error.message}`);
|
|
179
354
|
}
|
|
180
|
-
|
|
181
355
|
throw new Error('Failed to send OTP: Unknown error occurred');
|
|
182
356
|
}
|
|
183
357
|
}
|
|
184
358
|
|
|
185
|
-
|
|
359
|
+
/**
|
|
360
|
+
* Verifies OTP with server
|
|
361
|
+
*/
|
|
362
|
+
async function verifyOtpWithServer(otpId: string, otp: string): Promise<boolean> {
|
|
363
|
+
try {
|
|
364
|
+
const url = `${restBaseUrl}/kenyaemr/validate-otp`;
|
|
365
|
+
|
|
366
|
+
const response = await openmrsFetch(url, {
|
|
367
|
+
method: 'POST',
|
|
368
|
+
headers: {
|
|
369
|
+
'Content-Type': 'application/json',
|
|
370
|
+
},
|
|
371
|
+
body: JSON.stringify({
|
|
372
|
+
id: otpId,
|
|
373
|
+
otp: otp.trim(),
|
|
374
|
+
}),
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
if (!response.ok) {
|
|
378
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const rawText = await response.text();
|
|
382
|
+
|
|
383
|
+
let parsedResponse;
|
|
384
|
+
try {
|
|
385
|
+
parsedResponse = JSON.parse(rawText);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
throw new Error('Invalid response from server');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let data = parsedResponse;
|
|
391
|
+
if (parsedResponse.response && typeof parsedResponse.response === 'string') {
|
|
392
|
+
try {
|
|
393
|
+
data = JSON.parse(parsedResponse.response);
|
|
394
|
+
} catch (e) {
|
|
395
|
+
throw new Error('Invalid nested response from server');
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (data.status === 'success' || data.valid === true) {
|
|
400
|
+
return true;
|
|
401
|
+
} else {
|
|
402
|
+
const errorMessage = data.message || 'Invalid OTP';
|
|
403
|
+
throw new Error(errorMessage);
|
|
404
|
+
}
|
|
405
|
+
} catch (error) {
|
|
406
|
+
const errorMessage = error instanceof Error ? error.message : 'OTP verification failed';
|
|
407
|
+
throw new Error(errorMessage);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
class HieOTPManager {
|
|
186
412
|
private otpSessions: Map<
|
|
187
413
|
string,
|
|
188
414
|
{
|
|
@@ -192,6 +418,7 @@ export class OTPManager {
|
|
|
192
418
|
expiryTime: number;
|
|
193
419
|
nationalId?: string | null;
|
|
194
420
|
phoneNumber: string;
|
|
421
|
+
claimSummary: ClaimSummary;
|
|
195
422
|
}
|
|
196
423
|
> = new Map();
|
|
197
424
|
private readonly MAX_ATTEMPTS = 3;
|
|
@@ -222,6 +449,7 @@ export class OTPManager {
|
|
|
222
449
|
expiryTime,
|
|
223
450
|
nationalId,
|
|
224
451
|
phoneNumber: formattedPhone,
|
|
452
|
+
claimSummary,
|
|
225
453
|
};
|
|
226
454
|
|
|
227
455
|
this.otpSessions.set(formattedPhone, sessionData);
|
|
@@ -299,7 +527,6 @@ export class OTPManager {
|
|
|
299
527
|
if (!sessionData) {
|
|
300
528
|
return false;
|
|
301
529
|
}
|
|
302
|
-
|
|
303
530
|
return Date.now() - sessionData.timestamp <= sessionData.expiryTime;
|
|
304
531
|
}
|
|
305
532
|
|
|
@@ -309,7 +536,6 @@ export class OTPManager {
|
|
|
309
536
|
if (!sessionData) {
|
|
310
537
|
return 0;
|
|
311
538
|
}
|
|
312
|
-
|
|
313
539
|
const elapsed = Date.now() - sessionData.timestamp;
|
|
314
540
|
const remaining = Math.max(0, sessionData.expiryTime - elapsed);
|
|
315
541
|
return Math.ceil(remaining / (60 * 1000));
|
|
@@ -321,7 +547,6 @@ export class OTPManager {
|
|
|
321
547
|
if (!sessionData) {
|
|
322
548
|
return 0;
|
|
323
549
|
}
|
|
324
|
-
|
|
325
550
|
return Math.max(0, this.MAX_ATTEMPTS - sessionData.attempts);
|
|
326
551
|
}
|
|
327
552
|
|
|
@@ -343,13 +568,110 @@ export class OTPManager {
|
|
|
343
568
|
|
|
344
569
|
return true;
|
|
345
570
|
}
|
|
571
|
+
|
|
572
|
+
formatPhone(phoneNumber: string): string {
|
|
573
|
+
return formatKenyanPhoneNumber(phoneNumber);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ============================================================================
|
|
578
|
+
// ADAPTER - Unified OTP Manager Interface
|
|
579
|
+
// ============================================================================
|
|
580
|
+
|
|
581
|
+
interface IOTPManager {
|
|
582
|
+
requestOTP(
|
|
583
|
+
phoneNumber: string,
|
|
584
|
+
patientName: string,
|
|
585
|
+
claimSummary: ClaimSummary,
|
|
586
|
+
expiryMinutes?: number,
|
|
587
|
+
nationalId?: string | null,
|
|
588
|
+
t?: TFunction,
|
|
589
|
+
): Promise<void>;
|
|
590
|
+
verifyOTP(phoneNumber: string, inputOtp: string): Promise<boolean>;
|
|
591
|
+
clearOTP(phoneNumber: string): void;
|
|
592
|
+
clearAllOTPs(): void;
|
|
593
|
+
cleanupExpiredOTPs(): void;
|
|
594
|
+
hasValidOTP(phoneNumber: string): boolean;
|
|
595
|
+
getRemainingTimeMinutes(phoneNumber: string): number;
|
|
596
|
+
getRemainingAttempts(phoneNumber: string): number;
|
|
597
|
+
transferOTP(oldPhoneNumber: string, newPhoneNumber: string): boolean;
|
|
598
|
+
formatPhone(phoneNumber: string): string;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
class OTPManagerAdapter implements IOTPManager {
|
|
602
|
+
private kehmisManager: KehmisOTPManager;
|
|
603
|
+
private hieManager: HieOTPManager;
|
|
604
|
+
private currentSource: string;
|
|
605
|
+
|
|
606
|
+
constructor(otpSource: string = 'kehmis') {
|
|
607
|
+
this.kehmisManager = new KehmisOTPManager();
|
|
608
|
+
this.hieManager = new HieOTPManager();
|
|
609
|
+
this.currentSource = otpSource;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
setOtpSource(source: string) {
|
|
613
|
+
this.currentSource = source;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private getManager(): IOTPManager {
|
|
617
|
+
return this.currentSource === 'hie' ? this.hieManager : this.kehmisManager;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async requestOTP(
|
|
621
|
+
phoneNumber: string,
|
|
622
|
+
patientName: string,
|
|
623
|
+
claimSummary: ClaimSummary,
|
|
624
|
+
expiryMinutes: number = 5,
|
|
625
|
+
nationalId: string | null = null,
|
|
626
|
+
t: TFunction,
|
|
627
|
+
): Promise<void> {
|
|
628
|
+
return this.getManager().requestOTP(phoneNumber, patientName, claimSummary, expiryMinutes, nationalId, t);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async verifyOTP(phoneNumber: string, inputOtp: string): Promise<boolean> {
|
|
632
|
+
return this.getManager().verifyOTP(phoneNumber, inputOtp);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
clearOTP(phoneNumber: string): void {
|
|
636
|
+
this.kehmisManager.clearOTP(phoneNumber);
|
|
637
|
+
this.hieManager.clearOTP(phoneNumber);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
clearAllOTPs(): void {
|
|
641
|
+
this.kehmisManager.clearAllOTPs();
|
|
642
|
+
this.hieManager.clearAllOTPs();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
cleanupExpiredOTPs(): void {
|
|
646
|
+
this.kehmisManager.cleanupExpiredOTPs();
|
|
647
|
+
this.hieManager.cleanupExpiredOTPs();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
hasValidOTP(phoneNumber: string): boolean {
|
|
651
|
+
return this.getManager().hasValidOTP(phoneNumber);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
getRemainingTimeMinutes(phoneNumber: string): number {
|
|
655
|
+
return this.getManager().getRemainingTimeMinutes(phoneNumber);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
getRemainingAttempts(phoneNumber: string): number {
|
|
659
|
+
return this.getManager().getRemainingAttempts(phoneNumber);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
transferOTP(oldPhoneNumber: string, newPhoneNumber: string): boolean {
|
|
663
|
+
return this.getManager().transferOTP(oldPhoneNumber, newPhoneNumber);
|
|
664
|
+
}
|
|
665
|
+
|
|
346
666
|
formatPhone(phoneNumber: string): string {
|
|
347
667
|
return formatKenyanPhoneNumber(phoneNumber);
|
|
348
668
|
}
|
|
349
669
|
}
|
|
350
670
|
|
|
351
|
-
|
|
671
|
+
// Export singleton instance
|
|
672
|
+
export const otpManager = new OTPManagerAdapter();
|
|
352
673
|
|
|
674
|
+
// Cleanup interval
|
|
353
675
|
setInterval(() => {
|
|
354
676
|
otpManager.cleanupExpiredOTPs();
|
|
355
677
|
}, 2 * 60 * 1000);
|