Skip to content

Commit 86fe01c

Browse files
authored
Merge pull request #2680 from Northeastern-Electric-Racing/#1583-edit-refund-modal
#1583 Abstracted Refund Modal
2 parents 3e18f8e + 7f066c2 commit 86fe01c

11 files changed

Lines changed: 345 additions & 194 deletions

File tree

src/backend/src/services/reimbursement-requests.services.ts

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import {
2929
updateReimbursementProducts,
3030
validateReimbursementProducts,
3131
validateUserEditRRPermissions,
32-
validateUserIsPartOfFinanceTeam
32+
validateUserIsPartOfFinanceTeam,
33+
validateRefund
3334
} from '../utils/reimbursement-requests.utils';
3435
import {
3536
AccessDeniedAdminOnlyException,
@@ -210,26 +211,7 @@ export default class ReimbursementRequestService {
210211
throw new AccessDeniedException('Guests cannot reimburse a user for their expenses.');
211212
}
212213

213-
const totalOwed = await prisma.reimbursement_Request
214-
.findMany({
215-
where: { recipientId: submitter.userId, dateDeleted: null, accountCode: { organizationId } }
216-
})
217-
.then((userReimbursementRequests: Reimbursement_Request[]) => {
218-
return userReimbursementRequests.reduce((acc: number, curr: Reimbursement_Request) => acc + curr.totalCost, 0);
219-
});
220-
221-
const totalReimbursed = await prisma.reimbursement
222-
.findMany({
223-
where: { purchaserId: submitter.userId, organizationId },
224-
select: { amount: true }
225-
})
226-
.then((reimbursements: { amount: number }[]) =>
227-
reimbursements.reduce((acc: number, curr: { amount: number }) => acc + curr.amount, 0)
228-
);
229-
230-
if (amount > totalOwed - totalReimbursed) {
231-
throw new HttpException(400, 'Reimbursement is greater than the total amount owed to the user');
232-
}
214+
await validateRefund(submitter, amount, organizationId);
233215

234216
// make the date object but add 12 hours so that the time isn't 00:00 to avoid timezone problems
235217
const dateCreated = new Date(dateReceived.split('T')[0]);
@@ -334,7 +316,7 @@ export default class ReimbursementRequestService {
334316
* @param amount The new amount of the reimbursement
335317
* @param dateCreated The new date the reimbursement was created
336318
* @param organizationId The organization the user is currently in
337-
* @returns The updatd reimbursement
319+
* @returns The updated reimbursement
338320
*/
339321
static async editReimbursement(
340322
reimbursementId: string,
@@ -354,6 +336,10 @@ export default class ReimbursementRequestService {
354336
);
355337
if (request.organizationId !== organizationId) throw new InvalidOrganizationException('Reimbursement');
356338

339+
const difference = amount - request.amount;
340+
341+
if (difference > 0) await validateRefund(editor, difference, organizationId);
342+
357343
const updatedReimbursement = await prisma.reimbursement.update({
358344
where: { reimbursementId },
359345
data: { dateCreated, amount }

src/backend/src/utils/reimbursement-requests.utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,32 @@ export const validateUserEditRRPermissions = async (
395395
throw new AccessDeniedException('Only the creator or finance team can edit a reimbursement request');
396396
}
397397
};
398+
399+
/**
400+
* Validates that the refund amount is less than the total amount owed to the user
401+
* @param user the user reporting or editing a refund
402+
* @param refundAmount the amount of the refund
403+
* @param organizationId the organization the request pertains to
404+
*/
405+
export const validateRefund = async (user: User, refundAmount: number, organizationId: string) => {
406+
const totalOwed = await prisma.reimbursement_Request
407+
.findMany({
408+
where: { recipientId: user.userId, dateDeleted: null, accountCode: { organizationId } }
409+
})
410+
.then((userReimbursementRequests: Reimbursement_Request[]) => {
411+
return userReimbursementRequests.reduce((acc: number, curr: Reimbursement_Request) => acc + curr.totalCost, 0);
412+
});
413+
414+
const totalReimbursed = await prisma.reimbursement
415+
.findMany({
416+
where: { purchaserId: user.userId, organizationId },
417+
select: { amount: true }
418+
})
419+
.then((reimbursements: { amount: number }[]) =>
420+
reimbursements.reduce((acc: number, curr: { amount: number }) => acc + curr.amount, 0)
421+
);
422+
423+
if (refundAmount > totalOwed - totalReimbursed) {
424+
throw new HttpException(400, 'Reimbursement is greater than the total amount owed');
425+
}
426+
};

src/frontend/src/apis/finance.api.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
CreateReimbursementRequestPayload,
77
EditReimbursementRequestPayload,
88
EditVendorPayload,
9-
AccountCodePayload
9+
AccountCodePayload,
10+
RefundPayload
1011
} from '../hooks/finance.hooks';
1112
import axios from '../utils/axios';
1213
import { apiUrls } from '../utils/urls';
@@ -312,6 +313,17 @@ export const reportRefund = (amount: number, dateReceived: string) => {
312313
return axios.post(apiUrls.financeReportRefund(), { amount, dateReceived });
313314
};
314315

316+
/**
317+
* Edits a refund in the database
318+
*
319+
* @param id the reimbursement id
320+
* @param formData the amount and date to edit the refund with
321+
* @returns the updated reimbursement
322+
*/
323+
export const editRefund = (id: string, formData: RefundPayload) => {
324+
return axios.post(apiUrls.financeEditRefund(id), formData);
325+
};
326+
315327
/**
316328
* Edits an expense type in the database
317329
* @param id id of the expense type

src/frontend/src/hooks/finance.hooks.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
createVendor,
3030
editVendor,
3131
getAllAccountCodes,
32+
editRefund,
3233
leadershipApproveReimbursementRequest
3334
} from '../apis/finance.api';
3435
import {
@@ -77,6 +78,11 @@ export interface EditVendorPayload {
7778
name: string;
7879
}
7980

81+
export interface RefundPayload {
82+
amount: number;
83+
dateReceived: string;
84+
}
85+
8086
/**
8187
* Custom React Hook to upload a new picture.
8288
*/
@@ -438,16 +444,37 @@ export const useSendPendingAdvisorList = () => {
438444
*/
439445
export const useReportRefund = () => {
440446
const queryClient = useQueryClient();
441-
return useMutation<Reimbursement, Error, { refundAmount: number; dateReceived: string }>(
447+
return useMutation<Reimbursement, Error, { amount: number; dateReceived: string }>(
442448
['reimbursement'],
443-
async (formData: { refundAmount: number; dateReceived: string }) => {
444-
const { data } = await reportRefund(formData.refundAmount, formData.dateReceived);
449+
async (formData: { amount: number; dateReceived: string }) => {
450+
const { data } = await reportRefund(formData.amount, formData.dateReceived);
445451
queryClient.invalidateQueries(['reimbursement']);
446452
return data;
447453
}
448454
);
449455
};
450456

457+
/**
458+
* Custom react hook to edit a refund
459+
* @returns the edited refund
460+
*/
461+
export const useEditRefund = (id: string) => {
462+
const queryClient = useQueryClient();
463+
464+
return useMutation<Reimbursement, Error, { amount: number; dateReceived: string }>(
465+
['reimbursement', 'edit'],
466+
async (formData: { amount: number; dateReceived: string }) => {
467+
const { data } = await editRefund(id, formData);
468+
return data;
469+
},
470+
{
471+
onSuccess: () => {
472+
queryClient.invalidateQueries(['reimbursement']);
473+
}
474+
}
475+
);
476+
};
477+
451478
/**
452479
* Custom react hook to update a reimbursement request's SABO number
453480
*
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Reimbursement } from 'shared';
2+
import { useEditRefund } from '../../../hooks/finance.hooks';
3+
import RefundModal, { RefundModalInputs } from './RefundModal';
4+
5+
interface EditRefundModalProps {
6+
refund?: Reimbursement;
7+
handleClose: () => void;
8+
}
9+
10+
const EditRefundModal: React.FC<EditRefundModalProps> = ({ refund, handleClose }: EditRefundModalProps) => {
11+
const { mutateAsync, isLoading } = useEditRefund(refund!.reimbursementId);
12+
13+
const defaultValues: RefundModalInputs | undefined = refund && {
14+
amount: (refund.amount / 100).toFixed(2),
15+
dateReceived: new Date(refund.dateCreated)
16+
};
17+
18+
return (
19+
<RefundModal
20+
showModal={!!refund}
21+
handleClose={handleClose}
22+
mutateAsync={mutateAsync}
23+
isLoading={isLoading}
24+
defaultValues={defaultValues}
25+
title="Edit Refund"
26+
/>
27+
);
28+
};
29+
30+
export default EditRefundModal;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { yupResolver } from '@hookform/resolvers/yup';
2+
import AttachMoneyIcon from '@mui/icons-material/AttachMoney';
3+
import { FormControl, FormLabel } from '@mui/material';
4+
import { DatePicker } from '@mui/x-date-pickers';
5+
import { Controller, useForm } from 'react-hook-form';
6+
import * as yup from 'yup';
7+
import LoadingIndicator from '../../../components/LoadingIndicator';
8+
import NERFormModal from '../../../components/NERFormModal';
9+
import ReactHookTextField from '../../../components/ReactHookTextField';
10+
import { useToast } from '../../../hooks/toasts.hooks';
11+
import { Reimbursement } from 'shared';
12+
import { UseMutateAsyncFunction } from 'react-query';
13+
14+
const schema = yup.object().shape({
15+
amount: yup
16+
.number()
17+
.required('Refund amount is required')
18+
.positive('Refund amount must be positive')
19+
.test('two decimals', 'Refund amount must have at most two decimal places', (value) => {
20+
// technically this allows trailing zeros, but that's fine
21+
if (!value) return false;
22+
23+
const strValue = value.toString();
24+
const parts = strValue.split('.');
25+
if (parts.length === 1) return true;
26+
return parts[1].length <= 2;
27+
})
28+
.typeError('Refund amount is required'),
29+
dateReceived: yup.date().required()
30+
});
31+
32+
interface RefundModalProps {
33+
showModal: boolean;
34+
handleClose: () => void;
35+
mutateAsync: UseMutateAsyncFunction<
36+
Reimbursement,
37+
Error,
38+
{
39+
amount: number;
40+
dateReceived: string;
41+
},
42+
unknown
43+
>;
44+
isLoading: boolean;
45+
defaultValues?: RefundModalInputs;
46+
title: string;
47+
}
48+
49+
export interface RefundModalInputs {
50+
amount: string; // this allows us to display default value with 2 decimal places - the type is enforced and casted via the input field and form schema
51+
dateReceived: Date;
52+
}
53+
54+
const RefundModal: React.FC<RefundModalProps> = ({
55+
showModal: modalShow,
56+
handleClose,
57+
mutateAsync,
58+
defaultValues,
59+
isLoading,
60+
title
61+
}: RefundModalProps) => {
62+
const toast = useToast();
63+
64+
const {
65+
handleSubmit,
66+
control,
67+
formState: { errors, isValid },
68+
watch,
69+
reset
70+
} = useForm({
71+
resolver: yupResolver(schema),
72+
defaultValues: defaultValues ?? {
73+
amount: '0',
74+
dateReceived: new Date()
75+
},
76+
mode: 'onChange'
77+
});
78+
79+
const handleConfirm = async (data: { amount: number; dateReceived: Date }) => {
80+
try {
81+
await mutateAsync({
82+
amount: Math.round(data.amount * 100),
83+
dateReceived: data.dateReceived.toISOString()
84+
});
85+
toast.success(defaultValues ? 'Account credit updated successfully' : 'New account credit reported successfully');
86+
handleClose();
87+
} catch (error: unknown) {
88+
if (error instanceof Error) {
89+
toast.error(error.message);
90+
}
91+
}
92+
};
93+
94+
return (
95+
<NERFormModal
96+
open={modalShow}
97+
onHide={handleClose}
98+
title={title}
99+
reset={reset}
100+
handleUseFormSubmit={handleSubmit}
101+
onFormSubmit={handleConfirm}
102+
formId="reimbursement-form"
103+
disabled={!isValid || (defaultValues && defaultValues.amount === watch('amount'))}
104+
>
105+
{isLoading ? (
106+
<LoadingIndicator />
107+
) : (
108+
<FormControl>
109+
<FormLabel>Amount</FormLabel>
110+
<ReactHookTextField
111+
name="amount"
112+
type="number"
113+
control={control}
114+
sx={{ width: 1 }}
115+
startAdornment={<AttachMoneyIcon />}
116+
errorMessage={errors.amount}
117+
/>
118+
119+
<FormLabel sx={{ paddingTop: 2 }}>Date Received</FormLabel>
120+
<Controller
121+
name="dateReceived"
122+
control={control}
123+
rules={{ required: true }}
124+
render={({ field: { onChange, value } }) => (
125+
<DatePicker
126+
format="yyyy-MM-dd"
127+
onChange={(e) => onChange(e ?? new Date())}
128+
className={'padding: 10'}
129+
value={value}
130+
slotProps={{ textField: { autoComplete: 'off' } }}
131+
/>
132+
)}
133+
/>
134+
</FormControl>
135+
)}
136+
</NERFormModal>
137+
);
138+
};
139+
140+
export default RefundModal;

0 commit comments

Comments
 (0)