Skip to content

Commit efc832a

Browse files
committed
#1583 modal complete
1 parent f055542 commit efc832a

7 files changed

Lines changed: 285 additions & 196 deletions

File tree

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

Lines changed: 7 additions & 21 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]);
@@ -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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,26 @@ export const validateUserEditRRPermissions = async (
395395
throw new AccessDeniedException('Only the creator or finance team can edit a reimbursement request');
396396
}
397397
};
398+
399+
export const validateRefund = async (user: User, refundAmount: number, organizationId: string) => {
400+
const totalOwed = await prisma.reimbursement_Request
401+
.findMany({
402+
where: { recipientId: user.userId, dateDeleted: null, accountCode: { organizationId } }
403+
})
404+
.then((userReimbursementRequests: Reimbursement_Request[]) => {
405+
return userReimbursementRequests.reduce((acc: number, curr: Reimbursement_Request) => acc + curr.totalCost, 0);
406+
});
407+
408+
const totalReimbursed = await prisma.reimbursement
409+
.findMany({
410+
where: { purchaserId: user.userId, organizationId },
411+
select: { amount: true }
412+
})
413+
.then((reimbursements: { amount: number }[]) =>
414+
reimbursements.reduce((acc: number, curr: { amount: number }) => acc + curr.amount, 0)
415+
);
416+
417+
if (refundAmount > totalOwed - totalReimbursed) {
418+
throw new HttpException(400, 'Reimbursement is greater than the total amount owed');
419+
}
420+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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();
12+
13+
const defaultValues: RefundModalInputs | undefined = refund && {
14+
refundId: refund.reimbursementId,
15+
refundAmount: (refund.amount / 100).toFixed(2),
16+
dateReceived: new Date(refund.dateCreated)
17+
};
18+
19+
return (
20+
<RefundModal
21+
showModal={!!refund}
22+
handleClose={handleClose}
23+
mutateAsync={mutateAsync}
24+
isLoading={isLoading}
25+
defaultValues={defaultValues}
26+
title="Edit Refund"
27+
/>
28+
);
29+
};
30+
31+
export default EditRefundModal;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
12+
const schema = yup.object().shape({
13+
refundAmount: yup
14+
.number()
15+
.required('Refund amount is required')
16+
.positive('Refund amount must be positive')
17+
.test('two decimals', 'Refund amount must have at most two decimal places', (value) => {
18+
// technically this allows trailing zeros, but that's fine
19+
if (!value) return false;
20+
21+
const strValue = value.toString();
22+
const parts = strValue.split('.');
23+
if (parts.length === 1) return true;
24+
return parts[1].length <= 2;
25+
})
26+
.typeError('Refund amount is required'),
27+
dateReceived: yup.date().required()
28+
});
29+
30+
interface RefundModalProps {
31+
showModal: boolean;
32+
handleClose: () => void;
33+
mutateAsync: (data: any) => void; // if you can figure out how to change this without raising type errors, be my guest
34+
isLoading: boolean;
35+
defaultValues?: RefundModalInputs;
36+
title: string;
37+
}
38+
39+
export interface RefundModalInputs {
40+
refundId?: string;
41+
refundAmount: string; // this allows us to display default value with 2 decimal places
42+
dateReceived: Date;
43+
}
44+
45+
const RefundModal: React.FC<RefundModalProps> = ({
46+
showModal: modalShow,
47+
handleClose,
48+
mutateAsync,
49+
defaultValues,
50+
isLoading,
51+
title
52+
}: RefundModalProps) => {
53+
const toast = useToast();
54+
55+
const {
56+
handleSubmit,
57+
control,
58+
formState: { errors, isValid },
59+
watch,
60+
reset
61+
} = useForm({
62+
resolver: yupResolver(schema),
63+
defaultValues: defaultValues ?? {
64+
refundAmount: '0',
65+
dateReceived: new Date()
66+
},
67+
mode: 'onChange'
68+
});
69+
70+
const handleConfirm = async (data: { refundAmount: number; dateReceived: Date }) => {
71+
handleClose();
72+
try {
73+
await mutateAsync({
74+
refundId: defaultValues?.refundId,
75+
refundAmount: Math.round(data.refundAmount * 100),
76+
dateReceived: data.dateReceived.toISOString()
77+
});
78+
toast.success(defaultValues ? 'Account credit updated successfully' : 'New account credit reported successfully');
79+
} catch (error: unknown) {
80+
if (error instanceof Error) {
81+
toast.error(error.message);
82+
}
83+
}
84+
};
85+
86+
return (
87+
<NERFormModal
88+
open={modalShow}
89+
onHide={handleClose}
90+
title={title}
91+
reset={reset}
92+
handleUseFormSubmit={handleSubmit}
93+
onFormSubmit={handleConfirm}
94+
formId="reimbursement-form"
95+
disabled={!isValid || (defaultValues && defaultValues.refundAmount === watch('refundAmount'))}
96+
>
97+
{isLoading ? (
98+
<LoadingIndicator />
99+
) : (
100+
<FormControl>
101+
<FormLabel>Amount</FormLabel>
102+
<ReactHookTextField
103+
name="refundAmount"
104+
type="number"
105+
control={control}
106+
sx={{ width: 1 }}
107+
startAdornment={<AttachMoneyIcon />}
108+
errorMessage={errors.refundAmount}
109+
/>
110+
111+
<FormLabel sx={{ paddingTop: 2 }}>Date Received</FormLabel>
112+
<Controller
113+
name="dateReceived"
114+
control={control}
115+
rules={{ required: true }}
116+
render={({ field: { onChange, value } }) => (
117+
<DatePicker
118+
format="yyyy-MM-dd"
119+
onChange={(e) => onChange(e ?? new Date())}
120+
className={'padding: 10'}
121+
value={value}
122+
slotProps={{ textField: { autoComplete: 'off' } }}
123+
/>
124+
)}
125+
/>
126+
</FormControl>
127+
)}
128+
</NERFormModal>
129+
);
130+
};
131+
132+
export default RefundModal;
Lines changed: 13 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,23 @@
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';
101
import { useReportRefund } from '../../../hooks/finance.hooks';
11-
import { useToast } from '../../../hooks/toasts.hooks';
2+
import RefundModal from './RefundModal';
123

