import moment from 'moment'; /*************************************************** * 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, 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 }; /*************************************************** * HELPER: Federal Tax Brackets ***************************************************/ const STANDARD_DEDUCTION_SINGLE = 13850; function calculateAnnualFederalTaxSingle(annualTaxable) { const brackets = [ { limit: 11000, rate: 0.10 }, { limit: 44725, rate: 0.12 }, { limit: 95375, rate: 0.22 }, { limit: 182100, rate: 0.24 }, { limit: 231250, rate: 0.32 }, { limit: 578125, rate: 0.35 }, { limit: Infinity, rate: 0.37 } ]; let tax = 0; let lastLimit = 0; for (let i = 0; i < brackets.length; i++) { const { limit, rate } = brackets[i]; if (annualTaxable <= limit) { tax += (annualTaxable - lastLimit) * rate; break; } else { tax += (limit - lastLimit) * rate; lastLimit = limit; } } return tax; } /*************************************************** * 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; if (monthlyRate === 0) { return principal / numPayments; } return ( (principal * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -numPayments)) ); } /*************************************************** * MAIN SIMULATION FUNCTION ***************************************************/ export function simulateFinancialProjection(userProfile) { /*************************************************** * 1) DESTRUCTURE USER PROFILE ***************************************************/ const { // Basic incomes currentSalary = 0, monthlyExpenses = 0, monthlyDebtPayments = 0, partTimeIncome = 0, extraPayment = 0, // Student loan config studentLoanAmount = 0, interestRate = 5, loanTerm = 10, loanDeferralUntilGraduation = false, // College config inCollege = false, programType, hoursCompleted = 0, creditHoursPerYear, calculatedTuition, gradDate, startDate, academicCalendar = 'monthly', annualFinancialAid = 0, // Post-college salary expectedSalary = 0, // Savings & monthly contributions emergencySavings = 0, retirementSavings = 0, monthlyRetirementContribution = 0, monthlyEmergencyContribution = 0, // Surplus distribution surplusEmergencyAllocation = 50, surplusRetirementAllocation = 50, // Program length override programLength, // State code for taxes (default to GA if not provided) stateCode = 'GA', // Financial milestone impacts milestoneImpacts = [] } = userProfile; /*************************************************** * 2) CLAMP THE SCENARIO START TO MONTH-BEGIN ***************************************************/ const scenarioStartClamped = moment(startDate || new Date()).startOf('month'); /*************************************************** * 3) DETERMINE PROGRAM LENGTH (credit hours) ***************************************************/ let requiredCreditHours = 120; switch (programType) { case "Associate's Degree": requiredCreditHours = 60; break; case "Master's Degree": requiredCreditHours = 30; break; case "Doctoral Degree": requiredCreditHours = 60; break; // else Bachelor's = 120 } const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted); const dynamicProgramLength = Math.ceil( remainingCreditHours / (creditHoursPerYear || 30) ); const finalProgramLength = programLength || dynamicProgramLength; /*************************************************** * 4) TUITION CALC: lumps, deferral, etc. ***************************************************/ const netAnnualTuition = Math.max(0, (calculatedTuition || 0) - (annualFinancialAid || 0)); const totalTuitionCost = netAnnualTuition * finalProgramLength; let lumpsPerYear, lumpsSchedule; switch (academicCalendar) { case 'semester': lumpsPerYear = 2; lumpsSchedule = [0, 6]; break; case 'quarter': lumpsPerYear = 4; lumpsSchedule = [0, 3, 6, 9]; break; case 'trimester': lumpsPerYear = 3; lumpsSchedule = [0, 4, 8]; break; case 'monthly': default: lumpsPerYear = 12; lumpsSchedule = [...Array(12).keys()]; break; } const totalAcademicMonths = finalProgramLength * 12; const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength); /*************************************************** * 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 ? moment(gradDate).startOf('month') : null; console.log('simulateFinancialProjection - monthly tax approach'); console.log('scenarioStartClamped:', scenarioStartClamped.format('YYYY-MM-DD')); /*************************************************** * 7) THE MONTHLY LOOP ***************************************************/ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) { // date for this iteration const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months'); // check if loan is fully paid if (loanBalance <= 0 && !loanPaidOffMonth) { loanPaidOffMonth = currentSimDate.format('YYYY-MM'); } // Are we still in college? let stillInCollege = false; if (inCollege) { if (graduationDateObj) { stillInCollege = currentSimDate.isBefore(graduationDateObj, 'month'); } else { stillInCollege = (monthIndex < totalAcademicMonths); } } /************************************************ * 7.1 TUITION lumps ************************************************/ let tuitionCostThisMonth = 0; if (stillInCollege && lumpsPerYear > 0) { const academicYearIndex = Math.floor(monthIndex / 12); const monthInYear = monthIndex % 12; if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) { tuitionCostThisMonth = lumpAmount; } } // If deferring tuition => add to loan, no direct expense if (stillInCollege && loanDeferralUntilGraduation && tuitionCostThisMonth > 0) { loanBalance += tuitionCostThisMonth; tuitionCostThisMonth = 0; } /************************************************ * 7.2 BASE MONTHLY INCOME ************************************************/ let baseMonthlyIncome = 0; if (!stillInCollege) { // user is out of college => expected or current baseMonthlyIncome = (expectedSalary || currentSalary) / 12; } else { // in college => might have partTimeIncome + current baseMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12); } /************************************************ * 7.3 MILESTONE IMPACTS ************************************************/ let extraImpactsThisMonth = 0; milestoneImpacts.forEach((impact) => { 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() !== '') { 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 (monthIndex === startOffset) { if (impact.direction === 'add') { baseMonthlyIncome += impact.amount; } else { extraImpactsThisMonth += impact.amount; } } } else { // 'MONTHLY' if (monthIndex >= startOffset && monthIndex <= endOffset) { if (impact.direction === 'add') { baseMonthlyIncome += impact.amount; } else { extraImpactsThisMonth += impact.amount; } } } }); /************************************************ * 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); } let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth; 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 = Math.max(loanBalance - principalForMonth, 0); totalMonthlyExpenses += (monthlyLoanPayment + extraPayment); } } let leftover = netMonthlyIncome - totalMonthlyExpenses; // baseline contributions const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution; let effectiveRetirementContribution = 0; let effectiveEmergencyContribution = 0; if (leftover >= baselineContributions) { effectiveRetirementContribution = monthlyRetirementContribution; effectiveEmergencyContribution = monthlyEmergencyContribution; leftover -= baselineContributions; } 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; // leftover -= shortfall; // if you want negative leftover } // Surplus => leftover if (leftover > 0) { const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation; const emergPortion = leftover * (surplusEmergencyAllocation / totalPct); const retPortion = leftover * (surplusRetirementAllocation / totalPct); currentEmergencySavings += emergPortion; currentRetirementSavings += retPortion; } // net savings const netSavings = netMonthlyIncome - actualExpensesPaid; projectionData.push({ 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: +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), }; }