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

View File

@ -1,51 +1,74 @@
// src/components/MilestoneTracker.js // src/components/MilestoneTracker.js
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Line } from 'react-chartjs-2'; 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 annotationPlugin from 'chartjs-plugin-annotation';
import { Filler } from 'chart.js'; import { Filler } from 'chart.js';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import CareerSelectDropdown from './CareerSelectDropdown.js'; import CareerSelectDropdown from './CareerSelectDropdown.js';
import CareerSearch from './CareerSearch.js'; import CareerSearch from './CareerSearch.js';
import MilestoneTimeline from './MilestoneTimeline.js'; import MilestoneTimeline from './MilestoneTimeline.js';
import AISuggestedMilestones from './AISuggestedMilestones.js'; import AISuggestedMilestones from './AISuggestedMilestones.js';
import ScenarioEditModal from './ScenarioEditModal.js';
import './MilestoneTracker.css'; import './MilestoneTracker.css';
import './MilestoneTimeline.css'; import './MilestoneTimeline.css';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; 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 MilestoneTracker = ({ selectedCareer: initialCareer }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const apiURL = process.env.REACT_APP_API_URL;
// -------------------------
// State
// -------------------------
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null); const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
const [careerPathId, setCareerPathId] = useState(null); const [careerPathId, setCareerPathId] = useState(null);
const [existingCareerPaths, setExistingCareerPaths] = useState([]); const [existingCareerPaths, setExistingCareerPaths] = useState([]);
const [pendingCareerForModal, setPendingCareerForModal] = useState(null); const [pendingCareerForModal, setPendingCareerForModal] = useState(null);
const [activeView, setActiveView] = useState("Career"); const [activeView, setActiveView] = useState("Career");
// Store each profile separately
const [financialProfile, setFinancialProfile] = useState(null); const [financialProfile, setFinancialProfile] = useState(null);
const [collegeProfile, setCollegeProfile] = useState(null); const [collegeProfile, setCollegeProfile] = useState(null);
// For the chart
const [projectionData, setProjectionData] = useState([]); const [projectionData, setProjectionData] = useState([]);
const [loanPayoffMonth, setLoanPayoffMonth] = useState(null); const [loanPayoffMonth, setLoanPayoffMonth] = useState(null);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const apiURL = process.env.REACT_APP_API_URL;
// Possibly loaded from location.state // 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(() => { useEffect(() => {
const fetchCareerPaths = async () => { const fetchCareerPaths = async () => {
const res = await authFetch(`${apiURL}/premium/career-profile/all`); const res = await authFetch(`${apiURL}/premium/career-profile/all`);
@ -58,6 +81,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
setSelectedCareer(fromPopout); setSelectedCareer(fromPopout);
setCareerPathId(fromPopout.career_path_id); setCareerPathId(fromPopout.career_path_id);
} else if (!selectedCareer) { } else if (!selectedCareer) {
// Try to fetch the latest
const latest = await authFetch(`${apiURL}/premium/career-profile/latest`); const latest = await authFetch(`${apiURL}/premium/career-profile/latest`);
if (latest && latest.ok) { if (latest && latest.ok) {
const latestData = await latest.json(); const latestData = await latest.json();
@ -81,9 +105,9 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
fetchFinancialProfile(); fetchFinancialProfile();
}, [apiURL, location.state, selectedCareer]); }, [apiURL, location.state, selectedCareer]);
// ---------------------------- // -------------------------
// 2. Fetch the college profile for the selected careerPathId // 2. Fetch the college profile for the selected careerPathId
// ---------------------------- // -------------------------
useEffect(() => { useEffect(() => {
if (!careerPathId) { if (!careerPathId) {
setCollegeProfile(null); setCollegeProfile(null);
@ -91,38 +115,56 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
} }
const fetchCollegeProfile = async () => { 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}`); const res = await authFetch(`${apiURL}/premium/college-profile?careerPathId=${careerPathId}`);
if (!res || !res.ok) { if (!res || !res.ok) {
setCollegeProfile(null); setCollegeProfile(null);
return; return;
} }
const data = await res.json(); const data = await res.json();
setCollegeProfile(data); // could be an object or empty {} setCollegeProfile(data);
}; };
fetchCollegeProfile(); fetchCollegeProfile();
}, [careerPathId, apiURL]); }, [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(() => { useEffect(() => {
if (!financialProfile || !collegeProfile || !selectedCareer) return; if (!financialProfile || !collegeProfile || !selectedCareer || !careerPathId) 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'
);
// 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 || [];
// Merge financial + college data // 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 = { const mergedProfile = {
// From financialProfile // From financialProfile
currentSalary: financialProfile.current_salary || 0, currentSalary: financialProfile.current_salary || 0,
@ -144,56 +186,163 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
annualFinancialAid: collegeProfile.annual_financial_aid || 0, annualFinancialAid: collegeProfile.annual_financial_aid || 0,
calculatedTuition: collegeProfile.tuition || 0, calculatedTuition: collegeProfile.tuition || 0,
extraPayment: collegeProfile.extra_payment || 0, extraPayment: collegeProfile.extra_payment || 0,
partTimeIncome: 0, // or collegeProfile.part_time_income if you store it inCollege:
collegeProfile.college_enrollment_status === 'currently_enrolled' ||
collegeProfile.college_enrollment_status === 'prospective_student',
gradDate: collegeProfile.expected_graduation || null, gradDate: collegeProfile.expected_graduation || null,
programType: collegeProfile.program_type, programType: collegeProfile.program_type,
creditHoursPerYear: collegeProfile.credit_hours_per_year || 0, creditHoursPerYear: collegeProfile.credit_hours_per_year || 0,
hoursCompleted: collegeProfile.hours_completed || 0, hoursCompleted: collegeProfile.hours_completed || 0,
programLength: collegeProfile.program_length || 0, programLength: collegeProfile.program_length || 0,
// 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(), startDate: new Date().toISOString(),
// Future logic could set expectedSalary if there's a difference
expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary, expectedSalary: collegeProfile.expected_salary || financialProfile.current_salary,
// The key: impacts
milestoneImpacts: allImpacts
}; };
const result = simulateFinancialProjection(mergedProfile); // 5) Run the simulation
console.log("mergedProfile for simulation:", mergedProfile); const { projectionData: initialProjData, loanPaidOffMonth: payoff } =
simulateFinancialProjection(mergedProfile);
const { projectionData, loanPaidOffMonth } = result;
// If you want to accumulate net savings:
let cumulativeSavings = mergedProfile.emergencySavings || 0; let cumulativeSavings = mergedProfile.emergencySavings || 0;
const cumulativeProjectionData = projectionData.map(month => { const finalData = initialProjData.map((month) => {
cumulativeSavings += (month.netSavings || 0); cumulativeSavings += (month.netSavings || 0);
return { ...month, cumulativeNetSavings: cumulativeSavings }; return { ...month, cumulativeNetSavings: cumulativeSavings };
}); });
if (cumulativeProjectionData.length > 0) { setProjectionData(finalData);
setProjectionData(cumulativeProjectionData); setLoanPayoffMonth(payoff);
setLoanPayoffMonth(loanPaidOffMonth);
} catch (err) {
console.error('Error fetching initial milestones/impacts or simulating:', err);
}
})();
}, [financialProfile, collegeProfile, selectedCareer, careerPathId]);
// -------------------------------------------------
// 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;
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}`)
]);
if (!finResp.ok || !colResp.ok || !milResp.ok) {
console.error('One reSimulate fetch failed:', finResp.status, colResp.status, milResp.status);
return;
} }
console.log('mergedProfile for simulation:', mergedProfile); const [updatedFinancial, updatedCollege, milestonesData] = await Promise.all([
finResp.json(),
colResp.json(),
milResp.json()
]);
}, [financialProfile, collegeProfile, selectedCareer]); // 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 [];
})
);
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);
}
};
// 4. The rest of your code is unchanged, e.g. handleConfirmCareerSelection, etc.
// ... // ...
// The rest of your component logic
// ...
console.log( console.log(
'First 5 items of projectionData:', 'First 5 items of projectionData:',
Array.isArray(projectionData) ? projectionData.slice(0, 5) : 'projectionData not yet available' Array.isArray(projectionData) ? projectionData.slice(0, 5) : 'projectionData not yet available'
); );
// ...
// The remainder of your component: timeline, chart, AISuggestedMilestones, etc.
// ...
return ( return (
<div className="milestone-tracker"> <div className="milestone-tracker">
<CareerSelectDropdown <CareerSelectDropdown
@ -207,11 +356,13 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch} authFetch={authFetch}
/> />
{/* Pass reSimulate as onMilestoneUpdated: */}
<MilestoneTimeline <MilestoneTimeline
careerPathId={careerPathId} careerPathId={careerPathId}
authFetch={authFetch} authFetch={authFetch}
activeView={activeView} activeView={activeView}
setActiveView={setActiveView} setActiveView={setActiveView}
onMilestoneUpdated={reSimulate}
/> />
<AISuggestedMilestones <AISuggestedMilestones
@ -227,19 +378,19 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
<h3 className="text-lg font-semibold mb-2">Financial Projection</h3> <h3 className="text-lg font-semibold mb-2">Financial Projection</h3>
<Line <Line
data={{ data={{
labels: projectionData.map(p => p.month), labels: projectionData.map((p) => p.month),
datasets: [ datasets: [
{ {
label: 'Total Savings', label: 'Total Savings',
data: projectionData.map(p => p.cumulativeNetSavings), data: projectionData.map((p) => p.cumulativeNetSavings),
borderColor: 'rgba(54, 162, 235, 1)', borderColor: 'rgba(54, 162, 235, 1)',
backgroundColor: 'rgba(54, 162, 235, 0.2)', backgroundColor: 'rgba(54, 162, 235, 0.2)',
tension: 0.4, tension: 0.4,
fill: true, fill: true
}, },
{ {
label: 'Loan Balance', label: 'Loan Balance',
data: projectionData.map(p => p.loanBalance), data: projectionData.map((p) => p.loanBalance),
borderColor: 'rgba(255, 99, 132, 1)', borderColor: 'rgba(255, 99, 132, 1)',
backgroundColor: 'rgba(255, 99, 132, 0.2)', backgroundColor: 'rgba(255, 99, 132, 0.2)',
tension: 0.4, tension: 0.4,
@ -251,7 +402,7 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
}, },
{ {
label: 'Retirement Savings', label: 'Retirement Savings',
data: projectionData.map(p => p.retirementSavings), data: projectionData.map((p) => p.retirementSavings),
borderColor: 'rgba(75, 192, 192, 1)', borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)', backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.4, tension: 0.4,
@ -308,8 +459,6 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
authFetch={authFetch} authFetch={authFetch}
/> />
{/* SCENARIO EDIT MODAL */}
<ScenarioEditModal <ScenarioEditModal
show={showEditModal} show={showEditModal}
onClose={() => setShowEditModal(false)} onClose={() => setShowEditModal(false)}
@ -322,9 +471,11 @@ const MilestoneTracker = ({ selectedCareer: initialCareer }) => {
/> />
{pendingCareerForModal && ( {pendingCareerForModal && (
<button onClick={() => { <button
onClick={() => {
// handleConfirmCareerSelection logic // handleConfirmCareerSelection logic
}}> }}
>
Confirm Career Change to {pendingCareerForModal} Confirm Career Change to {pendingCareerForModal}
</button> </button>
)} )}

View File

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