669 lines
26 KiB
JavaScript
669 lines
26 KiB
JavaScript
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)
|
||
};
|
||
}
|