@kenyaemr/esm-billing-app 5.4.2-pre.2805 → 5.4.2-pre.2821

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.
@@ -1,6 +1,22 @@
1
- import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
2
- import { ClaimSummary } from '../types';
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
- * Verifies the OTP by calling the server's validation endpoint.
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
- export async function verifyOtpWithServer(otpId: string, otp: string): Promise<boolean> {
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 url = `${restBaseUrl}/kenyaemr/validate-otp`;
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
- headers: {
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
- const rawText = await response.text();
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
- let parsedResponse;
69
- try {
70
- parsedResponse = JSON.parse(rawText);
71
- } catch (e) {
72
- throw new Error('Invalid response from server');
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
- let data = parsedResponse;
76
- if (parsedResponse.response && typeof parsedResponse.response === 'string') {
77
- try {
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
- if (data.status === 'success' || data.valid === true) {
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
- const errorMessage = data.message || 'Invalid OTP';
88
- throw new Error(errorMessage);
189
+ this.otpStore.set(formattedPhone, storedData);
190
+ throw new Error(`Invalid OTP. ${this.MAX_ATTEMPTS - storedData.attempts} attempts remaining.`);
89
191
  }
90
- } catch (error) {
91
- const errorMessage = error instanceof Error ? error.message : 'OTP verification failed';
92
- throw new Error(errorMessage);
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
- * Sends an OTP request to the server with claim details which generates and sends the OTP via SMS.
98
- * Returns the OTP session ID needed for verification.
270
+ * Requests OTP from server (server generates and sends OTP)
99
271
  */
100
- export async function requestOtpFromServer(
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: `KES ${claimSummary.totalAmount.toLocaleString()}`,
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
- export class OTPManager {
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
- export const otpManager = new OTPManager();
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);
package/src/index.ts CHANGED
@@ -69,6 +69,7 @@ import { ClockOut } from './bill-administration/payment-points/payment-point/clo
69
69
  // Print Preview Components
70
70
  import PrintPreviewModal from './print-preview/print-preview.modal';
71
71
  import PaymentWorkspace from './invoice/payments/payment-form/payment.workspace';
72
+ import { ClaimSummaryModal } from './claims/claims-management/table/claim-summary-modal/claim-summary.modal';
72
73
 
73
74
  // Translation
74
75
  export const importTranslation = require.context('../translations', false, /.json$/, 'lazy');
@@ -132,6 +133,7 @@ export const bulkImportBillableServicesModal = getSyncLifecycle(BulkImportBillab
132
133
  // Claims Management Components
133
134
  export const claimsOverview = getSyncLifecycle(ClaimsManagementOverview, options);
134
135
  export const manageClaimRequestModal = getSyncLifecycle(ManageClaimRequest, options);
136
+ export const claimSummaryModal = getSyncLifecycle(ClaimSummaryModal, options);
135
137
 
136
138
  // Print Preview Components
137
139
  export const printPreviewModal = getSyncLifecycle(PrintPreviewModal, options);
package/src/routes.json CHANGED
@@ -390,6 +390,10 @@
390
390
  "name": "manage-claim-request-modal",
391
391
  "component": "manageClaimRequestModal"
392
392
  },
393
+ {
394
+ "name": "claim-summary-modal",
395
+ "component": "claimSummaryModal"
396
+ },
393
397
  {
394
398
  "name": "clock-in-modal",
395
399
  "component": "clockIn"
@@ -563,6 +563,7 @@ export type FacilityClaim = {
563
563
  externalId: string;
564
564
  responseUUID: string;
565
565
  insurer?: string;
566
+ visitType?: { uuid: string; display: string };
566
567
  };
567
568
  export type BillingPromptType = 'patient-chart' | 'billing-orders';
568
569
 
@@ -689,3 +690,7 @@ interface PatientIdentifier {
689
690
  export interface PatientIdentifierResponse {
690
691
  results: Array<PatientIdentifier>;
691
692
  }
693
+
694
+ export interface OTPSource {
695
+ otpSource?: string;
696
+ }