Skip to content

Commit

Permalink
(feat) : Add ability to capture reference code for defined mode of pa…
Browse files Browse the repository at this point in the history
…yments (#526)
  • Loading branch information
donaldkibet authored Jan 7, 2025
1 parent 17889fa commit ad56e2d
Show file tree
Hide file tree
Showing 11 changed files with 124 additions and 48 deletions.
19 changes: 17 additions & 2 deletions __mocks__/bills.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,26 +641,41 @@ export const mockLineItems = [
];

export const mockPaymentModes = [
{ uuid: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74', name: 'Cash', description: 'Cash Payment', retired: false },
{
uuid: '63eff7a4-6f82-43c4-a333-dbcc58fe9f74',
name: 'Cash',
description: 'Cash Payment',
retired: false,
attributeTypes: [],
},
{
uuid: 'beac329b-f1dc-4a33-9e7c-d95821a137a6',
name: 'Insurance',
description: 'Insurance method of payment',
retired: false,
attributeTypes: [],
},
{
uuid: '28989582-e8c3-46b0-96d0-c249cb06d5c6',
name: 'Mobile Money',
description: 'Mobile money method of payment',
retired: false,
attributeTypes: [],
},
{
uuid: 'd1d6e7da-2717-49c4-a855-28fc5df3b3b7',
name: 'Social Health Insurance Fund (SHA)',
description: 'Social Health Insurance Fund (SHA)',
retired: false,
attributeTypes: [],
},
{
uuid: 'eb6173cb-9678-4614-bbe1-0ccf7ed9d1d4',
name: 'Waiver',
description: 'Waiver payment',
retired: false,
attributeTypes: [],
},
{ uuid: 'eb6173cb-9678-4614-bbe1-0ccf7ed9d1d4', name: 'Waiver', description: 'Waiver payment', retired: false },
];

export const mockedActiveSheet = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ export const PaymentHistoryTable = ({
return {
...row,
totalAmount: convertToCurrency(row.payments.reduce((acc, payment) => acc + payment.amountTendered, 0)),
referenceCodes: row.payments.map(({ attributes }) => attributes.map(({ value }) => value).join(' ')).join(', '),
referenceCodes: row.payments
.map(({ attributes }) => attributes.map(({ value }) => value).join(', '))
.filter((code) => code !== '')
.join(', '),
};
});

Expand All @@ -83,6 +86,7 @@ export const PaymentHistoryTable = ({
'Total Amount Paid': row.payments.reduce((acc, payment) => acc + payment.amountTendered, 0),
'Reason/Reference': row.payments
.map(({ attributes }) => attributes.map(({ value }) => value).join(' '))
.filter((code) => code !== '')
.join(', '),
};
});
Expand Down
42 changes: 42 additions & 0 deletions packages/esm-billing-app/src/hooks/usePaymentSchema.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { z } from 'zod';
import { type MappedBill } from '../types';

export function usePaymentSchema(bill: MappedBill) {
const paymentSchema = z
.object({
method: z
.object({
uuid: z.string(),
name: z.string(),
attributeTypes: z.array(
z.object({
uuid: z.string(),
description: z.string(),
required: z.boolean(),
}),
),
})
.nullable()
.refine((val) => val !== null, { message: 'Payment method is required' }),
amount: z.number().refine((value) => {
const amountDue = Number(bill.totalAmount) - (Number(bill.tenderedAmount) + Number(value));
return amountDue >= 0 && value > 0;
}, 'Amount paid should not be greater than amount due'),
referenceCode: z.string(),
})
.refine(
(data) => {
const hasRequiredAttribute = data.method?.attributeTypes.some((attr) => attr.required === true);
if (hasRequiredAttribute) {
return data.referenceCode.trim().length > 0;
}
return true;
},
{
message: 'Reference code is required for this payment method',
path: ['referenceCode'],
},
);

return paymentSchema;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { Button, Dropdown, NumberInputSkeleton, TextInput, NumberInput } from '@
import { ErrorState } from '@openmrs/esm-patient-common-lib';
import styles from './payment-form.scss';
import { usePaymentModes } from '../../../billing.resource';
import { PaymentFormValue } from '../../../types';
import { PaymentFormValue, PaymentMethod } from '../../../types';

type PaymentFormProps = {
disablePayment: boolean;
amountDue: number;
append: (obj: { method: string; amount: number; referenceCode: string }) => void;
append: (obj: { method: PaymentMethod; amount: number; referenceCode: string }) => void;
fields: FieldArrayWithId<PaymentFormValue, 'payment', 'id'>[];
remove: UseFieldArrayRemove;
};
Expand All @@ -22,12 +22,18 @@ const PaymentForm: React.FC<PaymentFormProps> = ({ disablePayment, amountDue, ap
control,
formState: { errors },
setFocus,
getValues,
} = useFormContext<PaymentFormValue>();
const { paymentModes, isLoading, error } = usePaymentModes();

const shouldShowReferenceCode = (index: number) => {
const formValues = getValues();
return formValues?.payment?.[index]?.method?.attributeTypes?.some((attribute) => attribute.required);
};

const handleAppendPaymentMode = useCallback(() => {
{
append({ method: '', amount: 0, referenceCode: '' });
append({ method: null, amount: 0, referenceCode: '' });
setFocus(`payment.${fields.length}.method`);
}
}, [append]);
Expand Down Expand Up @@ -59,7 +65,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({ disablePayment, amountDue, ap
id="paymentMethod"
onChange={({ selectedItem }) => {
setFocus(`payment.${index}.amount`);
field.onChange(selectedItem?.uuid);
field.onChange(selectedItem);
}}
titleText={t('paymentMethod', 'Payment method')}
label={t('selectPaymentMethod', 'Select payment method')}
Expand All @@ -85,19 +91,23 @@ const PaymentForm: React.FC<PaymentFormProps> = ({ disablePayment, amountDue, ap
/>
)}
/>
<Controller
name={`payment.${index}.referenceCode`}
control={control}
render={({ field }) => (
<TextInput
{...field}
id="paymentReferenceCode"
labelText={t('referenceNumber', 'Reference number')}
placeholder={t('enterReferenceNumber', 'Enter ref. number')}
type="text"
/>
)}
/>
{shouldShowReferenceCode(index) && (
<Controller
name={`payment.${index}.referenceCode`}
control={control}
render={({ field }) => (
<TextInput
{...field}
id="paymentReferenceCode"
labelText={t('referenceNumber', 'Reference number')}
placeholder={t('enterReferenceNumber', 'Enter ref. number')}
type="text"
invalid={!!errors?.payment?.[index]?.referenceCode}
invalidText={errors?.payment?.[index]?.referenceCode?.message}
/>
)}
/>
)}
<div className={styles.removeButtonContainer}>
<TrashCan onClick={() => handleRemovePaymentMode(index)} className={styles.removeButton} size={20} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react';
import { Button, InlineNotification } from '@carbon/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { navigate, showSnackbar } from '@openmrs/esm-framework';
import { CardHeader } from '@openmrs/esm-patient-common-lib';
import React, { useState } from 'react';
import { FormProvider, useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { mutate } from 'swr';
Expand All @@ -17,6 +17,7 @@ import PaymentForm from './payment-form/payment-form.component';
import PaymentHistory from './payment-history/payment-history.component';
import styles from './payments.scss';
import { createPaymentPayload } from './utils';
import { usePaymentSchema } from '../../hooks/usePaymentSchema';

type PaymentProps = {
bill: MappedBill;
Expand All @@ -25,18 +26,11 @@ type PaymentProps = {

const Payments: React.FC<PaymentProps> = ({ bill, selectedLineItems }) => {
const { t } = useTranslation();
const paymentSchema = z.object({
method: z.string().refine((value) => !!value, 'Payment method is required'),
amount: z.number().refine((value) => {
const amountDue = Number(bill.totalAmount) - (Number(bill.tenderedAmount) + Number(value));
return amountDue >= 0;
}, 'Amount paid should not be greater than amount due'),
referenceCode: z.union([z.number(), z.string()]).optional(),
});
const paymentSchema = usePaymentSchema(bill);
const { globalActiveSheet } = useClockInStatus();

const methods = useForm<PaymentFormValue>({
mode: 'all',
mode: 'onSubmit',
defaultValues: { payment: [] },
resolver: zodResolver(z.object({ payment: z.array(paymentSchema) })),
});
Expand All @@ -46,7 +40,6 @@ const Payments: React.FC<PaymentProps> = ({ bill, selectedLineItems }) => {
name: 'payment',
control: methods.control,
});
const [paymentSuccessful, setPaymentSuccessful] = useState(false);

const totalWaivedAmount = computeWaivedAmount(bill);
const totalAmountTendered = formValues?.reduce((curr: number, prev) => curr + Number(prev.amount), 0) ?? 0;
Expand Down Expand Up @@ -85,7 +78,6 @@ const Payments: React.FC<PaymentProps> = ({ bill, selectedLineItems }) => {
});
const url = `/ws/rest/v1/cashier/bill/${bill.uuid}`;
mutate((key) => typeof key === 'string' && key.startsWith(url), undefined, { revalidate: true });
setPaymentSuccessful(true);
},
(error) => {
showSnackbar({
Expand Down Expand Up @@ -123,7 +115,7 @@ const Payments: React.FC<PaymentProps> = ({ bill, selectedLineItems }) => {
<InlineNotification
title={t('incompletePayment', 'Incomplete payment')}
subtitle={t(
'paymentErrorSubtitle',
'incompletePaymentSubtitle',
'Please ensure all selected line items are fully paid, Total amount expected is {{selectedLineItemsAmountDue}}',
{
selectedLineItemsAmountDue: convertToCurrency(selectedLineItemsAmountDue),
Expand All @@ -136,9 +128,9 @@ const Payments: React.FC<PaymentProps> = ({ bill, selectedLineItems }) => {
)}
{hasAmountPaidExceeded && (
<InlineNotification
title={t('paymentError', 'Payment error')}
title={t('overPayment', 'Over payment')}
subtitle={t(
'paymentErrorSubtitle',
'overPaymentSubtitle',
'Amount paid {{totalAmountTendered}} should not be greater than amount due {{selectedLineItemsAmountDue}} for selected line items',
{
totalAmountTendered: convertToCurrency(totalAmountTendered),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('Payment', () => {
voided: false,
},
{
billableService: null,
billableService: 'Hemoglobin',
display: 'BillLineItem',
item: 'Hemoglobin',
lineItemOrder: 0,
Expand Down
23 changes: 18 additions & 5 deletions packages/esm-billing-app/src/invoice/payments/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,16 +121,22 @@ export const createPaymentPayload = (
const existingPayments = payments.map((payment) => ({
amount: payment.amount,
amountTendered: payment.amountTendered,
attributes: [],
attributes: payment.attributes.map((attribute) => ({
attributeType: attribute.attributeType?.uuid,
value: attribute.value,
})),
instanceType: payment.instanceType.uuid,
}));

// Transform new payments
const currentPayments = paymentFormValues.map((formValue) => ({
amount: parseFloat(totalAmount.toFixed(2)),
amountTendered: parseFloat(Number(formValue.amount).toFixed(2)),
attributes: [],
instanceType: formValue.method,
attributes: formValue.method?.attributeTypes?.map((attribute) => ({
attributeType: attribute.uuid,
value: formValue.referenceCode,
})),
instanceType: formValue.method?.uuid,
}));

// Combine and calculate payments
Expand Down Expand Up @@ -163,13 +169,20 @@ export const createPaymentPayload = (
: // If no items were selected, update payment status for all line items
lineItems.map((lineItem) => ({
...lineItem,
billableService: extractServiceIdentifier(lineItem),
item: extractServiceIdentifier(lineItem),
billableService: extractServiceIdentifier(lineItem),
paymentStatus: isBillableItemFullyPaid(totalPaidAmount, lineItem),
}));

// Combine selected and remaining items into final processed list
const processedLineItems = [...processedSelectedBillableItems, ...remainingLineItems];
const processedLineItems = [
...processedSelectedBillableItems,
...remainingLineItems.map((item) => ({
...item,
item: extractServiceIdentifier(item),
billableService: extractServiceIdentifier(item),
})),
];

// Determine final bill status
const hasUnpaidItems = processedLineItems.some((item) => item.paymentStatus === PaymentStatus.PENDING);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,11 @@ describe('PaymentModeWorkspace', () => {
// key in name, description and retired
const nameInput = screen.getByRole('textbox', { name: /Payment mode name/i });
const descriptionInput = screen.getByRole('textbox', { name: /Payment mode description/i });
const retiredToggle = screen.getByRole('switch', { name: /Retired/i });
const enablePaymentToggle = screen.getByRole('switch', { name: /Enable payment mode/i });

await user.type(nameInput, 'Test Name');
await user.type(descriptionInput, 'Test Description');
await user.click(retiredToggle);
await user.click(enablePaymentToggle);

// Click to add attribute type
const addAttributeTypeButton = screen.getByRole('button', { name: /Add attribute type/i });
Expand All @@ -102,7 +102,6 @@ describe('PaymentModeWorkspace', () => {
const attributeRegExpInput = screen.getByRole('textbox', { name: /Enter regular expression/i });
const attributeRetiredToggle = screen.getByRole('switch', { name: /Attribute retired/i });
const attributeRequiredToggle = screen.getByRole('switch', { name: /Attribute required/i });
const attributeFormatDropdown = screen.getByRole('combobox', { name: /Attribute format/i });

// Key in attribute type details
await user.type(attributeTypeNameInput, 'Test Attribute Name');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ const PaymentModeWorkspace: React.FC<PaymentModeWorkspaceProps> = ({
render={({ field }) => (
<Toggle
{...field}
labelText={t('paymentModeRetired', 'Retired')}
labelText={t('enablePaymentMode', 'Enable payment mode')}
labelA="Off"
labelB="On"
toggled={field.value}
Expand Down
2 changes: 1 addition & 1 deletion packages/esm-billing-app/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export interface Payment {
resourceVersion: string;
}

export type FormPayment = { method: string; amount: string | number; referenceCode?: number | string };
export type FormPayment = { method: PaymentMethod; amount: string | number; referenceCode?: number | string };

export type PaymentFormValue = {
payment: Array<FormPayment>;
Expand Down
5 changes: 3 additions & 2 deletions packages/esm-billing-app/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"identifier": "Identifier",
"inactive": "Inactive",
"incompletePayment": "Incomplete payment",
"incompletePaymentSubtitle": "Please ensure all selected line items are fully paid, Total amount expected is {{selectedLineItemsAmountDue}}",
"initiatePay": "Initiate Payment",
"inputSampleSchema": "Input sample schema",
"insuranceScheme": "Insurance scheme",
Expand Down Expand Up @@ -186,6 +187,8 @@
"noTransactionHistorySubtitle": "No transaction history loaded for the selected filters",
"notSearchedState": "Please search for a patient in the input above",
"overflowMenu": "Overflow menu",
"overPayment": "Over payment",
"overPaymentSubtitle": "Amount paid {{totalAmountTendered}} should not be greater than amount due {{selectedLineItemsAmountDue}} for selected line items",
"package": "Package",
"packages": "Packages",
"packagesOptions": "Choose packages",
Expand All @@ -199,8 +202,6 @@
"patientMissingSHAId": "Patient missing SHA identification number",
"patientMissingSHANumber": "Patient is missing SHA number, SHA validation cannot be done, Advise patient to visit registration desk",
"patientName": "Patient Name",
"paymentError": "Payment error",
"paymentErrorSubtitle": "Amount paid {{totalAmountTendered}} should not be greater than amount due {{selectedLineItemsAmountDue}} for selected line items",
"paymentHistory": "Payment History",
"paymentMethod": "Payment method",
"paymentMethodDescription": "Payment method {{methodName}}",
Expand Down

0 comments on commit ad56e2d

Please sign in to comment.