diff --git a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx index 1f508b8339..61d189229e 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.test.tsx @@ -6,6 +6,20 @@ import { PdsGoalCalculatorTestWrapper } from '../PdsGoalCalculatorTestWrapper'; describe('PdsGoalCard', () => { it('renders the calculated PDS goal amount', async () => { + // Hand-derivation of $8,073.02 from the pinned defaults in + // PdsGoalCalculatorTestWrapper (payRate=50000 salaried full-time, benefits=1500, + // no geographic multiplier, all reimbursable fields=0, 403b=0): + // monthlyBase = 50000 / 12 = 4166.67 + // grossMonthlyPay = monthlyBase * 1 = 4166.67 (geo multiplier 0) + // employerFica = 4166.67 * 0.08 = 333.33 + // salarySubtotal = 4500 + // reimbursable = max(300, 0) = 300 (REIMBURSABLE_FLOOR) + // subtotal = 4500 + 300 + 0 (403b) + 0 (workComp) + 1500 (benefits) = 6300 + // attrition = 6300 * 0.06 = 378 + // creditCardFees = 6678 / 0.94 - 6678 ≈ 426.26 + // adminBase = 6300 + 378 + 426.26 ≈ 7104.26 + // assessment = 7104.26 / 0.88 - 7104.26 ≈ 968.76 + // overallTotal = 6300 + 378 + 426.26 + 968.76 ≈ 8073.02 const { findByText } = render( { , ); - expect(await findByText('$849.44')).toBeInTheDocument(); + expect(await findByText('$8,073.02')).toBeInTheDocument(); }); it('builds the View link with the PDS goal calculator path', async () => { diff --git a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx index 70c2b32d80..0d467561a2 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx @@ -1,19 +1,15 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { Chip } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { GoalCard } from 'src/components/Reports/Shared/GoalCard/GoalCard'; import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; -import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import { PdsGoalCalculationFieldsFragment, useDeletePdsGoalCalculationMutation, } from '../GoalsList/PdsGoalCalculations.generated'; import { useHcmUserQuery } from '../Shared/HCM.generated'; -import { - buildPdsGoalConstants, - calculatePdsGoalTotal, -} from '../calculations/calculatePdsGoalTotal'; +import { usePdsSummaryData } from '../calculations/usePdsSummaryData'; export interface PdsGoalCardProps { goal: PdsGoalCalculationFieldsFragment; @@ -24,23 +20,14 @@ export const PdsGoalCard: React.FC = ({ goal }) => { const accountListId = useAccountListId() ?? ''; const [deletePdsGoalCalculation] = useDeletePdsGoalCalculationMutation(); - const { - goalMiscConstants, - goalGeographicConstantMap, - loading: constantsLoading, - } = useGoalCalculatorConstants(); const { data: hcmData, loading: hcmLoading } = useHcmUserQuery(); const hcmUser = hcmData?.hcm[0]; - const goalTotal = useMemo(() => { - const constants = buildPdsGoalConstants( - goalMiscConstants, - goalGeographicConstantMap, - goal.geographicLocation, - hcmUser?.fourOThreeB, - ); - return constants ? calculatePdsGoalTotal(goal, constants) : 0; - }, [goal, goalMiscConstants, goalGeographicConstantMap, hcmUser]); + const { data: summaryData, loading: summaryLoading } = usePdsSummaryData( + goal, + hcmUser, + ); + const goalTotal = summaryData?.overallTotal ?? 0; const formType = goal.formType ?? DesignationSupportFormType.Detailed; const formTypeBadge = @@ -65,7 +52,7 @@ export const PdsGoalCard: React.FC = ({ goal }) => { name={goal.name} goalAmount={goalTotal} currency="USD" - loading={constantsLoading || hcmLoading} + loading={summaryLoading || hcmLoading} updatedAt={goal.updatedAt} viewHref={`/accountLists/${accountListId}/hrTools/pdsGoalCalculator/${goal.id}`} onDelete={handleDelete} diff --git a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx index ed9f228020..728bdb74f4 100644 --- a/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx +++ b/src/components/HrTools/PdsGoalCalculator/PdsGoalCalculatorTestWrapper.tsx @@ -47,8 +47,19 @@ const calculationsDefault = gqlMock< salaryOrHourly: DesignationSupportSalaryType.Salaried, payRate: 50000, hoursWorkedPerWeek: null, + averageHoursPerWeek: 0, benefits: 1500, geographicLocation: null, + ministryCellPhone: 0, + ministryInternet: 0, + mpdNewsletter: 0, + mpdMiscellaneous: 0, + accountTransfers: 0, + otherMonthlyReimbursements: 0, + conferenceRetreatCosts: 0, + ministryTravelMeals: 0, + otherAnnualReimbursements: 0, + designationSupportHoursItems: [], }, ], pageInfo: { @@ -80,8 +91,19 @@ const calculationDefault = gqlMock< salaryOrHourly: DesignationSupportSalaryType.Salaried, payRate: 50000, hoursWorkedPerWeek: null, + averageHoursPerWeek: 0, benefits: 1500, geographicLocation: null, + ministryCellPhone: 0, + ministryInternet: 0, + mpdNewsletter: 0, + mpdMiscellaneous: 0, + accountTransfers: 0, + otherMonthlyReimbursements: 0, + conferenceRetreatCosts: 0, + ministryTravelMeals: 0, + otherAnnualReimbursements: 0, + designationSupportHoursItems: [], }, }, variables: { id: 'goal-1' }, diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx index c96bb9025d..f2442c6947 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/PdsGoalCalculatorContext.tsx @@ -89,7 +89,7 @@ export const PdsGoalCalculatorProvider: React.FC = ({ children }) => { const { data: hcmData } = useHcmUserQuery(); const hcmUser = hcmData?.hcm[0]; - const summaryData = usePdsSummaryData(calculation, hcmUser); + const { data: summaryData } = usePdsSummaryData(calculation, hcmUser); // Track the user's place by step enum, not numeric index, so that a change // to the steps array (e.g. formType switch Detailed → Simple, dropping the diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx index 7937838ce1..7ff1638eb7 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx @@ -116,9 +116,12 @@ export const buildOtherBreakdownRows = ( { id: 'credit-card-fees', category: t('Credit Card Fees'), - formula: t('(Subtotal + Attrition) × {{rate}}', { - rate: percentageFormat(constants.creditCardFeeRate, locale), - }), + formula: t( + '(Subtotal + Attrition) ÷ (1 - {{rate}}) - (Subtotal + Attrition)', + { + rate: percentageFormat(constants.creditCardFeeRate, locale), + }, + ), amount: totals.creditCardFees, testId: 'other-credit-card-fees', bold: true, @@ -126,9 +129,12 @@ export const buildOtherBreakdownRows = ( { id: 'assessment', category: t('Assessment'), - formula: t('(Subtotal + Credit Card Fees + Attrition) × {{rate}}', { - rate: percentageFormat(constants.adminRate, locale), - }), + formula: t( + '(Subtotal + Attrition + Credit Card Fees) ÷ (1 − {{rate}}) − (Subtotal + Attrition + Credit Card Fees)', + { + rate: percentageFormat(constants.adminRate, locale), + }, + ), amount: totals.assessment, testId: 'other-assessment', bold: true, diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx index 41081d08eb..c12a0a874e 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx @@ -49,7 +49,6 @@ describe('buildSalaryBreakdownRows', () => { expect(rows.map((row) => row.id)).toEqual([ 'pay-rate', 'monthly-base', - 'geographic-multiplier', 'gross-monthly-pay', 'employer-fica', 'total', @@ -69,8 +68,6 @@ describe('buildSalaryBreakdownRows', () => { expect(byId['pay-rate']).toBe(60000); // monthlyBase = 60000 / 12 = 5000 expect(byId['monthly-base']).toBe(5000); - // geographicMultiplier passed through from constants - expect(byId['geographic-multiplier']).toBe(0); // grossMonthlyPay = 5000 * (1 + 0) = 5000 expect(byId['gross-monthly-pay']).toBe(5000); // employerFica = 5000 * 0.08 = 400 @@ -113,7 +110,6 @@ describe('buildSalaryBreakdownRows', () => { 'pay-rate', 'hours-per-week', 'monthly-base', - 'geographic-multiplier', 'gross-monthly-pay', 'employer-fica', 'total', diff --git a/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx b/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx index cd8138c7bc..80c3330382 100644 --- a/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx +++ b/src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.tsx @@ -73,16 +73,12 @@ export const buildSalaryBreakdownRows = ( amount: monthlyBase, format: 'currency', }, - { - id: 'geographic-multiplier', - category: t('Geographic Multiplier'), - amount: geographicMultiplier, - format: 'percentage', - }, { id: 'gross-monthly-pay', category: t('Gross Monthly Pay'), - formula: t('Monthly Base × (1 + Geographic Multiplier)'), + formula: t('Monthly Base × {{rate}}', { + rate: percentageFormat(1 + geographicMultiplier, locale), + }), amount: grossMonthlyPay, format: 'currency', testId: 'gross-monthly-pay', diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.test.ts index bc6f01250f..8b91b820ef 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.test.ts @@ -137,19 +137,26 @@ describe('calculateOtherExpenses', () => { }); describe('credit card fees', () => { - it('is 6% of (subtotal + attrition)', () => { + it('grosses up (subtotal + attrition) so that fees are `creditCardFeeRate` of the post-fees total', () => { const result = calculateOtherExpenses(fullTime(), defaultConstants); - // (7400 + 444) * 0.06 - expect(result.creditCardFees).toBeCloseTo(470.64); + // (7400 + 444) / (1 - 0.06) - (7400 + 444) ≈ 500.68 + expect(result.creditCardFees).toBeCloseTo(500.68); + }); + + it('returns 0 when creditCardFeeRate is 0', () => { + const result = calculateOtherExpenses(fullTime(), { + ...defaultConstants, + creditCardFeeRate: 0, + }); + expect(result.creditCardFees).toBe(0); }); }); describe('assessment', () => { - it('is (subtotal + creditCardFees + attrition) × adminRate', () => { + it('grosses up (subtotal + attrition + creditCardFees) so that admin is `adminRate` of the post-admin total', () => { const result = calculateOtherExpenses(fullTime(), defaultConstants); - // subtotal=7400, attrition=444, creditCardFees=470.64 - // (7400 + 470.64 + 444) * 0.12 ≈ 997.76 - expect(result.assessment).toBeCloseTo(997.76, 1); + // adminBase=7400+444+500.68=8344.68; assessment = adminBase/0.88 - adminBase ≈ 1137.91 + expect(result.assessment).toBeCloseTo(1137.91, 1); }); it('returns 0 when adminRate is 0', () => { @@ -170,8 +177,8 @@ describe('calculateOtherExpenses', () => { expect(result.benefits).toBe(1500); expect(result.subtotal).toBeCloseTo(7400); expect(result.attrition).toBeCloseTo(444); - expect(result.creditCardFees).toBeCloseTo(470.64); - expect(result.assessment).toBeCloseTo(997.76, 1); + expect(result.creditCardFees).toBeCloseTo(500.68); + expect(result.assessment).toBeCloseTo(1137.91, 1); }); it('produces correct totals for a part-time employee', () => { @@ -179,12 +186,13 @@ describe('calculateOtherExpenses', () => { // reimbursable=500, 403b=400, workComp=4000*0.17=680, benefits=0 // subtotal=5000+500+400+680+0=6580 // attrition=6580*0.06=394.80 - // creditCardFees=(6580+394.80)*0.06=418.49 - // assessment=(6580+418.49+394.80)*0.12≈887.19 + // creditCardFees=(6580+394.80)/(1-0.06)-(6580+394.80)≈445.20 + // adminBase=6580+445.20+394.80=7420 + // assessment = adminBase/0.88 - adminBase ≈ 1011.82 expect(result.subtotal).toBeCloseTo(6580); expect(result.attrition).toBeCloseTo(394.8); - expect(result.creditCardFees).toBeCloseTo(418.49, 1); - expect(result.assessment).toBeCloseTo(887.19, 1); + expect(result.creditCardFees).toBeCloseTo(445.2, 1); + expect(result.assessment).toBeCloseTo(1011.82, 1); }); }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.ts b/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.ts index c9240ae34b..2c87b99773 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/OtherExpenses.ts @@ -54,9 +54,13 @@ export const calculateOtherExpenses = ( benefits; const attrition = subtotal * constants.attritionRate; - const creditCardFees = (subtotal + attrition) * constants.creditCardFeeRate; - const adminRate = constants.adminRate; - const assessment = (subtotal + creditCardFees + attrition) * adminRate; + const creditCardFees = + (subtotal + attrition) / (1 - constants.creditCardFeeRate) - + (subtotal + attrition); + const adminBase = subtotal + attrition + creditCardFees; + // Admin assessment is `adminRate` of the post-admin total, not a markup on + // `adminBase`, so gross up: assessment / (adminBase + assessment) = adminRate. + const assessment = adminBase / (1 - constants.adminRate) - adminBase; return { reimbursableExpenses, diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/buildPdsGoalConstants.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/buildPdsGoalConstants.test.ts index 1a398f7b1e..939def989b 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/buildPdsGoalConstants.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/buildPdsGoalConstants.test.ts @@ -2,7 +2,7 @@ import { GoalGeographicConstantMap, GoalMiscConstants, } from 'src/hooks/useGoalCalculatorConstants'; -import { buildPdsGoalConstants } from './calculatePdsGoalTotal'; +import { buildPdsGoalConstants } from './pdsGoalConstants'; const makeConstant = (fee: number) => ({ fee, @@ -108,7 +108,7 @@ describe('buildPdsGoalConstants', () => { expect(result).toBeNull(); }); - it('returns geographicMultiplier of 0 for unknown location', () => { + it('defaults geographicMultiplier to 0 (no adjustment) for unknown location', () => { const result = buildPdsGoalConstants( buildMiscConstants(), defaultGeoMap, @@ -119,7 +119,7 @@ describe('buildPdsGoalConstants', () => { expect(result?.geographicMultiplier).toBe(0); }); - it('returns geographicMultiplier of 0 when location is null', () => { + it('defaults geographicMultiplier to 0 (no adjustment) when location is null', () => { const result = buildPdsGoalConstants( buildMiscConstants(), defaultGeoMap, @@ -130,7 +130,7 @@ describe('buildPdsGoalConstants', () => { expect(result?.geographicMultiplier).toBe(0); }); - it('returns geographicMultiplier of 0 when location is undefined', () => { + it('defaults geographicMultiplier to 0 (no adjustment) when location is undefined', () => { const result = buildPdsGoalConstants( buildMiscConstants(), defaultGeoMap, diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts deleted file mode 100644 index d5c613222a..0000000000 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { - DesignationSupportFormType, - DesignationSupportSalaryType, - DesignationSupportStatus, -} from 'src/graphql/types.generated'; -import { - PdsGoalTotalConstants, - PdsGoalTotalFields, - calculatePdsGoalTotal, -} from './calculatePdsGoalTotal'; - -const defaultConstants: PdsGoalTotalConstants = { - employerFicaRate: 0.08, - workCompPercentage: 0.17, - attritionRate: 0.06, - creditCardFeeRate: 0.06, - adminRate: 0.12, - fourOThreeBPercentage: 0.1, - geographicMultiplier: 0, -}; - -const makeGoal = ( - overrides: Partial = {}, -): PdsGoalTotalFields => ({ - hoursWorkedPerWeek: null, - salaryOrHourly: DesignationSupportSalaryType.Salaried, - status: DesignationSupportStatus.FullTime, - payRate: 60000, - benefits: 1500, - geographicLocation: null, - ministryCellPhone: 50, - ministryInternet: 50, - mpdNewsletter: 50, - mpdMiscellaneous: 50, - accountTransfers: 50, - otherMonthlyReimbursements: 50, - conferenceRetreatCosts: 0, - ministryTravelMeals: 0, - otherAnnualReimbursements: 0, - ...overrides, -}); - -describe('calculatePdsGoalTotal', () => { - it('computes the final assessment for a full-time salaried employee', () => { - const goal = makeGoal(); - const result = calculatePdsGoalTotal(goal, defaultConstants); - // assessment ≈ 1038.21 - expect(result).toBeCloseTo(1038.21, 1); - }); - - it('computes the final assessment for a part-time hourly employee', () => { - const goal = makeGoal({ - status: DesignationSupportStatus.PartTime, - salaryOrHourly: DesignationSupportSalaryType.Hourly, - payRate: 25, - hoursWorkedPerWeek: 20, - benefits: null, - }); - const result = calculatePdsGoalTotal(goal, defaultConstants); - expect(result).toBeCloseTo(434.83, 0); - }); - - it('returns a positive value when payRate is null', () => { - const goal = makeGoal({ payRate: null }); - const result = calculatePdsGoalTotal(goal, defaultConstants); - expect(result).toBeGreaterThan(0); - }); - - it('applies geographic multiplier', () => { - const goal = makeGoal(); - const withGeo = calculatePdsGoalTotal(goal, { - ...defaultConstants, - geographicMultiplier: 0.06, - }); - const withoutGeo = calculatePdsGoalTotal(goal, defaultConstants); - expect(withGeo).toBeGreaterThan(withoutGeo); - }); - - it('excludes reimbursable expenses and 403b when formType is Simple', () => { - const baseline = calculatePdsGoalTotal( - makeGoal({ formType: DesignationSupportFormType.Simple }), - { ...defaultConstants, fourOThreeBPercentage: 0.1 }, - ); - - const withDifferentReimbursables = calculatePdsGoalTotal( - makeGoal({ - formType: DesignationSupportFormType.Simple, - ministryCellPhone: 9999, - otherAnnualReimbursements: 9999, - }), - { ...defaultConstants, fourOThreeBPercentage: 0.1 }, - ); - expect(withDifferentReimbursables).toBeCloseTo(baseline); - - const withDifferent403b = calculatePdsGoalTotal( - makeGoal({ formType: DesignationSupportFormType.Simple }), - { ...defaultConstants, fourOThreeBPercentage: 0.5 }, - ); - expect(withDifferent403b).toBeCloseTo(baseline); - }); - - it('includes reimbursable expenses and 403b when formType is Detailed', () => { - const baseline = calculatePdsGoalTotal( - makeGoal({ formType: DesignationSupportFormType.Detailed }), - { ...defaultConstants, fourOThreeBPercentage: 0.1 }, - ); - - const withDifferentReimbursables = calculatePdsGoalTotal( - makeGoal({ - formType: DesignationSupportFormType.Detailed, - ministryCellPhone: 9999, - otherAnnualReimbursements: 9999, - }), - { ...defaultConstants, fourOThreeBPercentage: 0.1 }, - ); - expect(withDifferentReimbursables).toBeGreaterThan(baseline); - - const withDifferent403b = calculatePdsGoalTotal( - makeGoal({ formType: DesignationSupportFormType.Detailed }), - { ...defaultConstants, fourOThreeBPercentage: 0.5 }, - ); - expect(withDifferent403b).toBeGreaterThan(baseline); - }); - - it('treats null formType the same as Detailed (legacy goals)', () => { - const legacy = makeGoal({ formType: null }); - const detailed = makeGoal({ - formType: DesignationSupportFormType.Detailed, - }); - expect(calculatePdsGoalTotal(legacy, defaultConstants)).toBeCloseTo( - calculatePdsGoalTotal(detailed, defaultConstants), - ); - }); -}); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts b/src/components/HrTools/PdsGoalCalculator/calculations/pdsGoalConstants.ts similarity index 70% rename from src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts rename to src/components/HrTools/PdsGoalCalculator/calculations/pdsGoalConstants.ts index 36e9aad328..360d557f91 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/calculatePdsGoalTotal.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/pdsGoalConstants.ts @@ -4,24 +4,8 @@ import { GoalMiscConstants, } from 'src/hooks/useGoalCalculatorConstants'; import { HcmUserQuery } from '../Shared/HCM.generated'; -import { - OtherExpensesConstants, - OtherExpensesFields, - calculateOtherExpenses, -} from './OtherExpenses'; -import { - ReimbursableCalculationFields, - calculateReimbursableTotals, -} from './reimbursableExpenses'; -import { - SalaryCalculationFields, - SalaryTotals, - calculateSalaryTotals, -} from './salaryCalculation'; - -export type PdsGoalTotalFields = SalaryCalculationFields & - ReimbursableCalculationFields & - OtherExpensesFields; +import { OtherExpensesConstants } from './OtherExpenses'; +import { SalaryTotals } from './salaryCalculation'; export interface PdsGoalTotalConstants { employerFicaRate: number; @@ -59,7 +43,6 @@ export const buildPdsGoalConstants = ( ) { return null; } - const geographicMultiplier = goalGeographicConstantMap.get(geographicLocation ?? '') ?? 0; @@ -96,27 +79,3 @@ export const buildOtherExpensesConstants = ( adminRate: constants.adminRate, }; }; - -export const calculatePdsGoalTotal = ( - calculation: PdsGoalTotalFields, - constants: PdsGoalTotalConstants, -): number => { - const salaryTotals = calculateSalaryTotals(calculation, { - geographicMultiplier: constants.geographicMultiplier, - employerFicaRate: constants.employerFicaRate, - }); - - const reimbursableTotal = calculateReimbursableTotals(calculation).total; - - const otherExpenses = calculateOtherExpenses( - calculation, - buildOtherExpensesConstants( - calculation.formType ?? DesignationSupportFormType.Detailed, - constants, - salaryTotals, - reimbursableTotal, - ), - ); - - return otherExpenses.assessment; -}; diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/salaryCalculation.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/salaryCalculation.test.ts index c78088deed..4f3d2cabd6 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/salaryCalculation.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/salaryCalculation.test.ts @@ -39,12 +39,12 @@ describe('calculateSalaryTotals', () => { expect(result.grossMonthlyPay).toBe(5000); }); - it('applies geographic multiplier additively', () => { + it('applies geographic multiplier as a delta to the monthly base', () => { const result = calculateSalaryTotals(salaried(), { geographicMultiplier: GEO_MULTIPLIER, employerFicaRate: FICA_RATE, }); - // (60000 / 12) * 1.06 + // (60000 / 12) * (1 + 0.06) expect(result.grossMonthlyPay).toBeCloseTo(5300); }); @@ -72,7 +72,7 @@ describe('calculateSalaryTotals', () => { geographicMultiplier: GEO_MULTIPLIER, employerFicaRate: FICA_RATE, }); - // (25 * 40 * 52 / 12) * 1.06 + // (25 * 40 * 52 / 12) * (1 + 0.06) expect(result.grossMonthlyPay).toBeCloseTo(4593.333, 2); }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts index 9e58409a7c..9cb8a06714 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.test.ts @@ -17,10 +17,6 @@ import { PdsGoalCalculationFieldsFragmentDoc, } from '../GoalsList/PdsGoalCalculations.generated'; import { HcmUserDocument, HcmUserQuery } from '../Shared/HCM.generated'; -import { - PdsGoalTotalConstants, - calculatePdsGoalTotal, -} from './calculatePdsGoalTotal'; import { usePdsSummaryData } from './usePdsSummaryData'; jest.mock('src/hooks/useGoalCalculatorConstants'); @@ -141,7 +137,7 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(undefined, defaultHcmUser), ); - expect(result.current).toBeNull(); + expect(result.current.data).toBeNull(); }); it.each([ @@ -200,7 +196,7 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(defaultCalculation, defaultHcmUser), ); - expect(result.current).toBeNull(); + expect(result.current.data).toBeNull(); }); }); @@ -213,17 +209,17 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(calc, defaultHcmUser), ); - expect(result.current?.geographicMultiplier).toBe(GEO_MULTIPLIER); + expect(result.current.data?.geographicMultiplier).toBe(GEO_MULTIPLIER); }); - it('defaults to 0 when geographicLocation is null', () => { + it('defaults to 0 (no adjustment) when geographicLocation is null', () => { const { result } = renderHook(() => usePdsSummaryData(defaultCalculation, defaultHcmUser), ); - expect(result.current?.geographicMultiplier).toBe(0); + expect(result.current.data?.geographicMultiplier).toBe(0); }); - it('defaults to 0 when geographicLocation is not in the map', () => { + it('defaults to 0 (no adjustment) when geographicLocation is not in the map', () => { const calc = { ...defaultCalculation, geographicLocation: 'Unknown City', @@ -231,7 +227,7 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(calc, defaultHcmUser), ); - expect(result.current?.geographicMultiplier).toBe(0); + expect(result.current.data?.geographicMultiplier).toBe(0); }); }); @@ -244,7 +240,7 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(calc, defaultHcmUser), ); - expect(result.current?.salaryConstants).toEqual({ + expect(result.current.data?.salaryConstants).toEqual({ geographicMultiplier: GEO_MULTIPLIER, employerFicaRate: EMPLOYER_FICA_RATE, }); @@ -257,16 +253,16 @@ describe('usePdsSummaryData', () => { usePdsSummaryData(defaultCalculation, defaultHcmUser), ); // (5 + 3) / 100 = 0.08 - expect(result.current?.otherConstants.fourOThreeBPercentage).toBeCloseTo( - 0.08, - ); + expect( + result.current.data?.otherConstants.fourOThreeBPercentage, + ).toBeCloseTo(0.08); }); it('defaults to 0 when hcmUser is undefined', () => { const { result } = renderHook(() => usePdsSummaryData(defaultCalculation, undefined), ); - expect(result.current?.otherConstants.fourOThreeBPercentage).toBe(0); + expect(result.current.data?.otherConstants.fourOThreeBPercentage).toBe(0); }); it('defaults to 0 when contribution percentages are null', () => { @@ -281,7 +277,7 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(defaultCalculation, hcmUser), ); - expect(result.current?.otherConstants.fourOThreeBPercentage).toBe(0); + expect(result.current.data?.otherConstants.fourOThreeBPercentage).toBe(0); }); }); @@ -290,7 +286,7 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(defaultCalculation, defaultHcmUser), ); - const data = result.current!; + const data = result.current.data!; const expected = data.otherTotals.subtotal + data.otherTotals.attrition + @@ -300,8 +296,8 @@ describe('usePdsSummaryData', () => { }); it('computes correct overallTotal for a full-time salaried employee', () => { - // No geographic multiplier, payRate = 60000 - // grossMonthlyPay = 60000 / 12 = 5000 + // Geographic multiplier defaults to 0 (no adjustment), payRate = 60000 + // grossMonthlyPay = 60000 / 12 * (1 + 0) = 5000 // employerFica = 5000 * 0.08 = 400 // salarySubtotal = 5400 // @@ -313,13 +309,82 @@ describe('usePdsSummaryData', () => { // workComp = 0 (full-time) // otherSubtotal = 5400 + 500 + 400 + 0 + 1500 = 7800 // attrition = 7800 * 0.06 = 468 - // creditCardFees = (7800 + 468) * 0.06 = 496.08 - // assessment = (7800 + 468 + 496.08) * 0.12 = 1051.69 - // overallTotal = 7800 + 468 + 496.08 + 1051.69 = 9815.77 + // creditCardFees = (7800 + 468) / (1 - 0.06) - (7800 + 468) ≈ 527.74 + // adminBase = 7800 + 468 + 527.74 ≈ 8795.74 + // assessment = adminBase / 0.88 - adminBase ≈ 1199.42 + // overallTotal = 7800 + 468 + 527.74 + 1199.42 ≈ 9995.16 const { result } = renderHook(() => usePdsSummaryData(defaultCalculation, defaultHcmUser), ); - expect(result.current?.overallTotal).toBeCloseTo(9815.77, 0); + expect(result.current.data?.overallTotal).toBeCloseTo(9995.16, 0); + }); + + it('computes correct overallTotal for a part-time hourly employee', () => { + // No geographic multiplier (defaults to 0), payRate = 25, hours = 20 + // monthlyBase = (25 * 20 * 52) / 12 = 2166.667 + // grossMonthlyPay = 2166.667 (no geo) + // employerFica = 2166.667 * 0.08 = 173.333 + // salarySubtotal = 2340 + // + // Reimbursable: monthly = 400, annual = 1200, raw = 500, above floor + // + // 403b = 2166.667 * 0.08 = 173.333 + // workComp = 2166.667 * 0.17 = 368.333 (part-time) + // benefits = 0 (part-time, ignores calculation.benefits) + // subtotal = 2340 + 500 + 173.333 + 368.333 + 0 = 3381.667 + // attrition = 3381.667 * 0.06 = 202.9 + // creditCardFees = (3381.667 + 202.9) / (1 - 0.06) - (3381.667 + 202.9) ≈ 228.80 + // adminBase ≈ 3813.37 + // assessment = adminBase / (1 - 0.12) - adminBase ≈ 520.00 + // overallTotal ≈ 3381.667 + 202.9 + 228.80 + 520.00 ≈ 4333.37 + const calc = { + ...defaultCalculation, + salaryOrHourly: DesignationSupportSalaryType.Hourly, + status: DesignationSupportStatus.PartTime, + payRate: 25, + hoursWorkedPerWeek: 20, + benefits: null, + }; + const { result } = renderHook(() => + usePdsSummaryData(calc, defaultHcmUser), + ); + expect(result.current.data?.overallTotal).toBeCloseTo(4333.37, 0); + expect(result.current.data?.otherTotals.workComp).toBeCloseTo(368.33, 2); + expect(result.current.data?.otherTotals.benefits).toBe(0); + }); + + it('does not NaN-cascade when payRate is null', () => { + // Guards against a regression where a null payRate would propagate + // through the gross-up formulas (x / (1 - rate)) and produce NaN. + const calc = { + ...defaultCalculation, + payRate: null, + }; + const { result } = renderHook(() => + usePdsSummaryData(calc, defaultHcmUser), + ); + const data = result.current.data!; + expect(data.salaryTotals.grossMonthlyPay).toBe(0); + expect(Number.isFinite(data.otherTotals.creditCardFees)).toBe(true); + expect(Number.isFinite(data.otherTotals.assessment)).toBe(true); + expect(Number.isFinite(data.overallTotal)).toBe(true); + }); + + it('applies the geographic multiplier to the overall total', () => { + const { result: baseline } = renderHook(() => + usePdsSummaryData(defaultCalculation, defaultHcmUser), + ); + const { result: withGeo } = renderHook(() => + usePdsSummaryData( + { ...defaultCalculation, geographicLocation: 'Orlando, FL' }, + defaultHcmUser, + ), + ); + expect(baseline.current.data?.geographicMultiplier).toBe(0); + expect(withGeo.current.data?.geographicMultiplier).toBe(GEO_MULTIPLIER); + expect(withGeo.current.data!.overallTotal).toBeGreaterThan( + baseline.current.data!.overallTotal, + ); }); }); @@ -328,7 +393,7 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(defaultCalculation, defaultHcmUser), ); - const data = result.current!; + const data = result.current.data!; expect(data).toHaveProperty('salaryTotals'); expect(data).toHaveProperty('salaryConstants'); expect(data).toHaveProperty('reimbursableTotals'); @@ -348,12 +413,12 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(calc, defaultHcmUser), ); - expect(result.current?.otherConstants.reimbursableTotal).toBe(0); - expect(result.current?.otherConstants.fourOThreeBPercentage).toBe(0); + expect(result.current.data?.otherConstants.reimbursableTotal).toBe(0); + expect(result.current.data?.otherConstants.fourOThreeBPercentage).toBe(0); // The reimbursableTotals object is still computed (data isn't lost), // but the otherConstants.reimbursableTotal that feeds the Other Expenses // calculation is zeroed. - expect(result.current?.reimbursableTotals.total).toBeGreaterThan(0); + expect(result.current.data?.reimbursableTotals.total).toBeGreaterThan(0); }); it('uses saved reimbursable + 403b values when formType is Detailed', () => { @@ -364,12 +429,12 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(calc, defaultHcmUser), ); - expect(result.current?.otherConstants.reimbursableTotal).toBeGreaterThan( - 0, - ); - expect(result.current?.otherConstants.fourOThreeBPercentage).toBeCloseTo( - 0.08, - ); + expect( + result.current.data?.otherConstants.reimbursableTotal, + ).toBeGreaterThan(0); + expect( + result.current.data?.otherConstants.fourOThreeBPercentage, + ).toBeCloseTo(0.08); }); it('uses saved values when formType is null/undefined (legacy goals default to Detailed behavior)', () => { @@ -380,42 +445,12 @@ describe('usePdsSummaryData', () => { const { result } = renderHook(() => usePdsSummaryData(calc, defaultHcmUser), ); - expect(result.current?.otherConstants.reimbursableTotal).toBeGreaterThan( - 0, - ); - expect(result.current?.otherConstants.fourOThreeBPercentage).toBeCloseTo( - 0.08, - ); + expect( + result.current.data?.otherConstants.reimbursableTotal, + ).toBeGreaterThan(0); + expect( + result.current.data?.otherConstants.fourOThreeBPercentage, + ).toBeCloseTo(0.08); }); }); - - describe('consistency with calculatePdsGoalTotal', () => { - // Mirrors what buildPdsGoalConstants would derive from the mocked - // useGoalCalculatorConstants + defaultHcmUser, so we can call - // calculatePdsGoalTotal directly without the hook. - const directConstants: PdsGoalTotalConstants = { - employerFicaRate: EMPLOYER_FICA_RATE, - workCompPercentage: WORK_COMP_PERCENTAGE, - attritionRate: ATTRITION_RATE, - creditCardFeeRate: CREDIT_CARD_FEE_RATE, - adminRate: ADMIN_RATE, - fourOThreeBPercentage: 0.08, - geographicMultiplier: 0, - }; - - it.each([ - DesignationSupportFormType.Detailed, - DesignationSupportFormType.Simple, - ])( - 'calculatePdsGoalTotal matches usePdsSummaryData.otherTotals.assessment when formType is %s', - (formType) => { - const calc = { ...defaultCalculation, formType }; - const { result } = renderHook(() => - usePdsSummaryData(calc, defaultHcmUser), - ); - const direct = calculatePdsGoalTotal(calc, directConstants); - expect(direct).toBeCloseTo(result.current!.otherTotals.assessment, 5); - }, - ); - }); }); diff --git a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts index 0dc642c228..a6ed2e47d5 100644 --- a/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts +++ b/src/components/HrTools/PdsGoalCalculator/calculations/usePdsSummaryData.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { ApolloError } from '@apollo/client'; import { DesignationSupportFormType } from 'src/graphql/types.generated'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import { PdsGoalCalculationFieldsFragment } from '../GoalsList/PdsGoalCalculations.generated'; @@ -11,7 +12,7 @@ import { import { buildOtherExpensesConstants, buildPdsGoalConstants, -} from './calculatePdsGoalTotal'; +} from './pdsGoalConstants'; import { ReimbursableTotals, calculateReimbursableTotals, @@ -32,14 +33,20 @@ export interface PdsSummaryData { geographicMultiplier: number; } +export interface UsePdsSummaryDataResult { + data: PdsSummaryData | null; + loading: boolean; + error: ApolloError | undefined; +} + export const usePdsSummaryData = ( calculation: PdsGoalCalculationFieldsFragment | undefined, hcmUser: HcmUserQuery['hcm'][number] | undefined, -): PdsSummaryData | null => { - const { goalMiscConstants, goalGeographicConstantMap } = +): UsePdsSummaryDataResult => { + const { goalMiscConstants, goalGeographicConstantMap, loading, error } = useGoalCalculatorConstants(); - return useMemo(() => { + const data = useMemo(() => { if (!calculation) { return null; } @@ -85,4 +92,6 @@ export const usePdsSummaryData = ( geographicMultiplier: constants.geographicMultiplier, }; }, [calculation, hcmUser, goalMiscConstants, goalGeographicConstantMap]); + + return { data, loading, error }; };