import moment from 'moment'; /** ------------------------------------------------- * Utility: coerce ANY input to a safe number * - Empty string, null, undefined, NaN → 0 * - Valid numeric string → Number(value) * - Already-a-number → Number(value) * Keeps everything finite so .toFixed() is safe * -------------------------------------------------*/ const n = (val, fallback = 0) => { const num = Number(val); return Number.isFinite(num) ? num : fallback; }; const num = (v) => v === null || v === undefined || v === '' || Number.isNaN(+v) ? 0 : +v; /*************************************************** * 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) ***************************************************/ 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) ***************************************************/ 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) Show userProfile at the start /*************************************************** * 1) DESTRUCTURE USER PROFILE ***************************************************/ /* 1️⃣ Destructure exactly the same keys you already use … */ /* but add an underscore so we can soon normalise each one. */ const { // Basic incomes ---------------------------------------------- currentSalary: _currentSalary = 0, monthlyExpenses: _monthlyExpenses = 0, monthlyDebtPayments: _monthlyDebtPayments = 0, additionalIncome: _additionalIncome = 0, extraPayment: _extraPayment = 0, desired_retirement_income_monthly : _retSpend = 0, retirement_start_date : _retStart, // Student-loan config ---------------------------------------- studentLoanAmount: _studentLoanAmount = 0, existing_college_debt: _existingCollegeDebt = 0, interestRate: _interestRate = 5, loanTerm: _loanTerm = 10, loanDeferralUntilGraduation = false, // College ---------------------------------------------------- inCollege = false, collegeEnrollmentStatus = 'none', programType, hoursCompleted: _hoursCompleted = 0, creditHoursPerYear: _creditHoursPerYear, calculatedTuition: _calculatedTuition, enrollmentDate, gradDate, startDate, academicCalendar = 'monthly', annualFinancialAid: _annualFinancialAid = 0, // Post-college salary ---------------------------------------- expectedSalary: _expectedSalary = 0, // Savings & contributions ------------------------------------ emergencySavings: _emergencySavings = 0, retirementSavings: _retirementSavings = 0, monthlyRetirementContribution:_monthlyRetirementContribution = 0, monthlyEmergencyContribution:_monthlyEmergencyContribution = 0, // Surplus distribution --------------------------------------- surplusEmergencyAllocation: _surplusEmergencyAllocation = 50, surplusRetirementAllocation: _surplusRetirementAllocation = 50, // Other ------------------------------------------------------- programLength, stateCode = 'GA', milestoneImpacts = [], simulationYears = 20, interestStrategy = 'NONE', flatAnnualRate: _flatAnnualRate = 0.06, monthlyReturnSamples = [], randomRangeMin: _randomRangeMin = -0.02, randomRangeMax: _randomRangeMax = 0.02 } = userProfile; /* 2️⃣ Immediately convert every money/percentage/count field to a real Number */ const currentSalary = num(_currentSalary); const monthlyExpenses = num(_monthlyExpenses); const monthlyDebtPayments = num(_monthlyDebtPayments); const additionalIncome = num(_additionalIncome); const extraPayment = num(_extraPayment); const studentLoanAmount = num(_studentLoanAmount); const existingCollegeDebt = num(_existingCollegeDebt); const interestRate = num(_interestRate); const loanTerm = num(_loanTerm); const isProgrammeActive = ['enrolled', 'graduated'].includes(collegeEnrollmentStatus); const hoursCompleted = num(_hoursCompleted); const creditHoursPerYear = num(_creditHoursPerYear); const calculatedTuition = num(_calculatedTuition); const annualFinancialAid = num(_annualFinancialAid); const expectedSalary = num(_expectedSalary); const emergencySavings = num(_emergencySavings); const retirementSavings = num(_retirementSavings); const monthlyRetirementContribution= num(_monthlyRetirementContribution); const monthlyEmergencyContribution = num(_monthlyEmergencyContribution); const surplusEmergencyAllocation = num(_surplusEmergencyAllocation); const surplusRetirementAllocation = num(_surplusRetirementAllocation); const flatAnnualRate = num(_flatAnnualRate); const randomRangeMin = num(_randomRangeMin); const randomRangeMax = num(_randomRangeMax); /* ------------------------------------------------- * Use the “…Safe” variables below instead of the * raw ones whenever you’ll call .toFixed() or do * arithmetic. All names are preserved; only the * “Safe” suffix distinguishes the sanitized values. * -------------------------------------------------*/ /*************************************************** * 2) CLAMP THE SCENARIO START TO MONTH-BEGIN ***************************************************/ const scenarioStartClamped = moment().startOf('month'); // always “today” let reachedRetirement = false; // flip to true once we hit the month let firstRetirementBalance = null; // snapshot of balance in that first month const retirementSpendMonthly = num(_retSpend); const retirementStartISO = _retStart ? moment(_retStart).startOf('month') : scenarioStartClamped.clone().add(simulationYears,'years'); // fallback /*************************************************** * HELPER: Retirement Interest Rate ***************************************************/ function getMonthlyInterestRate() { // e.g. a switch or if-else: if (interestStrategy === 'NONE') { return 0; } else if (interestStrategy === 'FLAT') { // e.g. 6% annual => 0.5% per month return flatAnnualRate / 12; } else if (interestStrategy === 'MONTE_CARLO') { // if using a random range or historical sample if (monthlyReturnSamples.length > 0) { const idx = Math.floor(Math.random() * monthlyReturnSamples.length); return monthlyReturnSamples[idx]; // already monthly } else { const range = randomRangeMax - randomRangeMin; return randomRangeMin + (Math.random() * range); } } return 0; // fallback } /*************************************************** * HELPER: Retirement draw ***************************************************/ function simulateDrawdown(opts){ const { startingBalance, monthlySpend, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax, monthlyReturnSamples } = opts; let bal = startingBalance, m = 0; while (bal > 0 && m < 1200){ const r = getMonthlyInterestRate(); bal = bal*(1+r) - monthlySpend; m++; } return m; } /*************************************************** * 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; const enrollmentStart = enrollmentDate ? moment(enrollmentDate).startOf('month') : (startDate ? moment(startDate).startOf('month') : scenarioStartClamped.clone()); const creditsPerYear = creditHoursPerYear || 30; const creditsRemaining = Math.max(0, requiredCreditHours - hoursCompleted); const monthsRemaining = Math.ceil((creditsRemaining / Math.max(1, creditsPerYear)) * 12); const gradDateEffective = gradDate ? moment(gradDate).startOf('month') : enrollmentStart.clone().add(monthsRemaining, 'months'); /*************************************************** * 4) TUITION CALC ***************************************************/ 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) ***************************************************/ const initialLoanPrincipal = studentLoanAmount + existingCollegeDebt; let monthlyLoanPayment = loanDeferralUntilGraduation ? 0 : calculateLoanPayment(initialLoanPrincipal, interestRate, loanTerm); /*************************************************** * 6) SETUP FOR THE SIMULATION LOOP ***************************************************/ const maxMonths = simulationYears * 12; let loanBalance = initialLoanPrincipal; let loanPaidOffMonth = null; let currentEmergencySavings = emergencySavings; let currentRetirementSavings = retirementSavings; let projectionData = []; // Track YTD gross & tax let fedYTDgross = 0; let fedYTDtax = 0; let stateYTDgross = 0; let stateYTDtax = 0; // We'll keep track that we started in deferral if inCollege & deferral is true let wasInDeferral = inCollege && loanDeferralUntilGraduation; // If there's a gradDate, let's see if we pass it: const graduationDateObj = gradDate ? moment(gradDate).startOf('month') : null; const enrollmentDateObj = enrollmentDate ? moment(enrollmentDate).startOf('month') : scenarioStartClamped.clone(); // fallback /*************************************************** * 7) THE MONTHLY LOOP ***************************************************/ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) { const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months'); if (!reachedRetirement && currentSimDate.isSameOrAfter(retirementStartISO)) { reachedRetirement = true; firstRetirementBalance = currentRetirementSavings; // capture once } const isRetiredThisMonth = retirementSpendMonthly > 0 && currentSimDate.isSameOrAfter(retirementStartISO) // figure out if we are in the college window const stillInCollege = inCollege && currentSimDate.isSameOrAfter(enrollmentStart) && currentSimDate.isBefore(gradDateEffective); const hasGraduated = currentSimDate.isSameOrAfter(gradDateEffective.clone().add(1, 'month')); /************************************************ * 7.1 TUITION lumps ************************************************/ let tuitionCostThisMonth = 0; if (stillInCollege && lumpsPerYear > 0) { const monthsSinceEnroll = Math.max(0, currentSimDate.diff(enrollmentStart, 'months')); const academicYearIndex = Math.floor(monthsSinceEnroll / 12); const monthInAcadYear = monthsSinceEnroll % 12; if (lumpsSchedule.includes(monthInAcadYear) && 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 (salary -or- retirement draw) ************************************************/ let baseMonthlyIncome = 0; if (reachedRetirement) { const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings); currentRetirementSavings -= withdrawal; baseMonthlyIncome += withdrawal; } else if (!stillInCollege) { const monthlyFromJob = (hasGraduated && expectedSalary > 0 ? expectedSalary : currentSalary) / 12; baseMonthlyIncome = monthlyFromJob + (additionalIncome / 12); } else { baseMonthlyIncome = (currentSalary / 12) + (additionalIncome / 12); } /************************************************ * 7.3 MILESTONE IMPACTS – safe number handling ************************************************/ let extraImpactsThisMonth = 0; // affects expenses let salaryAdjustThisMonth = 0; // affects gross income milestoneImpacts.forEach((rawImpact) => { /* ---------- 1. Normalise ---------- */ const amount = Number(rawImpact.amount) || 0; const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // SALARY / SALARY_ANNUAL / MONTHLY / ONE_TIME const direction = (rawImpact.direction || 'subtract').toLowerCase(); // add / subtract /* ---------- 2. Work out timing ---------- */ const startDate = moment(rawImpact.start_date).startOf('month'); const endDate = rawImpact.end_date ? moment(rawImpact.end_date).startOf('month') : null; const startOffset = Math.max(0, startDate.diff(scenarioStartClamped, 'months')); const endOffset = endDate ? Math.max(0, endDate.diff(scenarioStartClamped, 'months')) : Infinity; const isActiveThisMonth = (type === 'ONE_TIME' && monthIndex === startOffset) || (type !== 'ONE_TIME' && monthIndex >= startOffset && monthIndex <= endOffset); if (!isActiveThisMonth) return; // skip to next impact /* ---------- 3. Apply the impact ---------- */ if (type.startsWith('SALARY')) { // ─── salary changes affect GROSS income ─── const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount; const salarySign = direction === 'add' ? 1 : -1; // unchanged salaryAdjustThisMonth += salarySign * monthlyDelta; } else { // ─── everything else is an expense or windfall ─── // “Add” ⇒ money coming *in* ⇒ LOWER expenses // “Subtract” ⇒ money going *out* ⇒ HIGHER expenses const expenseSign = direction === 'add' ? -1 : 1; extraImpactsThisMonth += expenseSign * amount; } }); /* ---------- 4. Reflect deltas in this month’s calc ---------- */ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax // `extraImpactsThisMonth` is already added to expenses later in the loop /************************************************ * 7.4 CALCULATE TAXES ************************************************/ const monthlyFederalTax = calculateMonthlyFedTaxNoYTD(baseMonthlyIncome); const monthlyStateTax = calculateMonthlyStateTaxNoYTD(baseMonthlyIncome, stateCode); const combinedTax = monthlyFederalTax + monthlyStateTax; // net after tax const netMonthlyIncome = baseMonthlyIncome - combinedTax; // increment YTD for reference fedYTDgross += baseMonthlyIncome; fedYTDtax += monthlyFederalTax; stateYTDgross += baseMonthlyIncome; stateYTDtax += monthlyStateTax; /************************************************ * 7.5 WITHDRAW FROM RETIREMENT ************************************************/ // From here on we’ll use `livingCost` instead of `monthlyExpenses` const livingCost = reachedRetirement ? retirementSpendMonthly : monthlyExpenses; // Leaving deferral → begin repayment using current balance if (loanDeferralUntilGraduation && wasInDeferral && !stillInCollege) { monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, loanTerm); } /************************************************ * 7.6 LOAN + EXPENSES ************************************************/ // sum up all monthly expenses let totalMonthlyExpenses = livingCost + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth; if (stillInCollege && loanDeferralUntilGraduation) { // accumulate interest only const interestForMonth = loanBalance * (interestRate / 100 / 12); loanBalance += interestForMonth; } else { // pay principal if (loanBalance > 0) { const interestForMonth = loanBalance * (interestRate / 100 / 12); const totalThisMonth = monthlyLoanPayment + extraPayment; const principalForMonth = Math.min(loanBalance, totalThisMonth - interestForMonth); loanBalance = Math.max(loanBalance - principalForMonth, 0); totalMonthlyExpenses += totalThisMonth; } } if (loanBalance <= 0 && !loanPaidOffMonth) { loanPaidOffMonth = currentSimDate.format('YYYY-MM'); } let leftover = netMonthlyIncome - totalMonthlyExpenses; const canSaveThisMonth = leftover > 0; // baseline contributions const monthlyRetContrib = canSaveThisMonth && !isRetiredThisMonth ? monthlyRetirementContribution : 0; const monthlyEmergContrib = canSaveThisMonth && !isRetiredThisMonth ? monthlyEmergencyContribution : 0; const baselineContributions = monthlyRetContrib + monthlyEmergContrib; let effectiveRetirementContribution = 0; let effectiveEmergencyContribution = 0; if (leftover >= baselineContributions) { effectiveRetirementContribution = monthlyRetContrib; effectiveEmergencyContribution = monthlyEmergContrib; currentRetirementSavings += effectiveRetirementContribution; currentEmergencySavings += effectiveEmergencyContribution; 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; } // Surplus => leftover if (canSaveThisMonth && leftover > 0) { const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation; const emergPortion = leftover * (surplusEmergencyAllocation / totalPct); const retPortion = leftover * (surplusRetirementAllocation / totalPct); currentEmergencySavings += emergPortion; currentRetirementSavings += retPortion; } const monthlyReturnRate = getMonthlyInterestRate(); if (monthlyReturnRate !== 0) { currentRetirementSavings *= (1 + monthlyReturnRate); } const netSavings = netMonthlyIncome - actualExpensesPaid; // (UPDATED) add inCollege, stillInCollege, loanDeferralUntilGraduation to the result projectionData.push({ month: currentSimDate.format('YYYY-MM'), inCollege, // new stillInCollege, // new loanDeferralUntilGraduation, // new 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), // If you want to show the new running values, // you can keep them as is or store them: emergencySavings: (typeof currentEmergencySavings === 'number') ? +currentEmergencySavings.toFixed(2) : currentEmergencySavings, retirementSavings: (typeof currentRetirementSavings === 'number') ? +currentRetirementSavings.toFixed(2) : currentRetirementSavings, loanBalance: +loanBalance.toFixed(2), loanPaymentThisMonth: +(monthlyLoanPayment + extraPayment).toFixed(2), totalSavings: (currentEmergencySavings + currentRetirementSavings).toFixed(2), fedYTDgross: +fedYTDgross.toFixed(2), fedYTDtax: +fedYTDtax.toFixed(2), stateYTDgross: +stateYTDgross.toFixed(2), stateYTDtax: +stateYTDtax.toFixed(2) }); wasInDeferral = (stillInCollege && loanDeferralUntilGraduation); } if (!firstRetirementBalance && reachedRetirement) { firstRetirementBalance = currentRetirementSavings; } /* ---- 8) RETIREMENT DRAWDOWN ---------------------------------- */ const monthlySpend = retirementSpendMonthly || 0; const monthsCovered = monthlySpend > 0 ? simulateDrawdown({ startingBalance : firstRetirementBalance ?? currentRetirementSavings, monthlySpend, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax, monthlyReturnSamples }) : 0; const yearsCovered = Math.round(monthsCovered / 12); /* ---- 9) RETURN ------------------------------------------------ */ /* 9.1  »  Readiness Score (0-100, whole-number) */ const safeWithdrawalRate = flatAnnualRate; // 6 % for now (plug 0.04 if you prefer) const projectedAnnualFlow = (firstRetirementBalance ?? currentRetirementSavings) * safeWithdrawalRate; const desiredAnnualIncome = retirementSpendMonthly // user override ? retirementSpendMonthly * 12 : monthlyExpenses * 12; // fallback to current spend const readinessScore = desiredAnnualIncome > 0 ? Math.min( 100, Math.round((projectedAnnualFlow / desiredAnnualIncome) * 100) ) : 0; return { projectionData, loanPaidOffMonth, readinessScore, retirementAtMilestone : firstRetirementBalance ?? 0, yearsCovered, // <-- add these two monthsCovered, // (or just yearsCovered if that’s all you need) finalEmergencySavings: +currentEmergencySavings.toFixed(2), finalRetirementSavings: +currentRetirementSavings.toFixed(2), finalLoanBalance: +loanBalance.toFixed(2), fedYTDgross: +fedYTDgross.toFixed(2), fedYTDtax: +fedYTDtax.toFixed(2), stateYTDgross: +stateYTDgross.toFixed(2), stateYTDtax: +stateYTDtax.toFixed(2) }; }