From 8d1dcf26b91f7006303f686b3b096a7ea05bca29 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 22 Apr 2025 12:10:59 +0000 Subject: [PATCH] Fixed tax implementation in simulator, milestone impacts. --- src/components/MilestoneTimeline.js | 3 +- src/components/MilestoneTracker.js | 369 ++++++++++++++------ src/utils/FinancialProjectionService.js | 435 +++++++++++------------- user_profile.db | Bin 106496 -> 106496 bytes 4 files changed, 470 insertions(+), 337 deletions(-) diff --git a/src/components/MilestoneTimeline.js b/src/components/MilestoneTimeline.js index 2b15dcf..d7f6c7d 100644 --- a/src/components/MilestoneTimeline.js +++ b/src/components/MilestoneTimeline.js @@ -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); diff --git a/src/components/MilestoneTracker.js b/src/components/MilestoneTracker.js index c94c2b6..bd6c395 100644 --- a/src/components/MilestoneTracker.js +++ b/src/components/MilestoneTracker.js @@ -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 (
{ authFetch={authFetch} /> + {/* Pass reSimulate as onMilestoneUpdated: */} {

Financial Projection

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 */} - setShowEditModal(false)} - financialProfile={financialProfile} - setFinancialProfile={setFinancialProfile} - collegeProfile={collegeProfile} - setCollegeProfile={setCollegeProfile} - apiURL={apiURL} - authFetch={authFetch} - /> + setShowEditModal(false)} + financialProfile={financialProfile} + setFinancialProfile={setFinancialProfile} + collegeProfile={collegeProfile} + setCollegeProfile={setCollegeProfile} + apiURL={apiURL} + authFetch={authFetch} + /> {pendingCareerForModal && ( - )} diff --git a/src/utils/FinancialProjectionService.js b/src/utils/FinancialProjectionService.js index 923d924..d619a0f 100644 --- a/src/utils/FinancialProjectionService.js +++ b/src/utils/FinancialProjectionService.js @@ -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), }; } diff --git a/user_profile.db b/user_profile.db index 89df59e05a058c82e74885cb244d4009f1310b12..b44135456a1eb52805db1afd13edd1071d182485 100644 GIT binary patch delta 410 zcmZoTz}9epZGtr8>xnYXjITE)EZNV&Ai%)DAIG={++ef|AHJbWS(Q&QM3 z7#SFu>lzsA8W|cH7#qP@My9$3CP1+eLvt$=3oBDIJtI@gD4<%b;*Ii*KxfFxGAlCJ z+NPvhSXdYtTj(a5o0$NeVQHzGm||$IYo3;5W@c`gmTJh9mq8JZenx7cv=zw_VXxy`Li4XljJ^$ZLwxBu5?WEFq}(GC6^n*|LP^9O1$ XhcXfqG)(Hup`3&rpP0BTfH437UKMD7 delta 222 zcmZoTz}9epZGtr8#fdV`j2Aa1EZNV-$REnUAG)#8o8PcegOMvVR92QrQPI}cFxk{N z#nRMR*EGe#RM*7V(p)z&*~~&W$u!N#$jrjnC^^-9a{qY~7x4lk10z#i0~1{%LmokyT(b%Y_^K MD#W;CSpZ`I04%^eO#lD@