@kenyaemr/esm-billing-app 5.4.2-pre.2548 → 5.4.2-pre.2553
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/{113.js → 476.js} +1 -1
- package/dist/476.js.map +1 -0
- package/dist/kenyaemr-esm-billing-app.js +3 -3
- package/dist/kenyaemr-esm-billing-app.js.buildmanifest.json +27 -27
- package/dist/main.js +2 -2
- 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 +281 -63
- package/src/config-schema.ts +6 -0
- package/src/hooks/useOTP.ts +292 -0
- package/src/hooks/usePhoneNumber.ts +35 -0
- package/src/types/index.ts +35 -0
- package/dist/113.js.map +0 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
|
|
2
|
+
import { OtpPayload, OtpResponse, ClaimSummary } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generates a random OTP of a specified length.
|
|
6
|
+
*/
|
|
7
|
+
export function generateOTP(length = 5) {
|
|
8
|
+
let otpNumbers = '0123456789';
|
|
9
|
+
let OTP = '';
|
|
10
|
+
const len = otpNumbers.length;
|
|
11
|
+
for (let i = 0; i < length; i++) {
|
|
12
|
+
OTP += otpNumbers[Math.floor(Math.random() * len)];
|
|
13
|
+
}
|
|
14
|
+
return OTP;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Replaces placeholders in a template string with values from a given context.
|
|
19
|
+
*/
|
|
20
|
+
export function parseMessage<T extends Record<string, string | number>>(context: T, template: string): string {
|
|
21
|
+
if (!template?.trim()) {
|
|
22
|
+
throw new Error('Template must be a non-empty string');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const placeholderRegex = /\{\{([^{}]+)\}\}/g;
|
|
26
|
+
|
|
27
|
+
return template.replace(placeholderRegex, (match, key: string) => {
|
|
28
|
+
const trimmedKey = key.trim();
|
|
29
|
+
return trimmedKey in context ? String(context[trimmedKey]) : match;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Builds a URL for sending an SMS message.
|
|
35
|
+
*/
|
|
36
|
+
function buildSmsUrl(message: string, receiver: string): string {
|
|
37
|
+
const encodedMessage = encodeURIComponent(message);
|
|
38
|
+
return `${restBaseUrl}/kenyaemr/send-kenyaemr-sms?message=${encodedMessage}&phone=${receiver}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validates that the required parameters for sending an OTP message are present.
|
|
43
|
+
*/
|
|
44
|
+
function validateOtpInputs(otp: string, receiver: string, patientName: string): void {
|
|
45
|
+
if (!otp?.trim() || !receiver?.trim() || !patientName?.trim()) {
|
|
46
|
+
throw new Error('Missing required parameters: otp, receiver, or patientName');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sends an OTP message to a patient's phone number with claim details.
|
|
52
|
+
*/
|
|
53
|
+
export async function sendOtp(
|
|
54
|
+
payload: OtpPayload,
|
|
55
|
+
patientName: string,
|
|
56
|
+
claimSummary: ClaimSummary,
|
|
57
|
+
expiryMinutes: number = 5,
|
|
58
|
+
): Promise<OtpResponse> {
|
|
59
|
+
const { otp, receiver } = payload;
|
|
60
|
+
validateOtpInputs(otp, receiver, patientName);
|
|
61
|
+
|
|
62
|
+
const otpContext = {
|
|
63
|
+
patientName: patientName,
|
|
64
|
+
claimAmount: `KES ${claimSummary.totalAmount.toLocaleString()}`,
|
|
65
|
+
servicesSummary:
|
|
66
|
+
claimSummary.services.length > 100 ? claimSummary.services.substring(0, 97) + '...' : claimSummary.services,
|
|
67
|
+
startDate: claimSummary.startDate,
|
|
68
|
+
endDate: claimSummary.endDate,
|
|
69
|
+
facility: claimSummary.facility,
|
|
70
|
+
expiryTime: expiryMinutes,
|
|
71
|
+
otp: otp,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const claimConsentTemplate =
|
|
75
|
+
'Dear {{patientName}}, ' +
|
|
76
|
+
'We are submitting a claim to your insurance for services provided from {{startDate}} to {{endDate}} at {{facility}}. ' +
|
|
77
|
+
'Total claim amount: {{claimAmount}}. ' +
|
|
78
|
+
'Services: {{servicesSummary}}. ' +
|
|
79
|
+
'Your OTP for consent is {{otp}} (valid {{expiryTime}} mins). ';
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const message = parseMessage(otpContext, claimConsentTemplate);
|
|
83
|
+
const url = buildSmsUrl(message, receiver);
|
|
84
|
+
|
|
85
|
+
const response = await openmrsFetch(url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
redirect: 'follow',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return response.json();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
97
|
+
throw new Error(`Failed to send OTP: ${errorMessage}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class OTPManager {
|
|
102
|
+
private otpStore: Map<string, { otp: string; timestamp: number; attempts: number; expiryTime: number }> = new Map();
|
|
103
|
+
private readonly MAX_ATTEMPTS = 3;
|
|
104
|
+
private readonly DEFAULT_EXPIRY_TIME = 5 * 60 * 1000;
|
|
105
|
+
|
|
106
|
+
async requestOTP(
|
|
107
|
+
phoneNumber: string,
|
|
108
|
+
patientName: string,
|
|
109
|
+
claimSummary: ClaimSummary,
|
|
110
|
+
expiryMinutes: number = this.DEFAULT_EXPIRY_TIME,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const otp = generateOTP(5);
|
|
113
|
+
const expiryTime = expiryMinutes * 60 * 1000;
|
|
114
|
+
|
|
115
|
+
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
|
116
|
+
|
|
117
|
+
const otpData = {
|
|
118
|
+
otp,
|
|
119
|
+
timestamp: Date.now(),
|
|
120
|
+
attempts: 0,
|
|
121
|
+
expiryTime,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
this.otpStore.set(normalizedPhone, otpData);
|
|
125
|
+
|
|
126
|
+
await sendOtp({ otp, receiver: phoneNumber }, patientName, claimSummary, expiryMinutes);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async verifyOTP(phoneNumber: string, inputOtp: string): Promise<boolean> {
|
|
130
|
+
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
|
131
|
+
|
|
132
|
+
const storedData = this.otpStore.get(normalizedPhone);
|
|
133
|
+
|
|
134
|
+
if (!storedData) {
|
|
135
|
+
throw new Error('No OTP found for this phone number. Please request a new OTP.');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (Date.now() - storedData.timestamp > storedData.expiryTime) {
|
|
139
|
+
this.otpStore.delete(normalizedPhone);
|
|
140
|
+
throw new Error('OTP has expired. Please request a new OTP.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
storedData.attempts++;
|
|
144
|
+
|
|
145
|
+
if (storedData.attempts > this.MAX_ATTEMPTS) {
|
|
146
|
+
this.otpStore.delete(normalizedPhone);
|
|
147
|
+
throw new Error('Maximum OTP attempts exceeded. Please request a new OTP.');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (storedData.otp === inputOtp.trim()) {
|
|
151
|
+
this.otpStore.delete(normalizedPhone);
|
|
152
|
+
return true;
|
|
153
|
+
} else {
|
|
154
|
+
this.otpStore.set(normalizedPhone, storedData);
|
|
155
|
+
throw new Error(`Invalid OTP. ${this.MAX_ATTEMPTS - storedData.attempts} attempts remaining.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
clearOTP(phoneNumber: string): void {
|
|
160
|
+
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
|
161
|
+
this.otpStore.delete(normalizedPhone);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
clearAllOTPs(): void {
|
|
165
|
+
this.otpStore.clear();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
cleanupExpiredOTPs(): void {
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
for (const [phoneNumber, data] of this.otpStore.entries()) {
|
|
171
|
+
if (now - data.timestamp > data.expiryTime) {
|
|
172
|
+
this.otpStore.delete(phoneNumber);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
hasValidOTP(phoneNumber: string): boolean {
|
|
178
|
+
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
|
179
|
+
const storedData = this.otpStore.get(normalizedPhone);
|
|
180
|
+
if (!storedData) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return Date.now() - storedData.timestamp <= storedData.expiryTime;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
getRemainingTimeMinutes(phoneNumber: string): number {
|
|
188
|
+
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
|
189
|
+
const storedData = this.otpStore.get(normalizedPhone);
|
|
190
|
+
if (!storedData) {
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const elapsed = Date.now() - storedData.timestamp;
|
|
195
|
+
const remaining = Math.max(0, storedData.expiryTime - elapsed);
|
|
196
|
+
return Math.ceil(remaining / (60 * 1000));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getRemainingAttempts(phoneNumber: string): number {
|
|
200
|
+
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
|
201
|
+
const storedData = this.otpStore.get(normalizedPhone);
|
|
202
|
+
if (!storedData) {
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return Math.max(0, this.MAX_ATTEMPTS - storedData.attempts);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
getStoredOTP(phoneNumber: string): string | null {
|
|
210
|
+
const normalizedPhone = this.normalizePhoneNumber(phoneNumber);
|
|
211
|
+
const storedData = this.otpStore.get(normalizedPhone);
|
|
212
|
+
return storedData ? storedData.otp : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
transferOTP(oldPhoneNumber: string, newPhoneNumber: string): boolean {
|
|
216
|
+
const oldNormalized = this.normalizePhoneNumber(oldPhoneNumber);
|
|
217
|
+
const newNormalized = this.normalizePhoneNumber(newPhoneNumber);
|
|
218
|
+
|
|
219
|
+
if (oldNormalized === newNormalized) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const storedData = this.otpStore.get(oldNormalized);
|
|
224
|
+
if (!storedData) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.otpStore.set(newNormalized, storedData);
|
|
229
|
+
this.otpStore.delete(oldNormalized);
|
|
230
|
+
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private normalizePhoneNumber(phoneNumber: string): string {
|
|
235
|
+
if (!phoneNumber) {
|
|
236
|
+
return '';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let normalized = phoneNumber.replace(/\D/g, '');
|
|
240
|
+
|
|
241
|
+
if (normalized.startsWith('254') && normalized.length === 12) {
|
|
242
|
+
return normalized;
|
|
243
|
+
} else if (normalized.startsWith('0') && normalized.length === 10) {
|
|
244
|
+
return '254' + normalized.substring(1);
|
|
245
|
+
} else if (normalized.length === 9) {
|
|
246
|
+
return '254' + normalized;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return normalized;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const otpManager = new OTPManager();
|
|
254
|
+
|
|
255
|
+
setInterval(() => {
|
|
256
|
+
otpManager.cleanupExpiredOTPs();
|
|
257
|
+
}, 2 * 60 * 1000);
|
|
258
|
+
|
|
259
|
+
export function createOtpHandlers(patientName: string, expiryMinutes: number) {
|
|
260
|
+
return {
|
|
261
|
+
onRequestOtp: async (phone: string, claimSummary: ClaimSummary): Promise<void> => {
|
|
262
|
+
await otpManager.requestOTP(phone, patientName, claimSummary, expiryMinutes);
|
|
263
|
+
},
|
|
264
|
+
onVerify: async (otp: string, phoneNumber: string): Promise<void> => {
|
|
265
|
+
const isValid = await otpManager.verifyOTP(phoneNumber, otp);
|
|
266
|
+
if (!isValid) {
|
|
267
|
+
throw new Error('OTP verification failed');
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
hasValidOTP: (phone: string): boolean => {
|
|
271
|
+
return otpManager.hasValidOTP(phone);
|
|
272
|
+
},
|
|
273
|
+
getRemainingTime: (phone: string): number => {
|
|
274
|
+
return otpManager.getRemainingTimeMinutes(phone);
|
|
275
|
+
},
|
|
276
|
+
getRemainingAttempts: (phone: string): number => {
|
|
277
|
+
return otpManager.getRemainingAttempts(phone);
|
|
278
|
+
},
|
|
279
|
+
clearOTP: (phone: string): void => {
|
|
280
|
+
otpManager.clearOTP(phone);
|
|
281
|
+
},
|
|
282
|
+
clearAllOTPs: (): void => {
|
|
283
|
+
otpManager.clearAllOTPs();
|
|
284
|
+
},
|
|
285
|
+
getStoredOTP: (phone: string): string | null => {
|
|
286
|
+
return otpManager.getStoredOTP(phone);
|
|
287
|
+
},
|
|
288
|
+
transferOTP: (oldPhone: string, newPhone: string): boolean => {
|
|
289
|
+
return otpManager.transferOTP(oldPhone, newPhone);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { openmrsFetch, restBaseUrl, useConfig } from '@openmrs/esm-framework';
|
|
2
|
+
import useSWR from 'swr';
|
|
3
|
+
import { BillingConfig } from '../config-schema';
|
|
4
|
+
|
|
5
|
+
interface PersonAttribute {
|
|
6
|
+
uuid: string;
|
|
7
|
+
value: string;
|
|
8
|
+
attributeType?: {
|
|
9
|
+
uuid: string;
|
|
10
|
+
display: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PersonAttributeResponse {
|
|
15
|
+
results: PersonAttribute[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const usePhoneNumberAttribute = (patientUuid: string) => {
|
|
19
|
+
const url = `${restBaseUrl}/person/${patientUuid}/attribute?v=custom:(uuid,value,attributeType:(uuid,display))`;
|
|
20
|
+
const { phoneNumberAttributeTypeUUID } = useConfig<BillingConfig>();
|
|
21
|
+
|
|
22
|
+
const { data, isLoading, error } = useSWR<{ data: PersonAttributeResponse }>(patientUuid ? url : null, openmrsFetch);
|
|
23
|
+
|
|
24
|
+
const phoneNumberAttribute = data?.data?.results?.find(
|
|
25
|
+
(attr) => attr.uuid === phoneNumberAttributeTypeUUID || attr.attributeType?.uuid === phoneNumberAttributeTypeUUID,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
attribute: phoneNumberAttribute,
|
|
30
|
+
phoneNumber: phoneNumberAttribute?.value || null,
|
|
31
|
+
isLoading,
|
|
32
|
+
error,
|
|
33
|
+
allAttributes: data?.data?.results,
|
|
34
|
+
};
|
|
35
|
+
};
|
package/src/types/index.ts
CHANGED
|
@@ -631,3 +631,38 @@ export interface ProgressTracker {
|
|
|
631
631
|
export interface checkSHARegNumResponse {
|
|
632
632
|
registrationNumber: string;
|
|
633
633
|
}
|
|
634
|
+
export type OTPVerificationModalOptions = {
|
|
635
|
+
otpLength?: number;
|
|
636
|
+
onVerify?: (otp: string) => Promise<void>;
|
|
637
|
+
obscureText?: boolean;
|
|
638
|
+
centerBoxes?: boolean;
|
|
639
|
+
phoneNumber: string;
|
|
640
|
+
onRequestOtp?: (phoneNumber: string) => Promise<void>;
|
|
641
|
+
onVerificationSuccess?: () => void;
|
|
642
|
+
expiryMinutes?: number;
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
export interface OtpPayload {
|
|
646
|
+
otp: string;
|
|
647
|
+
receiver: string;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export interface OtpContext extends Record<string, string | number> {
|
|
651
|
+
otp: string;
|
|
652
|
+
patient_name: string;
|
|
653
|
+
expiry_time: number;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export interface OtpResponse {
|
|
657
|
+
success: boolean;
|
|
658
|
+
message: string;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export interface ClaimSummary {
|
|
662
|
+
totalAmount: number;
|
|
663
|
+
facility: string;
|
|
664
|
+
totalItems: number;
|
|
665
|
+
services: string;
|
|
666
|
+
startDate: string;
|
|
667
|
+
endDate: string;
|
|
668
|
+
}
|