Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<PdsGoalCalculatorTestWrapper
withProvider={false}
Expand All @@ -17,7 +31,7 @@ describe('PdsGoalCard', () => {
</PdsGoalCalculatorTestWrapper>,
);

expect(await findByText('$849.44')).toBeInTheDocument();
expect(await findByText('$8,073.02')).toBeInTheDocument();
Comment thread
wjames111 marked this conversation as resolved.
});

it('builds the View link with the PDS goal calculator path', async () => {
Expand Down
29 changes: 8 additions & 21 deletions src/components/HrTools/PdsGoalCalculator/GoalCard/PdsGoalCard.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,23 +20,14 @@ export const PdsGoalCard: React.FC<PdsGoalCardProps> = ({ 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;
Comment thread
wjames111 marked this conversation as resolved.

const formType = goal.formType ?? DesignationSupportFormType.Detailed;
const formTypeBadge =
Expand All @@ -65,7 +52,7 @@ export const PdsGoalCard: React.FC<PdsGoalCardProps> = ({ 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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const PdsGoalCalculatorProvider: React.FC<Props> = ({ 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,19 +116,25 @@
{
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,
},
{
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)',
Comment thread
wjames111 marked this conversation as resolved.
{
rate: percentageFormat(constants.adminRate, locale),
},
),

Check warning on line 137 in src/components/HrTools/PdsGoalCalculator/SupportItem/otherBreakdown.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Large Method

buildOtherBreakdownRows increases from 102 to 108 lines of code, threshold = 100. Large functions with many lines of code are generally harder to understand and lower the code health. Avoid adding more lines to this function.
Comment thread
wjames111 marked this conversation as resolved.
amount: totals.assessment,
testId: 'other-assessment',
bold: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DataGrid } from '@mui/x-data-grid';

Check notice on line 1 in src/components/HrTools/PdsGoalCalculator/SupportItem/salaryBreakdown.test.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

✅ No longer an issue: Code Duplication

The module no longer contains too many functions with similar structure
import { render } from '@testing-library/react';
import { DesignationSupportSalaryType } from 'src/graphql/types.generated';
import i18n from 'src/lib/i18n';
Expand Down Expand Up @@ -49,7 +49,6 @@
expect(rows.map((row) => row.id)).toEqual([
'pay-rate',
'monthly-base',
'geographic-multiplier',
'gross-monthly-pay',
'employer-fica',
'total',
Expand All @@ -69,8 +68,6 @@
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
Expand Down Expand Up @@ -102,42 +99,6 @@
expect(byId['total']).toBeCloseTo(4680.0, 2);
});

it('includes the geographic location as a suffix on the multiplier amount and in the formula when set', () => {
const rows = buildSalaryBreakdownRows(
{ ...salariedCalculation, geographicLocation: 'Atlanta' },
constants,
'en-US',
i18n.t,
);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));

expect(byId['geographic-multiplier'].category).toBe(
'Geographic Multiplier',
);
expect(byId['geographic-multiplier'].amountSuffix).toBe('(Atlanta)');
expect(byId['gross-monthly-pay'].formula).toBe(
'Monthly Base × (1 + Geographic Multiplier (Atlanta))',
);
});

it('omits the location suffix when geographicLocation is null', () => {
const rows = buildSalaryBreakdownRows(
salariedCalculation,
constants,
'en-US',
i18n.t,
);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));

expect(byId['geographic-multiplier'].category).toBe(
'Geographic Multiplier',
);
expect(byId['geographic-multiplier'].amountSuffix).toBeUndefined();
expect(byId['gross-monthly-pay'].formula).toBe(
'Monthly Base × (1 + Geographic Multiplier)',
);
});

it('inserts hours-per-week and monthly-base rows for hourly', () => {
const rows = buildSalaryBreakdownRows(
hourlyCalculation,
Expand All @@ -149,7 +110,6 @@
'pay-rate',
'hours-per-week',
'monthly-base',
'geographic-multiplier',
'gross-monthly-pay',
'employer-fica',
'total',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,11 @@ const CategoryCellBox = styled(Box)({
height: '100%',
});

const AmountSuffix = styled('span')(({ theme }) => ({
marginLeft: theme.spacing(1),
}));

export interface SalaryBreakdownRow {
id: string;
category: string;
formula?: string;
amount: number;
amountSuffix?: string;
format: AmountFormat;
testId?: string;
}
Expand All @@ -44,23 +39,14 @@ export const buildSalaryBreakdownRows = (
t: TFunction,
): SalaryBreakdownRow[] => {
const { geographicMultiplier, employerFicaRate } = constants;
const { salaryOrHourly, geographicLocation } = calculation;
const { salaryOrHourly } = calculation;
const payRate = calculation.payRate ?? 0;
const hoursPerWeek = calculation.hoursWorkedPerWeek ?? 0;
const isSalaried = salaryOrHourly === DesignationSupportSalaryType.Salaried;

const { monthlyBase, grossMonthlyPay, employerFica, subtotal } =
calculateSalaryTotals(calculation, constants);

const geographicMultiplierSuffix = geographicLocation
? `(${geographicLocation})`
: undefined;
const grossMonthlyPayFormula = geographicLocation
? t('Monthly Base × (1 + Geographic Multiplier ({{location}}))', {
location: geographicLocation,
})
: t('Monthly Base × (1 + Geographic Multiplier)');

return [
{
id: 'pay-rate',
Expand All @@ -87,17 +73,12 @@ export const buildSalaryBreakdownRows = (
amount: monthlyBase,
format: 'currency',
},
{
id: 'geographic-multiplier',
category: t('Geographic Multiplier'),
amount: geographicMultiplier,
amountSuffix: geographicMultiplierSuffix,
format: 'percentage',
},
{
id: 'gross-monthly-pay',
category: t('Gross Monthly Pay'),
formula: grossMonthlyPayFormula,
formula: t('Monthly Base × {{rate}}', {
Comment thread
wjames111 marked this conversation as resolved.
rate: percentageFormat(1 + geographicMultiplier, locale),
Comment thread
wjames111 marked this conversation as resolved.
}),
amount: grossMonthlyPay,
format: 'currency',
testId: 'gross-monthly-pay',
Expand Down Expand Up @@ -150,19 +131,14 @@ export const buildSalaryBreakdownColumns = (
align: 'left',
headerAlign: 'left',
renderCell: (params: GridRenderCellParams<SalaryBreakdownRow>) => {
const { amount, amountSuffix, format, testId } = params.row;
const { amount, format, testId } = params.row;
const formatted =
format === 'currency'
? currencyFormat(amount, 'USD', locale)
: format === 'percentage'
? percentageFormat(amount, locale)
: numberFormat(amount, locale);
return (
<span data-testid={testId}>
{formatted}
{amountSuffix && <AmountSuffix>{amountSuffix}</AmountSuffix>}
</span>
);
return <span data-testid={testId}>{formatted}</span>;
},
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
wjames111 marked this conversation as resolved.
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', () => {
Expand All @@ -170,21 +177,22 @@ 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', () => {
const result = calculateOtherExpenses(partTime(), defaultConstants);
// 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);
});
});
});
Loading
Loading