Fixed tax implementation in simulator, milestone impacts.

This commit is contained in:
Josh 2025-04-22 12:10:59 +00:00
parent 253dbee9fe
commit 8d1dcf26b9
4 changed files with 470 additions and 337 deletions

View File

@ -3,7 +3,7 @@ import React, { useEffect, useState, useCallback } from 'react';
const today = new Date();
const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView }) => {
const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView, onMilestoneUpdated }) => {
const [milestones, setMilestones] = useState({ Career: [], Financial: [] });
// The "new or edit" milestone form state
@ -148,6 +148,7 @@ const MilestoneTimeline = ({ careerPathId, authFetch, activeView, setActiveView
alert(errorData.error || 'Error saving milestone');
return;
}
if (onMilestoneUpdated) onMilestoneUpdated();
const savedMilestone = await res.json();
console.log('Milestone saved/updated:', savedMilestone);

View File

@ -1,51 +1,74 @@
// src/components/MilestoneTracker.js
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
import { Line } from 'react-chartjs-2';
import { Chart as ChartJS, LineElement, CategoryScale, LinearScale, PointElement, Tooltip, Legend } from 'chart.js';
import {
Chart as ChartJS,
LineElement,
CategoryScale,
LinearScale,
PointElement,
Tooltip,
Legend
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { Filler } from 'chart.js';
import authFetch from '../utils/authFetch.js';
import CareerSelectDropdown from './CareerSelectDropdown.js';
import CareerSearch from './CareerSearch.js';
import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js';
import ScenarioEditModal from './ScenarioEditModal.js';
import './MilestoneTracker.css';
import './MilestoneTimeline.css';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import ScenarioEditModal from './ScenarioEditModal.js';
ChartJS.register(LineElement, CategoryScale, LinearScale, Filler, PointElement, Tooltip, Legend, annotationPlugin);
ChartJS.register(
LineElement,
CategoryScale,
LinearScale,
Filler,
PointElement,
Tooltip,
Legend,
annotationPlugin
);
const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const location = useLocation();
const navigate = useNavigate();
const apiURL = process.env.REACT_APP_API_URL;
// -------------------------
// State
// -------------------------
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
const [careerPathId, setCareerPathId] = useState(null);
const [existingCareerPaths, setExistingCareerPaths] = useState([]);
const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
const [activeView, setActiveView] = useState("Career");
// Store each profile separately
const [financialProfile, setFinancialProfile] = useState(null);
const [collegeProfile, setCollegeProfile] = useState(null);
// For the chart
const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
const [showEditModal, setShowEditModal] = useState(false);
const apiURL = process.env.REACT_APP_API_URL;
// Possibly loaded from location.state
const { projectionData: initialProjectionData = [], loanPayoffMonth: initialLoanPayoffMonth = null } = location.state || {};
const {
projectionData: initialProjectionData = [],
loanPayoffMonth: initialLoanPayoffMonth = null
} = location.state || {};
// ----------------------------
// 1. Fetch career paths + financialProfile
// ----------------------------
// -------------------------
// 1. Fetch career paths + financialProfile on mount
// -------------------------
useEffect(() => {
const fetchCareerPaths = async () => {
const res = await authFetch(`${apiURL}/premium/career-profile/all`);
@ -58,6 +81,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
setSelectedCareer(fromPopout);
setCareerPathId(fromPopout.career_path_id);
} else if (!selectedCareer) {
// Try to fetch the latest
const latest = await authFetch(`${apiURL}/premium/career-profile/latest`);
if (latest && latest.ok) {
const latestData = await latest.json();
@ -81,9 +105,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
fetchFinancialProfile();
}, [apiURL, location.state, selectedCareer]);
// ----------------------------
// -------------------------
// 2. Fetch the college profile for the selected careerPathId
// ----------------------------
// -------------------------
useEffect(() => {
if (!careerPathId) {
setCollegeProfile(null);
@ -91,109 +115,234 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
}
const fetchCollegeProfile = async () => {
// If you have a route like GET /api/premium/college-profile?careerPathId=XYZ
const res = await authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`);
if (!res || !res.ok) {
setCollegeProfile(null);
return;
}
const data = await res.json();
setCollegeProfile(data); // could be an object or empty {}
setCollegeProfile(data);
};
fetchCollegeProfile();
}, [careerPathId, apiURL]);
// ----------------------------
// 3. Merge data + simulate once both profiles + selectedCareer are loaded
// ----------------------------
// -------------------------
// 3. Initial simulation when profiles + career loaded
// (But this does NOT update after milestone changes yet)
// -------------------------
useEffect(() => {
if (!financialProfile || !collegeProfile || !selectedCareer) return;
console.log("About to build mergedProfile");
console.log("collegeProfile from DB/fetch = ", collegeProfile);
console.log(
"college_enrollment_status check:",
"[" + collegeProfile.college_enrollment_status + "]",
"length=", collegeProfile.college_enrollment_status?.length
);
console.log(
"Comparison => ",
collegeProfile.college_enrollment_status === 'currently_enrolled'
);
if (!financialProfile || !collegeProfile || !selectedCareer || !careerPathId) return;
// 1) Fetch the raw milestones for this careerPath
(async () => {
try {
const milRes = await authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`);
if (!milRes.ok) {
console.error('Failed to fetch initial milestones');
return;
}
const milestonesData = await milRes.json();
const allMilestones = milestonesData.milestones || [];
// 2) For each milestone, fetch impacts
const impactPromises = allMilestones.map((m) =>
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => data?.impacts || [])
.catch((err) => {
console.error('Failed fetching impacts for milestone', m.id, err);
return [];
})
);
const impactsForEach = await Promise.all(impactPromises);
const milestonesWithImpacts = allMilestones.map((m, i) => ({
...m,
impacts: impactsForEach[i] || [],
}));
// 3) Flatten them
const allImpacts = milestonesWithImpacts.flatMap((m) => m.impacts || []);
// 4) Build the mergedProfile (like you already do)
const mergedProfile = {
// From financialProfile
currentSalary: financialProfile.current_salary || 0,
monthlyExpenses: financialProfile.monthly_expenses || 0,
monthlyDebtPayments: financialProfile.monthly_debt_payments || 0,
retirementSavings: financialProfile.retirement_savings || 0,
emergencySavings: financialProfile.emergency_fund || 0,
monthlyRetirementContribution: financialProfile.retirement_contribution || 0,
monthlyEmergencyContribution: financialProfile.emergency_contribution || 0,
surplusEmergencyAllocation: financialProfile.extra_cash_emergency_pct || 50,
surplusRetirementAllocation: financialProfile.extra_cash_retirement_pct || 50,
// From collegeProfile
studentLoanAmount: collegeProfile.existing_college_debt || 0,
interestRate: collegeProfile.interest_rate || 5,
loanTerm: collegeProfile.loan_term || 10,
loanDeferralUntilGraduation: !!collegeProfile.loan_deferral_until_graduation,
academicCalendar: collegeProfile.academic_calendar || 'monthly',
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
calculatedTuition: collegeProfile.tuition || 0,
extraPayment: collegeProfile.extra_payment || 0,
inCollege:
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
collegeProfile.college_enrollment_status === 'prospective_student',
gradDate: collegeProfile.expected_graduation || null,
programType: collegeProfile.program_type,
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
hoursCompleted: collegeProfile.hours_completed || 0,
programLength: collegeProfile.program_length || 0,
startDate: new Date().toISOString(),
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
// The key: impacts
milestoneImpacts: allImpacts
};
// 5) Run the simulation
const { projectionData: initialProjData, loanPaidOffMonth: payoff } =
simulateFinancialProjection(mergedProfile);
let cumulativeSavings = mergedProfile.emergencySavings || 0;
const finalData = initialProjData.map((month) => {
cumulativeSavings += (month.netSavings || 0);
return { ...month, cumulativeNetSavings: cumulativeSavings };
});
setProjectionData(finalData);
setLoanPayoffMonth(payoff);
} catch (err) {
console.error('Error fetching initial milestones/impacts or simulating:', err);
}
})();
}, [financialProfile, collegeProfile, selectedCareer, careerPathId]);
// Merge financial + college data
const mergedProfile = {
// From financialProfile
currentSalary: financialProfile.current_salary || 0,
monthlyExpenses: financialProfile.monthly_expenses || 0,
monthlyDebtPayments: financialProfile.monthly_debt_payments || 0,
retirementSavings: financialProfile.retirement_savings || 0,
emergencySavings: financialProfile.emergency_fund || 0,
monthlyRetirementContribution: financialProfile.retirement_contribution || 0,
monthlyEmergencyContribution: financialProfile.emergency_contribution || 0,
surplusEmergencyAllocation: financialProfile.extra_cash_emergency_pct || 50,
surplusRetirementAllocation: financialProfile.extra_cash_retirement_pct || 50,
// -------------------------------------------------
// 4. reSimulate() => re-fetch everything (financial, college, milestones & impacts),
// re-run the simulation. This is triggered AFTER user updates a milestone in MilestoneTimeline.
// -------------------------------------------------
const reSimulate = async () => {
if (!careerPathId) return;
// From collegeProfile
studentLoanAmount: collegeProfile.existing_college_debt || 0,
interestRate: collegeProfile.interest_rate || 5,
loanTerm: collegeProfile.loan_term || 10,
loanDeferralUntilGraduation: !!collegeProfile.loan_deferral_until_graduation,
academicCalendar: collegeProfile.academic_calendar || 'monthly',
annualFinancialAid: collegeProfile.annual_financial_aid || 0,
calculatedTuition: collegeProfile.tuition || 0,
extraPayment: collegeProfile.extra_payment || 0,
partTimeIncome: 0, // or collegeProfile.part_time_income if you store it
gradDate: collegeProfile.expected_graduation || null,
programType: collegeProfile.program_type,
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
hoursCompleted: collegeProfile.hours_completed || 0,
programLength: collegeProfile.program_length || 0,
try {
// 1) Fetch financial + college + raw milestones
const [finResp, colResp, milResp] = await Promise.all([
authFetch(`${apiURL}/premium/financial-profile`),
authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`),
authFetch(`${apiURL}/premium/milestones?careerPathId=${careerPathId}`)
]);
// Are they in college?
inCollege: (collegeProfile.college_enrollment_status === 'currently_enrolled' ||
collegeProfile.college_enrollment_status === 'prospective_student'),
// If they've graduated or not in college, false
startDate: new Date().toISOString(),
// Future logic could set expectedSalary if there's a difference
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
};
if (!finResp.ok || !colResp.ok || !milResp.ok) {
console.error('One reSimulate fetch failed:', finResp.status, colResp.status, milResp.status);
return;
}
const result = simulateFinancialProjection(mergedProfile);
console.log("mergedProfile for simulation:", mergedProfile);
const [updatedFinancial, updatedCollege, milestonesData] = await Promise.all([
finResp.json(),
colResp.json(),
milResp.json()
]);
const { projectionData, loanPaidOffMonth } = result;
// If you want to accumulate net savings:
let cumulativeSavings = mergedProfile.emergencySavings || 0;
const cumulativeProjectionData = projectionData.map(month => {
cumulativeSavings += (month.netSavings || 0);
return { ...month, cumulativeNetSavings: cumulativeSavings };
});
// 2) For each milestone, fetch its impacts separately (if not already included)
const allMilestones = milestonesData.milestones || [];
const impactsPromises = allMilestones.map(m =>
authFetch(`${apiURL}/premium/milestone-impacts?milestone_id=${m.id}`)
.then(r => r.ok ? r.json() : null)
.then(data => data?.impacts || [])
.catch(err => {
console.error('Failed fetching impacts for milestone', m.id, err);
return [];
})
);
if (cumulativeProjectionData.length > 0) {
setProjectionData(cumulativeProjectionData);
setLoanPayoffMonth(loanPaidOffMonth);
const impactsForEach = await Promise.all(impactsPromises);
// Merge them onto the milestone array if desired
const milestonesWithImpacts = allMilestones.map((m, i) => ({
...m,
impacts: impactsForEach[i] || []
}));
// Flatten or gather all impacts if your simulation function needs them
const allImpacts = milestonesWithImpacts.flatMap(m => m.impacts || []);
// 3) Build mergedProfile
const mergedProfile = {
// From updatedFinancial
currentSalary: updatedFinancial.current_salary || 0,
monthlyExpenses: updatedFinancial.monthly_expenses || 0,
monthlyDebtPayments: updatedFinancial.monthly_debt_payments || 0,
retirementSavings: updatedFinancial.retirement_savings || 0,
emergencySavings: updatedFinancial.emergency_fund || 0,
monthlyRetirementContribution: updatedFinancial.retirement_contribution || 0,
monthlyEmergencyContribution: updatedFinancial.emergency_contribution || 0,
surplusEmergencyAllocation: updatedFinancial.extra_cash_emergency_pct || 50,
surplusRetirementAllocation: updatedFinancial.extra_cash_retirement_pct || 50,
// From updatedCollege
studentLoanAmount: updatedCollege.existing_college_debt || 0,
interestRate: updatedCollege.interest_rate || 5,
loanTerm: updatedCollege.loan_term || 10,
loanDeferralUntilGraduation: !!updatedCollege.loan_deferral_until_graduation,
academicCalendar: updatedCollege.academic_calendar || 'monthly',
annualFinancialAid: updatedCollege.annual_financial_aid || 0,
calculatedTuition: updatedCollege.tuition || 0,
extraPayment: updatedCollege.extra_payment || 0,
inCollege:
updatedCollege.college_enrollment_status === 'currently_enrolled' ||
updatedCollege.college_enrollment_status === 'prospective_student',
gradDate: updatedCollege.expected_graduation || null,
programType: updatedCollege.program_type,
creditHoursPerYear: updatedCollege.credit_hours_per_year || 0,
hoursCompleted: updatedCollege.hours_completed || 0,
programLength: updatedCollege.program_length || 0,
startDate: new Date().toISOString(),
expectedSalary: updatedCollege.expected_salary || updatedFinancial.current_salary,
// The key: pass the impacts to the simulation if needed
milestoneImpacts: allImpacts
};
// 4) Re-run simulation
const { projectionData: newProjData, loanPaidOffMonth: payoff } =
simulateFinancialProjection(mergedProfile);
// 5) If you track cumulative net savings:
let cumulativeSavings = mergedProfile.emergencySavings || 0;
const finalData = newProjData.map(month => {
cumulativeSavings += (month.netSavings || 0);
return { ...month, cumulativeNetSavings: cumulativeSavings };
});
// 6) Update states => triggers chart refresh
setProjectionData(finalData);
setLoanPayoffMonth(payoff);
// Optionally store the new profiles in state if you like
setFinancialProfile(updatedFinancial);
setCollegeProfile(updatedCollege);
console.log('Re-simulated after Milestone update!', {
mergedProfile,
milestonesWithImpacts
});
} catch (err) {
console.error('Error in reSimulate:', err);
}
};
console.log('mergedProfile for simulation:', mergedProfile);
}, [financialProfile, collegeProfile, selectedCareer]);
// 4. The rest of your code is unchanged, e.g. handleConfirmCareerSelection, etc.
// ...
// ...
// The rest of your component logic
// ...
console.log(
'First 5 items of projectionData:',
Array.isArray(projectionData) ? projectionData.slice(0, 5) : 'projectionData not yet available'
);
// ...
// The remainder of your component: timeline, chart, AISuggestedMilestones, etc.
// ...
return (
<div className="milestone-tracker">
<CareerSelectDropdown
@ -207,11 +356,13 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch}
/>
{/* Pass reSimulate as onMilestoneUpdated: */}
<MilestoneTimeline
careerPathId={careerPathId}
authFetch={authFetch}
activeView={activeView}
setActiveView={setActiveView}
onMilestoneUpdated={reSimulate}
/>
<AISuggestedMilestones
@ -227,19 +378,19 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
<Line
data={{
labels: projectionData.map(p => p.month),
labels: projectionData.map((p) => p.month),
datasets: [
{
label: 'Total Savings',
data: projectionData.map(p => p.cumulativeNetSavings),
data: projectionData.map((p) => p.cumulativeNetSavings),
borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: 'rgba(54, 162, 235, 0.2)',
tension: 0.4,
fill: true,
fill: true
},
{
label: 'Loan Balance',
data: projectionData.map(p => p.loanBalance),
data: projectionData.map((p) => p.loanBalance),
borderColor: 'rgba(255, 99, 132, 1)',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
tension: 0.4,
@ -251,7 +402,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
},
{
label: 'Retirement Savings',
data: projectionData.map(p => p.retirementSavings),
data: projectionData.map((p) => p.retirementSavings),
borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4,
@ -308,23 +459,23 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch}
/>
{/* SCENARIO EDIT MODAL */}
<ScenarioEditModal
show={showEditModal}
onClose={() => setShowEditModal(false)}
financialProfile={financialProfile}
setFinancialProfile={setFinancialProfile}
collegeProfile={collegeProfile}
setCollegeProfile={setCollegeProfile}
apiURL={apiURL}
authFetch={authFetch}
/>
<ScenarioEditModal
show={showEditModal}
onClose={() => setShowEditModal(false)}
financialProfile={financialProfile}
setFinancialProfile={setFinancialProfile}
collegeProfile={collegeProfile}
setCollegeProfile={setCollegeProfile}
apiURL={apiURL}
authFetch={authFetch}
/>
{pendingCareerForModal && (
<button onClick={() => {
// handleConfirmCareerSelection logic
}}>
<button
onClick={() => {
// handleConfirmCareerSelection logic
}}
>
Confirm Career Change to {pendingCareerForModal}
</button>
)}

View File

@ -1,67 +1,23 @@
import moment from 'moment';
/**
* Single-filer federal tax calculation (2023).
* Includes standard deduction ($13,850).
*/
/***************************************************
* HELPER: Approx State Tax Rates
***************************************************/
const APPROX_STATE_TAX_RATES = {
AL: 0.05,
AK: 0.00,
AZ: 0.025,
AR: 0.05,
CA: 0.07,
CO: 0.045,
CT: 0.055,
DE: 0.05,
FL: 0.00,
GA: 0.05,
HI: 0.06,
ID: 0.058,
IL: 0.05,
IN: 0.035,
IA: 0.05,
KS: 0.05,
KY: 0.05,
LA: 0.04,
ME: 0.055,
MD: 0.05,
MA: 0.05,
MI: 0.0425,
MN: 0.06,
MS: 0.04,
MO: 0.05,
MT: 0.05,
NE: 0.05,
NV: 0.00,
NH: 0.00, // ignoring interest/dividend nuance
NJ: 0.057,
NM: 0.045,
NY: 0.06,
NC: 0.0475,
ND: 0.02,
OH: 0.04,
OK: 0.045,
OR: 0.07,
PA: 0.03,
RI: 0.045,
SC: 0.04,
SD: 0.00,
TN: 0.00,
TX: 0.00,
UT: 0.045,
VT: 0.055,
VA: 0.05,
WA: 0.00,
WV: 0.05,
WI: 0.05,
WY: 0.00,
DC: 0.05
AL: 0.05, AK: 0.00, AZ: 0.025, AR: 0.05, CA: 0.07, CO: 0.045, CT: 0.055, DE: 0.05,
FL: 0.00, GA: 0.05, HI: 0.06, ID: 0.058, IL: 0.05, IN: 0.035, IA: 0.05, KS: 0.05,
KY: 0.05, LA: 0.04, ME: 0.055, MD: 0.05, MA: 0.05, MI: 0.0425, MN: 0.06, MS: 0.04,
MO: 0.05, MT: 0.05, NE: 0.05, NV: 0.00, NH: 0.00, NJ: 0.057, NM: 0.045, NY: 0.06,
NC: 0.0475, ND: 0.02, OH: 0.04, OK: 0.045, OR: 0.07, PA: 0.03, RI: 0.045, SC: 0.04,
SD: 0.00, TN: 0.00, TX: 0.00, UT: 0.045, VT: 0.055, VA: 0.05, WA: 0.00, WV: 0.05,
WI: 0.05, WY: 0.00, DC: 0.05
};
function calculateAnnualFederalTaxSingle(annualIncome) {
const STANDARD_DEDUCTION_SINGLE = 13850;
const taxableIncome = Math.max(0, annualIncome - STANDARD_DEDUCTION_SINGLE);
/***************************************************
* HELPER: Federal Tax Brackets
***************************************************/
const STANDARD_DEDUCTION_SINGLE = 13850;
function calculateAnnualFederalTaxSingle(annualTaxable) {
const brackets = [
{ limit: 11000, rate: 0.10 },
{ limit: 44725, rate: 0.12 },
@ -74,11 +30,10 @@ function calculateAnnualFederalTaxSingle(annualIncome) {
let tax = 0;
let lastLimit = 0;
for (let i = 0; i < brackets.length; i++) {
const { limit, rate } = brackets[i];
if (taxableIncome <= limit) {
tax += (taxableIncome - lastLimit) * rate;
if (annualTaxable <= limit) {
tax += (annualTaxable - lastLimit) * rate;
break;
} else {
tax += (limit - lastLimit) * rate;
@ -88,14 +43,34 @@ function calculateAnnualFederalTaxSingle(annualIncome) {
return tax;
}
function calculateAnnualStateTax(annualIncome, stateCode) {
const rate = APPROX_STATE_TAX_RATES[stateCode] ?? 0.05;
return annualIncome * rate;
/***************************************************
* HELPER: Monthly Federal Tax (no YTD)
* We just treat (monthlyGross * 12) - standardDed
* -> bracket -> / 12
***************************************************/
function calculateMonthlyFedTaxNoYTD(monthlyGross) {
const annualGross = monthlyGross * 12;
let annualTaxable = annualGross - STANDARD_DEDUCTION_SINGLE;
if (annualTaxable < 0) annualTaxable = 0;
const annualTax = calculateAnnualFederalTaxSingle(annualTaxable);
return annualTax / 12;
}
/***************************************************
* HELPER: Monthly State Tax (no YTD)
* Uses GA (5%) by default if user doesn't override
***************************************************/
function calculateMonthlyStateTaxNoYTD(monthlyGross, stateCode = 'GA') {
const rate = APPROX_STATE_TAX_RATES[stateCode] ?? 0.05;
return monthlyGross * rate;
}
/***************************************************
* HELPER: Loan Payment (if not deferring)
***************************************************/
function calculateLoanPayment(principal, annualRate, years) {
if (principal <= 0) return 0;
const monthlyRate = annualRate / 100 / 12;
const numPayments = years * 12;
@ -108,35 +83,28 @@ function calculateLoanPayment(principal, annualRate, years) {
);
}
/**
* Main projection function with bracket-based FEDERAL + optional STATE tax logic.
*
* milestoneImpacts: [
* {
* impact_type: 'ONE_TIME' | 'MONTHLY',
* direction: 'add' | 'subtract',
* amount: number,
* start_date: 'YYYY-MM-DD',
* end_date?: 'YYYY-MM-DD' | null
* }, ...
* ]
*/
/***************************************************
* MAIN SIMULATION FUNCTION
***************************************************/
export function simulateFinancialProjection(userProfile) {
/***************************************************
* 1) DESTRUCTURE USER PROFILE
***************************************************/
const {
// Income & expenses
// Basic incomes
currentSalary = 0,
monthlyExpenses = 0,
monthlyDebtPayments = 0,
partTimeIncome = 0,
extraPayment = 0,
// Loan info
// Student loan config
studentLoanAmount = 0,
interestRate = 5, // %
loanTerm = 10, // years
interestRate = 5,
loanTerm = 10,
loanDeferralUntilGraduation = false,
// College & tuition
// College config
inCollege = false,
programType,
hoursCompleted = 0,
@ -147,40 +115,37 @@ export function simulateFinancialProjection(userProfile) {
academicCalendar = 'monthly',
annualFinancialAid = 0,
// Salary after graduation
// Post-college salary
expectedSalary = 0,
// Savings
// Savings & monthly contributions
emergencySavings = 0,
retirementSavings = 0,
// Monthly contributions
monthlyRetirementContribution = 0,
monthlyEmergencyContribution = 0,
// Surplus allocation
// Surplus distribution
surplusEmergencyAllocation = 50,
surplusRetirementAllocation = 50,
// Potential override
// Program length override
programLength,
// State code
stateCode = 'TX',
// State code for taxes (default to GA if not provided)
stateCode = 'GA',
// Milestone impacts (with dates, add/subtract logic)
// Financial milestone impacts
milestoneImpacts = []
} = userProfile;
// scenario start date
const scenarioStart = startDate ? new Date(startDate) : new Date();
/***************************************************
* 2) CLAMP THE SCENARIO START TO MONTH-BEGIN
***************************************************/
const scenarioStartClamped = moment(startDate || new Date()).startOf('month');
// 1. Monthly loan payment if not deferring
let monthlyLoanPayment = loanDeferralUntilGraduation
? 0
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
// 2. Determine credit hours
/***************************************************
* 3) DETERMINE PROGRAM LENGTH (credit hours)
***************************************************/
let requiredCreditHours = 120;
switch (programType) {
case "Associate's Degree":
@ -192,17 +157,20 @@ export function simulateFinancialProjection(userProfile) {
case "Doctoral Degree":
requiredCreditHours = 60;
break;
// otherwise Bachelor's
// else Bachelor's = 120
}
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
const dynamicProgramLength = Math.ceil(remainingCreditHours / creditHoursPerYear);
const dynamicProgramLength = Math.ceil(
remainingCreditHours / (creditHoursPerYear || 30)
);
const finalProgramLength = programLength || dynamicProgramLength;
// 3. Net annual tuition
const netAnnualTuition = Math.max(0, calculatedTuition - annualFinancialAid);
/***************************************************
* 4) TUITION CALC: lumps, deferral, etc.
***************************************************/
const netAnnualTuition = Math.max(0, (calculatedTuition || 0) - (annualFinancialAid || 0));
const totalTuitionCost = netAnnualTuition * finalProgramLength;
// 4. lumps
let lumpsPerYear, lumpsSchedule;
switch (academicCalendar) {
case 'semester':
@ -226,99 +194,118 @@ export function simulateFinancialProjection(userProfile) {
const totalAcademicMonths = finalProgramLength * 12;
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
// 5. Simulation loop
const maxMonths = 240; // 20 years
let date = new Date(scenarioStart);
/***************************************************
* 5) LOAN PAYMENT (if not deferring)
***************************************************/
let monthlyLoanPayment = loanDeferralUntilGraduation
? 0
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
/***************************************************
* 6) SETUP FOR THE SIMULATION LOOP
***************************************************/
const maxMonths = 240; // 20 years
let loanBalance = Math.max(studentLoanAmount, 0);
let loanPaidOffMonth = null;
let currentEmergencySavings = emergencySavings;
let currentRetirementSavings = retirementSavings;
let projectionData = [];
// Keep track of YTD gross & tax for reference
let fedYTDgross = 0;
let fedYTDtax = 0;
let stateYTDgross = 0;
let stateYTDtax = 0;
let wasInDeferral = inCollege && loanDeferralUntilGraduation;
const graduationDateObj = gradDate ? new Date(gradDate) : null;
const graduationDateObj = gradDate ? moment(gradDate).startOf('month') : null;
// For YTD taxes
const taxStateByYear = {};
console.log('simulateFinancialProjection - monthly tax approach');
console.log('scenarioStartClamped:', scenarioStartClamped.format('YYYY-MM-DD'));
for (let month = 0; month < maxMonths; month++) {
date.setMonth(date.getMonth() + 1);
const currentYear = date.getFullYear();
/***************************************************
* 7) THE MONTHLY LOOP
***************************************************/
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
// date for this iteration
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
// elapsed months since scenario start
const elapsedMonths = moment(date).diff(moment(scenarioStart), 'months');
// if loan paid
// check if loan is fully paid
if (loanBalance <= 0 && !loanPaidOffMonth) {
loanPaidOffMonth = `${currentYear}-${String(date.getMonth() + 1).padStart(2, '0')}`;
loanPaidOffMonth = currentSimDate.format('YYYY-MM');
}
// are we in college?
// Are we still in college?
let stillInCollege = false;
if (inCollege) {
if (graduationDateObj) {
stillInCollege = date < graduationDateObj;
stillInCollege = currentSimDate.isBefore(graduationDateObj, 'month');
} else {
stillInCollege = (elapsedMonths < totalAcademicMonths);
stillInCollege = (monthIndex < totalAcademicMonths);
}
}
// 6. tuition lumps
/************************************************
* 7.1 TUITION lumps
************************************************/
let tuitionCostThisMonth = 0;
if (stillInCollege && lumpsPerYear > 0) {
const academicYearIndex = Math.floor(elapsedMonths / 12);
const monthInYear = elapsedMonths % 12;
const academicYearIndex = Math.floor(monthIndex / 12);
const monthInYear = monthIndex % 12;
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) {
tuitionCostThisMonth = lumpAmount;
}
}
// 7. Exiting college?
const nowExitingCollege = wasInDeferral && !stillInCollege;
// 8. deferral lumps
if (stillInCollege && loanDeferralUntilGraduation) {
if (tuitionCostThisMonth > 0) {
loanBalance += tuitionCostThisMonth;
tuitionCostThisMonth = 0;
}
// If deferring tuition => add to loan, no direct expense
if (stillInCollege && loanDeferralUntilGraduation && tuitionCostThisMonth > 0) {
loanBalance += tuitionCostThisMonth;
tuitionCostThisMonth = 0;
}
// 9. Base monthly income
let grossMonthlyIncome = 0;
/************************************************
* 7.2 BASE MONTHLY INCOME
************************************************/
let baseMonthlyIncome = 0;
if (!stillInCollege) {
grossMonthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
// user is out of college => expected or current
baseMonthlyIncome = (expectedSalary || currentSalary) / 12;
} else {
grossMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12);
// in college => might have partTimeIncome + current
baseMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12);
}
// Track extra subtracting impacts in a separate variable
/************************************************
* 7.3 MILESTONE IMPACTS
************************************************/
let extraImpactsThisMonth = 0;
// 9b. Apply milestone impacts
milestoneImpacts.forEach((impact) => {
const startOffset = impact.start_date
? moment(impact.start_date).diff(moment(scenarioStart), 'months')
: 0;
const startDateClamped = moment(impact.start_date).startOf('month');
let startOffset = startDateClamped.diff(scenarioStartClamped, 'months');
if (startOffset < 0) startOffset = 0;
let endOffset = Infinity;
if (impact.end_date && impact.end_date.trim() !== '') {
endOffset = moment(impact.end_date).diff(moment(scenarioStart), 'months');
const endDateClamped = moment(impact.end_date).startOf('month');
endOffset = endDateClamped.diff(scenarioStartClamped, 'months');
if (endOffset < 0) endOffset = 0;
}
if (impact.impact_type === 'ONE_TIME') {
if (elapsedMonths === startOffset) {
if (monthIndex === startOffset) {
if (impact.direction === 'add') {
grossMonthlyIncome += impact.amount;
baseMonthlyIncome += impact.amount;
} else {
extraImpactsThisMonth += impact.amount;
}
}
} else {
// 'MONTHLY'
if (elapsedMonths >= startOffset && elapsedMonths <= endOffset) {
if (monthIndex >= startOffset && monthIndex <= endOffset) {
if (impact.direction === 'add') {
grossMonthlyIncome += impact.amount;
baseMonthlyIncome += impact.amount;
} else {
extraImpactsThisMonth += impact.amount;
}
@ -326,70 +313,51 @@ export function simulateFinancialProjection(userProfile) {
}
});
// 10. Taxes
if (!taxStateByYear[currentYear]) {
taxStateByYear[currentYear] = {
federalYtdGross: 0,
federalYtdTaxSoFar: 0,
stateYtdGross: 0,
stateYtdTaxSoFar: 0
};
/************************************************
* 7.4 CALCULATE TAXES (No YTD approach)
************************************************/
const monthlyFederalTax = calculateMonthlyFedTaxNoYTD(baseMonthlyIncome);
const monthlyStateTax = calculateMonthlyStateTaxNoYTD(baseMonthlyIncome, stateCode);
const combinedTax = monthlyFederalTax + monthlyStateTax;
// net after tax
const netMonthlyIncome = baseMonthlyIncome - combinedTax;
// increment YTD gross & tax for reference
fedYTDgross += baseMonthlyIncome;
fedYTDtax += monthlyFederalTax;
stateYTDgross += baseMonthlyIncome;
stateYTDtax += monthlyStateTax;
/************************************************
* 7.5 LOAN + EXPENSES
************************************************/
const nowExitingCollege = wasInDeferral && !stillInCollege;
if (nowExitingCollege) {
monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, loanTerm);
}
// accumulate YTD gross
taxStateByYear[currentYear].federalYtdGross += grossMonthlyIncome;
taxStateByYear[currentYear].stateYtdGross += grossMonthlyIncome;
// fed tax
const newFedTaxTotal = calculateAnnualFederalTaxSingle(
taxStateByYear[currentYear].federalYtdGross
);
const monthlyFederalTax = newFedTaxTotal - taxStateByYear[currentYear].federalYtdTaxSoFar;
taxStateByYear[currentYear].federalYtdTaxSoFar = newFedTaxTotal;
// state tax
const newStateTaxTotal = calculateAnnualStateTax(
taxStateByYear[currentYear].stateYtdGross,
stateCode
);
const monthlyStateTax = newStateTaxTotal - taxStateByYear[currentYear].stateYtdTaxSoFar;
taxStateByYear[currentYear].stateYtdTaxSoFar = newStateTaxTotal;
const combinedTax = monthlyFederalTax + monthlyStateTax;
const netMonthlyIncome = grossMonthlyIncome - combinedTax;
// 11. Expenses & loan
let thisMonthLoanPayment = 0;
// now include tuition lumps + any 'subtract' impacts
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth;
// re-amortize after deferral ends
if (nowExitingCollege) {
monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, 10);
}
// if deferring
if (stillInCollege && loanDeferralUntilGraduation) {
// accumulate interest
const interestForMonth = loanBalance * (interestRate / 100 / 12);
loanBalance += interestForMonth;
} else {
// pay principal
if (loanBalance > 0) {
const interestForMonth = loanBalance * (interestRate / 100 / 12);
const principalForMonth = Math.min(
loanBalance,
(monthlyLoanPayment + extraPayment) - interestForMonth
);
loanBalance -= principalForMonth;
loanBalance = Math.max(loanBalance, 0);
loanBalance = Math.max(loanBalance - principalForMonth, 0);
thisMonthLoanPayment = monthlyLoanPayment + extraPayment;
totalMonthlyExpenses += thisMonthLoanPayment;
totalMonthlyExpenses += (monthlyLoanPayment + extraPayment);
}
}
// leftover after mandatory expenses
let leftover = netMonthlyIncome - totalMonthlyExpenses;
if (leftover < 0) leftover = 0;
// baseline contributions
const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution;
@ -402,59 +370,72 @@ export function simulateFinancialProjection(userProfile) {
leftover -= baselineContributions;
}
// shortfall check
const totalWantedContributions = effectiveRetirementContribution + effectiveEmergencyContribution;
const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
const actualExpensesPaid = totalMonthlyExpenses + effectiveRetirementContribution + effectiveEmergencyContribution;
let shortfall = actualExpensesPaid - netMonthlyIncome;
// cover shortfall with emergency
if (shortfall > 0) {
const canCover = Math.min(shortfall, currentEmergencySavings);
currentEmergencySavings -= canCover;
shortfall -= canCover;
if (shortfall > 0) {
// bankrupt scenario, end
break;
}
// leftover -= shortfall; // if you want negative leftover
}
// 13. Surplus
// Surplus => leftover
if (leftover > 0) {
const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation;
const emergencyPortion = leftover * (surplusEmergencyAllocation / totalPct);
const retirementPortion = leftover * (surplusRetirementAllocation / totalPct);
const emergPortion = leftover * (surplusEmergencyAllocation / totalPct);
const retPortion = leftover * (surplusRetirementAllocation / totalPct);
currentEmergencySavings += emergencyPortion;
currentRetirementSavings += retirementPortion;
currentEmergencySavings += emergPortion;
currentRetirementSavings += retPortion;
}
// netSavings for display
const finalExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
const netSavings = netMonthlyIncome - finalExpensesPaid;
// net savings
const netSavings = netMonthlyIncome - actualExpensesPaid;
projectionData.push({
month: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`,
grossMonthlyIncome: Math.round(grossMonthlyIncome * 100) / 100,
monthlyFederalTax: Math.round(monthlyFederalTax * 100) / 100,
monthlyStateTax: Math.round(monthlyStateTax * 100) / 100,
combinedTax: Math.round(combinedTax * 100) / 100,
netMonthlyIncome: Math.round(netMonthlyIncome * 100) / 100,
totalExpenses: Math.round(finalExpensesPaid * 100) / 100,
effectiveRetirementContribution: Math.round(effectiveRetirementContribution * 100) / 100,
effectiveEmergencyContribution: Math.round(effectiveEmergencyContribution * 100) / 100,
netSavings: Math.round(netSavings * 100) / 100,
emergencySavings: Math.round(currentEmergencySavings * 100) / 100,
retirementSavings: Math.round(currentRetirementSavings * 100) / 100,
loanBalance: Math.round(loanBalance * 100) / 100,
loanPaymentThisMonth: Math.round(thisMonthLoanPayment * 100) / 100
month: currentSimDate.format('YYYY-MM'),
grossMonthlyIncome: +baseMonthlyIncome.toFixed(2),
monthlyFederalTax: +monthlyFederalTax.toFixed(2),
monthlyStateTax: +monthlyStateTax.toFixed(2),
combinedTax: +combinedTax.toFixed(2),
netMonthlyIncome: +netMonthlyIncome.toFixed(2),
totalExpenses: +actualExpensesPaid.toFixed(2),
effectiveRetirementContribution: +effectiveRetirementContribution.toFixed(2),
effectiveEmergencyContribution: +effectiveEmergencyContribution.toFixed(2),
netSavings: +netSavings.toFixed(2),
emergencySavings: +currentEmergencySavings.toFixed(2),
retirementSavings: +currentRetirementSavings.toFixed(2),
loanBalance: +loanBalance.toFixed(2),
// actual loan payment
loanPaymentThisMonth: +(monthlyLoanPayment + extraPayment).toFixed(2),
// YTD references
fedYTDgross: +fedYTDgross.toFixed(2),
fedYTDtax: +fedYTDtax.toFixed(2),
stateYTDgross: +stateYTDgross.toFixed(2),
stateYTDtax: +stateYTDtax.toFixed(2),
});
// update deferral
wasInDeferral = stillInCollege && loanDeferralUntilGraduation;
}
return {
projectionData,
loanPaidOffMonth,
finalEmergencySavings: Math.round(currentEmergencySavings * 100) / 100,
finalRetirementSavings: Math.round(currentRetirementSavings * 100) / 100,
finalLoanBalance: Math.round(loanBalance * 100) / 100
finalEmergencySavings: +currentEmergencySavings.toFixed(2),
finalRetirementSavings: +currentRetirementSavings.toFixed(2),
finalLoanBalance: +loanBalance.toFixed(2),
// Final YTD totals
fedYTDgross: +fedYTDgross.toFixed(2),
fedYTDtax: +fedYTDtax.toFixed(2),
stateYTDgross: +stateYTDgross.toFixed(2),
stateYTDtax: +stateYTDtax.toFixed(2),
};
}

Binary file not shown.