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 };
};