13-
const schema = yup.object().shape({
14-
refundAmount: yup
15-
.number()
16-
.required()
17-
.test('two decimals', 'Refund must be formatted as a dollar amount with at most two decimals', (value) => {
18-
if (!value) return false;
19-
const roundedValue = Math.round(value * 100) / 100; // 2 decimal places
20-
const threshold = 0.001;
21-
return Math.abs(roundedValue - value) <= threshold;
22-
})
23-
.typeError('The refund amount should be a valid number'),
24-
dateReceived: yup.date().required()
25-
});
26-
27-
interface ReportRefundProps {
28-
modalShow: boolean;
4+
interface ReportRefundModalProps {
5+
showModal: boolean;
296
handleClose: () => void;
307
}
318

32-
export interface ReportRefundInputs {
33-
newAccountCreditAmount: number;
34-
}
35-
36-
const RefundModal: React.FC<ReportRefundProps> = ({ modalShow, handleClose }: ReportRefundProps) => {
37-
const toast = useToast();
38-
const { isLoading, mutateAsync } = useReportRefund();
39-
40-
const {
41-
handleSubmit,
42-
control,
43-
formState: { errors },
44-
reset
45-
} = useForm({
46-
resolver: yupResolver(schema),
47-
defaultValues: {
48-
refundAmount: '',
49-
dateReceived: new Date()
50-
},
51-
mode: 'onChange'
52-
});
53-
54-
const handleConfirm = async (data: { refundAmount: number; dateReceived: Date }) => {
55-
handleClose();
56-
try {
57-
await mutateAsync({
58-
refundAmount: Math.round(data.refundAmount * 100),
59-
dateReceived: data.dateReceived.toISOString()
60-
});
61-
toast.success('New Account Credit Reported Successfully');
62-
} catch (error: unknown) {
63-
if (error instanceof Error) {
64-
toast.error(error.message);
65-
}
66-
}
67-
};
9+
const ReportRefundModal: React.FC<ReportRefundModalProps> = ({ showModal, handleClose }: ReportRefundModalProps) => {
10+
const { mutateAsync, isLoading } = useReportRefund();
6811

6912
return (
70-
<NERFormModal
71-
open={modalShow}
72-
onHide={handleClose}
73-
title={'Report New Account Credit'}
74-
reset={reset}
75-
handleUseFormSubmit={handleSubmit}
76-
onFormSubmit={handleConfirm}
77-
formId="reimbursement-form"
78-
>
79-
{isLoading ? (
80-
<LoadingIndicator />
81-
) : (
82-
<FormControl>
83-
<FormLabel>Amount</FormLabel>
84-
<ReactHookTextField
85-
name="refundAmount"
86-
control={control}
87-
sx={{ width: 1 }}
88-
startAdornment={<AttachMoneyIcon />}
89-
errorMessage={errors.refundAmount}
90-
/>
91-
92-
<FormLabel sx={{ paddingTop: 2 }}>Date Received</FormLabel>
93-
<Controller
94-
name="dateReceived"
95-
control={control}
96-
rules={{ required: true }}
97-
render={({ field: { onChange, value } }) => (
98-
<DatePicker
99-
format="yyyy-MM-dd"
100-
onChange={(e) => onChange(e ?? new Date())}
101-
className={'padding: 10'}
102-
value={value}
103-
slotProps={{ textField: { autoComplete: 'off' } }}
104-
/>
105-
)}
106-
/>
107-
</FormControl>
108-
)}
109-
</NERFormModal>
13+
<RefundModal
14+
showModal={showModal}
15+
handleClose={handleClose}
16+
mutateAsync={mutateAsync}
17+
isLoading={isLoading}
18+
title="Report New Account Credit"
19+
/>
11020
);
11121
};
11222

113-
export default RefundModal;
23+
export default ReportRefundModal;

src/frontend/src/pages/FinancePage/FinancePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ import LoadingIndicator from '../../components/LoadingIndicator';
2424
import PageLayout from '../../components/PageLayout';
2525
import { useHistory } from 'react-router-dom';
2626
import { routes } from '../../utils/routes';
27-
import RefundModal from './FinanceComponents/ReportRefundModal';
2827
import GenerateReceiptsModal from './FinanceComponents/GenerateReceiptsModal';
2928
import PendingAdvisorModal from './FinanceComponents/PendingAdvisorListModal';
3029
import { isAdmin, isGuest } from 'shared';
3130
import WorkIcon from '@mui/icons-material/Work';
3231
import TotalAmountSpentModal from './FinanceComponents/TotalAmountSpentModal';
3332
import { useToast } from '../../hooks/toasts.hooks';
33+
import ReportRefundModal from './FinanceComponents/ReportRefundModal';
3434

3535
const FinancePage = () => {
3636
const user = useCurrentUser();
@@ -176,7 +176,7 @@ const FinancePage = () => {
176176
onHide={() => setShowTotalAmountSpent(false)}
177177
/>
178178
)}
179-
<RefundModal modalShow={accountCreditModalShow} handleClose={() => setAccountCreditModalShow(false)} />
179+
<ReportRefundModal showModal={accountCreditModalShow} handleClose={() => setAccountCreditModalShow(false)} />
180180
<GenerateReceiptsModal
181181
open={showGenerateReceipts}
182182
setOpen={setShowGenerateReceipts}

0 commit comments

Comments
 (0)