From 4ae0786c44764ab03d1720597e8e3350f636cbfa Mon Sep 17 00:00:00 2001 From: zachery with an e <45150570+zweatshirt@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:27:43 -0500 Subject: [PATCH] Refactor PDS Goal Calculator to remove hardcoded values and implement dynamic calculations for reimbursable expenses and monthly support goals --- .../GoalsList/PdsGoalCalculations.graphql | 2 + .../GoalsList/PdsGoalsList.test.tsx | 38 +----- .../GoalsList/PdsGoalsList.tsx | 18 +-- .../PdsGoalCalculatorTestWrapper.tsx | 6 + .../MonthlyReimbursableSection.test.tsx | 16 ++- .../TotalReimbursableSection.test.tsx | 13 ++ .../TotalReimbursableSection.tsx | 13 +- .../Shared/Autosave/useSaveField.test.tsx | 127 +++++++++++++++--- .../Shared/Autosave/useSaveField.ts | 53 +++++++- .../Shared/PdsGoalCalculatorContext.tsx | 30 +++++ .../SupportItem/OtherSection.tsx | 19 ++- .../calculations/reimbursableExpenses.test.ts | 112 +++++++++------ .../calculations/reimbursableExpenses.ts | 7 +- 13 files changed, 321 insertions(+), 133 deletions(-) diff --git a/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalCalculations.graphql b/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalCalculations.graphql index ad179ef2dd..6cdc1728e8 100644 --- a/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalCalculations.graphql +++ b/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalCalculations.graphql @@ -18,6 +18,8 @@ fragment PdsGoalCalculationFields on DesignationSupportCalculation { conferenceRetreatCosts ministryTravelMeals otherAnnualReimbursements + totalReimbursableExpenses + totalMonthlySupportGoal } query PdsGoalCalculations($after: String) { diff --git a/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx b/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx index fb1944c563..b431f65ab3 100644 --- a/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalsList.test.tsx @@ -1,10 +1,6 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { - MpdGoalMiscConstantCategoryEnum, - MpdGoalMiscConstantLabelEnum, -} from 'src/graphql/types.generated'; import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; import { PdsGoalsList } from './PdsGoalsList'; @@ -49,44 +45,18 @@ describe('PdsGoalsList', () => { expect(queryByTestId('goal-name')).not.toBeInTheDocument(); }); - it('seeds reimbursable defaults from MPD constants on create', async () => { + it('creates a new goal with empty attributes so the server applies defaults', async () => { const { findByRole } = render( - + , ); - const button = await findByRole('button', { name: 'Create a New Goal' }); - await waitFor(() => { - expect(button).toBeEnabled(); - }); - userEvent.click(button); + userEvent.click(await findByRole('button', { name: 'Create a New Goal' })); await waitFor(() => { expect(mutationSpy).toHaveGraphqlOperation('CreatePdsGoalCalculation', { - attributes: { - ministryCellPhone: 75, - ministryInternet: 50, - }, + attributes: {}, }); }); }); diff --git a/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx b/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx index b6175a06b5..074a6809ca 100644 --- a/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx +++ b/src/components/Reports/PdsGoalCalculator/GoalsList/PdsGoalsList.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'; import { useGetUserQuery } from 'src/components/User/GetUser.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; import { useFetchAllPages } from 'src/hooks/useFetchAllPages'; -import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import illustration6graybg from 'src/images/drawkit/grape/drawkit-grape-pack-illustration-6-gray-bg.svg'; import { PdsGoalCard } from '../GoalCard/PdsGoalCard'; import { @@ -41,8 +40,6 @@ export const PdsGoalsList: React.FC = () => { }); const [createPdsGoalCalculation] = useCreatePdsGoalCalculationMutation(); const [deletePdsGoalCalculation] = useDeletePdsGoalCalculationMutation(); - const { goalMiscConstants, loading: constantsLoading } = - useGoalCalculatorConstants(); const goals = data?.designationSupportCalculations.nodes; @@ -58,14 +55,7 @@ export const PdsGoalsList: React.FC = () => { const handleCreateGoal = async () => { const { data } = await createPdsGoalCalculation({ - variables: { - attributes: { - ministryCellPhone: - goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.PHONE?.fee, - ministryInternet: - goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.INTERNET?.fee, - }, - }, + variables: { attributes: {} }, }); const calculation = data?.createDesignationSupportCalculation?.designationSupportCalculation; @@ -81,11 +71,7 @@ export const PdsGoalsList: React.FC = () => { - diff --git a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx index 54814c6cd4..194b5e435f 100644 --- a/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx +++ b/src/components/Reports/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx @@ -197,6 +197,12 @@ export const PdsGoalCalculatorTestWrapper: React.FC< label: MpdGoalMiscConstantLabelEnum.AdminRate, fee: 0.12, }, + { + category: + MpdGoalMiscConstantCategoryEnum.AdditionalRates, + label: MpdGoalMiscConstantLabelEnum.MinimumReimbursable, + fee: 300, + }, ], }, constantsMock, diff --git a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.test.tsx b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.test.tsx index 7ec665a3bd..fbddb4f33e 100644 --- a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/MonthlyReimbursableSection.test.tsx @@ -21,6 +21,9 @@ const TestComponent: React.FC = () => ( mpdMiscellaneous: 10, accountTransfers: 50, otherMonthlyReimbursements: 10, + conferenceRetreatCosts: 0, + ministryTravelMeals: 0, + otherAnnualReimbursements: 0, }} constantsMock={{ mpdGoalMiscConstants: [ @@ -34,6 +37,11 @@ const TestComponent: React.FC = () => ( label: MpdGoalMiscConstantLabelEnum.Internet, fee: 30, }, + { + category: MpdGoalMiscConstantCategoryEnum.AdditionalRates, + label: MpdGoalMiscConstantLabelEnum.MinimumReimbursable, + fee: 0, + }, ], }} > @@ -70,14 +78,18 @@ describe('MonthlyReimbursableSection', () => { ); }); - it('autosaves a valid amount edit', async () => { + it('autosaves a valid amount edit along with the recalculated total', async () => { const { findByRole } = render(); await editAmountCell(findByRole, 'Ministry Cell Phone', '20'); + // monthly = 20 + 30 + 25 + 10 + 50 + 10 = 145; floor = 0 (no constant set) await waitFor(() => expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { - attributes: { id: 'goal-1', ministryCellPhone: 20 }, + attributes: { + ministryCellPhone: 20, + totalReimbursableExpenses: 145, + }, }), ); }); diff --git a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.test.tsx b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.test.tsx index 269ad38336..cff90ef264 100644 --- a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.test.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { + MpdGoalMiscConstantCategoryEnum, + MpdGoalMiscConstantLabelEnum, +} from 'src/graphql/types.generated'; import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; import { TotalReimbursableSection } from './TotalReimbursableSection'; @@ -26,6 +30,15 @@ const TestComponent: React.FC = ({ ministryTravelMeals: 0, otherAnnualReimbursements: 0, }} + constantsMock={{ + mpdGoalMiscConstants: [ + { + category: MpdGoalMiscConstantCategoryEnum.AdditionalRates, + label: MpdGoalMiscConstantLabelEnum.MinimumReimbursable, + fee: 300, + }, + ], + }} > diff --git a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx index 846a2ca8ba..4e41d22760 100644 --- a/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx +++ b/src/components/Reports/PdsGoalCalculator/ReimbursableExpenses/TotalReimbursableSection.tsx @@ -8,13 +8,11 @@ import { styled, } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; -import { - REIMBURSABLE_FLOOR, - calculateReimbursableTotals, -} from '../calculations/reimbursableExpenses'; +import { calculateReimbursableTotals } from '../calculations/reimbursableExpenses'; const AmountTypography = styled(Typography)(({ theme }) => ({ fontSize: '2.5rem', @@ -26,12 +24,15 @@ export const TotalReimbursableSection: React.FC = () => { const { t } = useTranslation(); const locale = useLocale(); const { calculation } = usePdsGoalCalculator(); + const { goalMiscConstants } = useGoalCalculatorConstants(); + const reimbursableFloor = + goalMiscConstants.ADDITIONAL_RATES?.MINIMUM_REIMBURSABLE?.fee ?? 0; if (!calculation) { return null; } - const { total } = calculateReimbursableTotals(calculation); + const { total } = calculateReimbursableTotals(calculation, reimbursableFloor); return ( @@ -44,7 +45,7 @@ export const TotalReimbursableSection: React.FC = () => { title={t( 'The total is the greater of the {{floor}} minimum or your calculated amount.', { - floor: currencyFormat(REIMBURSABLE_FLOOR, 'USD', locale), + floor: currencyFormat(reimbursableFloor, 'USD', locale), }, )} > diff --git a/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx b/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx index 6b2fba668b..eab7c4963c 100644 --- a/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx +++ b/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.test.tsx @@ -1,26 +1,48 @@ import React from 'react'; import { renderHook, waitFor } from '@testing-library/react'; -import { PdsGoalCalculatorTestWrapper } from '../../PdsGoalCalculatorTestWrapper'; +import { + DesignationSupportSalaryType, + MpdGoalMiscConstantCategoryEnum, + MpdGoalMiscConstantLabelEnum, +} from 'src/graphql/types.generated'; +import { + GoalCalculatorConstantsMock, + PdsGoalCalculationMock, + PdsGoalCalculatorTestWrapper, +} from '../../PdsGoalCalculatorTestWrapper'; import { useSaveField } from './useSaveField'; const mutationSpy = jest.fn(); -const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - {children} - -); +const buildWrapper = ( + calculationMock: PdsGoalCalculationMock, + constantsMock?: GoalCalculatorConstantsMock, +): React.FC<{ children: React.ReactNode }> => { + const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + + ); + return Wrapper; +}; describe('useSaveField', () => { - it('should update the calculation when a value changes', async () => { - const { result } = renderHook(useSaveField, { wrapper: Wrapper }); + beforeEach(() => { + mutationSpy.mockClear(); + }); + + it('updates the calculation when a value changes', async () => { + const { result } = renderHook(useSaveField, { + wrapper: buildWrapper({ + id: 'goal-1', + name: 'Test Goal', + payRate: 50000, + }), + }); await waitFor(() => expect(mutationSpy).toHaveGraphqlOperation('PdsGoalCalculation'), @@ -28,11 +50,84 @@ describe('useSaveField', () => { result.current({ name: 'New Name' }); + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { id: 'goal-1', name: 'New Name' }, + }), + ); + }); + + it('recomputes totalMonthlySupportGoal with the floored total when a salary input changes', async () => { + const { result } = renderHook(useSaveField, { + wrapper: buildWrapper({ + id: 'goal-1', + salaryOrHourly: DesignationSupportSalaryType.Salaried, + payRate: 48000, + hoursWorkedPerWeek: null, + geographicLocation: null, + totalMonthlySupportGoal: 0, + }), + }); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('PdsGoalCalculation'), + ); + + result.current({ payRate: 60000 }); + + // 60000 / 12 = 5000; FICA 0.08 -> 400; subtotal 5400 + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { + attributes: { + id: 'goal-1', + payRate: 60000, + totalMonthlySupportGoal: 5400, + }, + }), + ); + }); + + it('recomputes totalReimbursableExpenses with the floor applied when a reimbursable input changes', async () => { + const { result } = renderHook(useSaveField, { + wrapper: buildWrapper( + { + id: 'goal-1', + ministryCellPhone: 0, + ministryInternet: 0, + mpdNewsletter: 0, + mpdMiscellaneous: 0, + accountTransfers: 0, + otherMonthlyReimbursements: 0, + conferenceRetreatCosts: 0, + ministryTravelMeals: 0, + otherAnnualReimbursements: 0, + totalReimbursableExpenses: 0, + }, + { + mpdGoalMiscConstants: [ + { + category: MpdGoalMiscConstantCategoryEnum.AdditionalRates, + label: MpdGoalMiscConstantLabelEnum.MinimumReimbursable, + fee: 300, + }, + ], + }, + ), + }); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('PdsGoalCalculation'), + ); + + result.current({ ministryCellPhone: 100 }); + + // raw 100 < 300 floor → total clamps to 300 await waitFor(() => expect(mutationSpy).toHaveGraphqlOperation('UpdatePdsGoalCalculation', { attributes: { id: 'goal-1', - name: 'New Name', + ministryCellPhone: 100, + totalReimbursableExpenses: 300, }, }), ); diff --git a/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.ts b/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.ts index 93ed87327f..b3f7d34704 100644 --- a/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.ts +++ b/src/components/Reports/PdsGoalCalculator/Shared/Autosave/useSaveField.ts @@ -2,9 +2,13 @@ import { useCallback } from 'react'; import { usePdsGoalCalculator } from 'src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext'; import { DesignationSupportCalculationUpdateInput } from 'src/graphql/types.generated'; import { useUpdatePdsGoalCalculationMutation } from '../../GoalsList/PdsGoalCalculations.generated'; +import { calculateReimbursableTotals } from '../../calculations/reimbursableExpenses'; +import { calculateSalaryTotals } from '../../calculations/salaryCalculation'; export const useSaveField = () => { - const { calculation, trackMutation } = usePdsGoalCalculator(); + const { calculation, constants, trackMutation } = usePdsGoalCalculator(); + const { reimbursableFloor, employerFicaRate, geographicMultipliers } = + constants; const [updatePdsGoalCalculation] = useUpdatePdsGoalCalculationMutation(); const saveField = useCallback( @@ -13,8 +17,32 @@ export const useSaveField = () => { return; } - const unchanged = Object.keys(attributes).every( - (key) => calculation[key] === attributes[key], + const merged = { ...calculation, ...attributes }; + const derived: { + totalReimbursableExpenses?: number; + totalMonthlySupportGoal?: number; + } = {}; + + if (reimbursableFloor !== undefined) { + derived.totalReimbursableExpenses = calculateReimbursableTotals( + merged, + reimbursableFloor, + ).total; + } + + if (employerFicaRate !== undefined) { + const geographicMultiplier = + geographicMultipliers.get(merged.geographicLocation ?? '') ?? 0; + derived.totalMonthlySupportGoal = calculateSalaryTotals(merged, { + geographicMultiplier, + employerFicaRate, + }).subtotal; + } + + const payload = { ...attributes, ...derived }; + + const unchanged = Object.keys(payload).every( + (key) => calculation[key] === payload[key], ); if (unchanged) { return; @@ -25,7 +53,7 @@ export const useSaveField = () => { variables: { attributes: { id: calculation.id, - ...attributes, + ...payload, }, }, optimisticResponse: { @@ -34,14 +62,27 @@ export const useSaveField = () => { designationSupportCalculation: { __typename: 'DesignationSupportCalculation', ...calculation, - ...attributes, + ...payload, + totalReimbursableExpenses: + derived.totalReimbursableExpenses ?? + calculation.totalReimbursableExpenses, + totalMonthlySupportGoal: + derived.totalMonthlySupportGoal ?? + calculation.totalMonthlySupportGoal, }, }, }, }), ); }, - [calculation, trackMutation, updatePdsGoalCalculation], + [ + calculation, + reimbursableFloor, + employerFicaRate, + geographicMultipliers, + trackMutation, + updatePdsGoalCalculation, + ], ); return saveField; diff --git a/src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx index 73a3eeb240..5e871955c3 100644 --- a/src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx +++ b/src/components/Reports/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx @@ -2,6 +2,10 @@ import { useRouter } from 'next/router'; import React, { createContext, useCallback, useMemo, useState } from 'react'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; +import { + GoalGeographicConstantMap, + useGoalCalculatorConstants, +} from 'src/hooks/useGoalCalculatorConstants'; import { useTrackMutation } from 'src/hooks/useTrackMutation'; import { PdsGoalCalculationFieldsFragment, @@ -11,6 +15,14 @@ import { PdsGoalCalculatorStepEnum } from '../PdsGoalCalculatorHelper'; import { HcmUserQuery, useHcmUserQuery } from './HCM.generated'; import { PdsGoalCalculatorStep, useSteps } from './useSteps'; +export interface PdsGoalCalculatorConstants { + reimbursableFloor: number | undefined; + employerFicaRate: number | undefined; + phoneMax: number | undefined; + internetMax: number | undefined; + geographicMultipliers: GoalGeographicConstantMap; +} + export type PdsGoalCalculatorType = { steps: PdsGoalCalculatorStep[]; currentStep: PdsGoalCalculatorStep; @@ -18,6 +30,7 @@ export type PdsGoalCalculatorType = { calculation?: PdsGoalCalculationFieldsFragment; calculationLoading: boolean; hcmUser?: HcmUserQuery['hcm'][number]; + constants: PdsGoalCalculatorConstants; /** Whether any mutations are currently in progress */ isMutating: boolean; @@ -71,6 +84,21 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const { data: hcmData } = useHcmUserQuery(); const hcmUser = hcmData?.hcm[0]; + const { goalMiscConstants, goalGeographicConstantMap } = + useGoalCalculatorConstants(); + const constants = useMemo( + () => ({ + reimbursableFloor: + goalMiscConstants.ADDITIONAL_RATES?.MINIMUM_REIMBURSABLE?.fee, + employerFicaRate: + goalMiscConstants.ADDITIONAL_RATES?.EMPLOYER_FICA_RATE?.fee, + phoneMax: goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.PHONE?.fee, + internetMax: goalMiscConstants.REIMBURSEMENTS_WITH_MAXIMUM?.INTERNET?.fee, + geographicMultipliers: goalGeographicConstantMap, + }), + [goalMiscConstants, goalGeographicConstantMap], + ); + const steps = useSteps(); const [stepIndex, setStepIndex] = useState(0); const [rightPanelContent, setRightPanelContent] = @@ -121,6 +149,7 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { stepIndex, calculation, calculationLoading, + constants, isMutating, trackMutation, hcmUser, @@ -140,6 +169,7 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { stepIndex, calculation, calculationLoading, + constants, isMutating, trackMutation, hcmUser, diff --git a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx index 1f44f34b25..f0fbd93759 100644 --- a/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx +++ b/src/components/Reports/PdsGoalCalculator/SupportItem/OtherSection.tsx @@ -18,12 +18,12 @@ export const OtherSection: React.FC = () => { const { t } = useTranslation(); const locale = useLocale(); const localeText = useDataGridLocaleText(); - const { calculation, hcmUser } = usePdsGoalCalculator(); - const { goalMiscConstants, goalGeographicConstantMap } = - useGoalCalculatorConstants(); + const { calculation, hcmUser, constants } = usePdsGoalCalculator(); + const { reimbursableFloor, employerFicaRate, geographicMultipliers } = + constants; + const { goalMiscConstants } = useGoalCalculatorConstants(); const additionalRates = goalMiscConstants.ADDITIONAL_RATES; - const employerFicaRate = additionalRates?.EMPLOYER_FICA_RATE?.fee; const workCompPercentage = additionalRates?.PART_TIME_WORK_COMPENSATION?.fee; const attritionRate = goalMiscConstants.RATES?.ATTRITION_RATE?.fee; const creditCardFeeRate = additionalRates?.CREDIT_CARD_FEE_RATE?.fee; @@ -33,6 +33,7 @@ export const OtherSection: React.FC = () => { if ( !calculation || employerFicaRate === undefined || + reimbursableFloor === undefined || workCompPercentage === undefined || attritionRate === undefined || creditCardFeeRate === undefined || @@ -42,13 +43,16 @@ export const OtherSection: React.FC = () => { } const geographicMultiplier = - goalGeographicConstantMap.get(calculation.geographicLocation ?? '') ?? 0; + geographicMultipliers.get(calculation.geographicLocation ?? '') ?? 0; const salaryTotals = calculateSalaryTotals(calculation, { geographicMultiplier, employerFicaRate, }); - const reimbursableTotals = calculateReimbursableTotals(calculation); + const reimbursableTotals = calculateReimbursableTotals( + calculation, + reimbursableFloor, + ); const taxDeferredPct = (hcmUser?.fourOThreeB?.currentTaxDeferredContributionPercentage ?? 0) / @@ -71,12 +75,13 @@ export const OtherSection: React.FC = () => { }, [ calculation, hcmUser, + reimbursableFloor, employerFicaRate, workCompPercentage, attritionRate, creditCardFeeRate, adminRate, - goalGeographicConstantMap, + geographicMultipliers, locale, t, ]); diff --git a/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.test.ts b/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.test.ts index 383de9cf36..01b61d5d6c 100644 --- a/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.test.ts +++ b/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.test.ts @@ -1,9 +1,10 @@ import { - REIMBURSABLE_FLOOR, ReimbursableCalculationFields, calculateReimbursableTotals, } from './reimbursableExpenses'; +const FLOOR = 300; + const emptyCalculation: ReimbursableCalculationFields = { ministryCellPhone: null, ministryInternet: null, @@ -18,72 +19,87 @@ const emptyCalculation: ReimbursableCalculationFields = { describe('calculateReimbursableTotals', () => { it('returns the floor when all fields are null', () => { - const result = calculateReimbursableTotals(emptyCalculation); + const result = calculateReimbursableTotals(emptyCalculation, FLOOR); expect(result.monthlySubtotal).toBe(0); expect(result.annualSubtotal).toBe(0); expect(result.raw).toBe(0); - expect(result.total).toBe(REIMBURSABLE_FLOOR); + expect(result.total).toBe(FLOOR); expect(result.floorApplied).toBe(true); }); it('applies the floor when monthly subtotal is below 300 with no annual', () => { - const result = calculateReimbursableTotals({ - ...emptyCalculation, - ministryCellPhone: 100, - ministryInternet: 150, - }); + const result = calculateReimbursableTotals( + { + ...emptyCalculation, + ministryCellPhone: 100, + ministryInternet: 150, + }, + FLOOR, + ); expect(result.monthlySubtotal).toBe(250); expect(result.raw).toBe(250); - expect(result.total).toBe(REIMBURSABLE_FLOOR); + expect(result.total).toBe(FLOOR); expect(result.floorApplied).toBe(true); }); it('applies the floor at the exact boundary when raw equals the floor', () => { - const result = calculateReimbursableTotals({ - ...emptyCalculation, - ministryCellPhone: 150, - ministryInternet: 150, - }); - expect(result.raw).toBe(REIMBURSABLE_FLOOR); - expect(result.total).toBe(REIMBURSABLE_FLOOR); + const result = calculateReimbursableTotals( + { + ...emptyCalculation, + ministryCellPhone: 150, + ministryInternet: 150, + }, + FLOOR, + ); + expect(result.raw).toBe(FLOOR); + expect(result.total).toBe(FLOOR); expect(result.floorApplied).toBe(true); }); it('returns the exact monthly subtotal when above the floor', () => { - const result = calculateReimbursableTotals({ - ...emptyCalculation, - ministryCellPhone: 200, - ministryInternet: 200, - }); + const result = calculateReimbursableTotals( + { + ...emptyCalculation, + ministryCellPhone: 200, + ministryInternet: 200, + }, + FLOOR, + ); expect(result.monthlySubtotal).toBe(400); expect(result.total).toBe(400); expect(result.floorApplied).toBe(false); }); it('divides annual subtotal by 12 and applies the floor when result is below', () => { - const result = calculateReimbursableTotals({ - ...emptyCalculation, - conferenceRetreatCosts: 1200, - }); + const result = calculateReimbursableTotals( + { + ...emptyCalculation, + conferenceRetreatCosts: 1200, + }, + FLOOR, + ); expect(result.annualSubtotal).toBe(1200); expect(result.raw).toBe(100); - expect(result.total).toBe(REIMBURSABLE_FLOOR); + expect(result.total).toBe(FLOOR); expect(result.floorApplied).toBe(true); }); it('combines monthly and annual contributions above the floor', () => { - const result = calculateReimbursableTotals({ - ...emptyCalculation, - ministryCellPhone: 50, - ministryInternet: 50, - mpdNewsletter: 25, - mpdMiscellaneous: 25, - accountTransfers: 50, - otherMonthlyReimbursements: 50, - conferenceRetreatCosts: 600, - ministryTravelMeals: 600, - otherAnnualReimbursements: 0, - }); + const result = calculateReimbursableTotals( + { + ...emptyCalculation, + ministryCellPhone: 50, + ministryInternet: 50, + mpdNewsletter: 25, + mpdMiscellaneous: 25, + accountTransfers: 50, + otherMonthlyReimbursements: 50, + conferenceRetreatCosts: 600, + ministryTravelMeals: 600, + otherAnnualReimbursements: 0, + }, + FLOOR, + ); expect(result.monthlySubtotal).toBe(250); expect(result.annualSubtotal).toBe(1200); expect(result.raw).toBe(350); @@ -92,12 +108,24 @@ describe('calculateReimbursableTotals', () => { }); it('treats null fields as zero alongside non-null values', () => { + const result = calculateReimbursableTotals( + { + ...emptyCalculation, + ministryCellPhone: 500, + ministryInternet: null, + }, + FLOOR, + ); + expect(result.monthlySubtotal).toBe(500); + expect(result.total).toBe(500); + }); + + it('defaults the floor to zero when no floor is provided', () => { const result = calculateReimbursableTotals({ ...emptyCalculation, - ministryCellPhone: 500, - ministryInternet: null, + ministryCellPhone: 50, }); - expect(result.monthlySubtotal).toBe(500); - expect(result.total).toBe(500); + expect(result.total).toBe(50); + expect(result.floorApplied).toBe(false); }); }); diff --git a/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.ts b/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.ts index 6e4af57c9c..462d448376 100644 --- a/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.ts +++ b/src/components/Reports/PdsGoalCalculator/calculations/reimbursableExpenses.ts @@ -1,7 +1,5 @@ import { DesignationSupportCalculation } from 'src/graphql/types.generated'; -export const REIMBURSABLE_FLOOR = 300; - export type ReimbursableCalculationFields = Pick< DesignationSupportCalculation, | 'ministryCellPhone' @@ -25,6 +23,7 @@ export interface ReimbursableTotals { export const calculateReimbursableTotals = ( calculation: ReimbursableCalculationFields, + floor = 0, ): ReimbursableTotals => { const monthlySubtotal = (calculation.ministryCellPhone ?? 0) + @@ -38,12 +37,12 @@ export const calculateReimbursableTotals = ( (calculation.ministryTravelMeals ?? 0) + (calculation.otherAnnualReimbursements ?? 0); const raw = monthlySubtotal + annualSubtotal / 12; - const total = Math.max(REIMBURSABLE_FLOOR, raw); + const total = Math.max(floor, raw); return { monthlySubtotal, annualSubtotal, raw, total, - floorApplied: raw <= REIMBURSABLE_FLOOR, + floorApplied: raw <= floor, }; };