dev1/src/utils/FinancialProjectionService.js
2025-06-26 15:43:49 +00:00

657 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
interestRate: _interestRate = 5,
loanTerm: _loanTerm = 10,
loanDeferralUntilGraduation = false,
// College ----------------------------------------------------
inCollege = false,
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 interestRate = num(_interestRate);
const loanTerm = num(_loanTerm);
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 youll 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(
interestStrategy, flatAnnualRate,
randomRangeMin, randomRangeMax,
monthlyReturnSamples);
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;
/***************************************************
* 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)
***************************************************/
let monthlyLoanPayment = loanDeferralUntilGraduation
? 0
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
// Log the initial loan info:
console.log("Initial loan payment setup:", {
studentLoanAmount,
interestRate,
loanTerm,
loanDeferralUntilGraduation,
monthlyLoanPayment
});
/***************************************************
* 6) SETUP FOR THE SIMULATION LOOP
***************************************************/
const maxMonths = simulationYears * 12;
let loanBalance = Math.max(studentLoanAmount, 0);
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
let stillInCollege = false;
if (inCollege && enrollmentDateObj && graduationDateObj) {
stillInCollege = currentSimDate.isSameOrAfter(enrollmentDateObj)
&& currentSimDate.isBefore(graduationDateObj);
if (inCollege && gradDate) {
stillInCollege =
currentSimDate.isSameOrAfter(enrollmentDateObj) &&
currentSimDate.isBefore(graduationDateObj);
}
}
/************************************************
* 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 (salary -or- retirement draw)
************************************************/
let baseMonthlyIncome = 0;
if (reachedRetirement) {
const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings);
currentRetirementSavings -= withdrawal;
baseMonthlyIncome += withdrawal;
} else if (!stillInCollege) {
baseMonthlyIncome = (expectedSalary || currentSalary) / 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 ---------- */
const sign = direction === 'add' ? 1 : -1;
if (type.startsWith('SALARY')) {
// SALARY = already-monthly | SALARY_ANNUAL = annual → divide by 12
const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount;
salaryAdjustThisMonth += sign * monthlyDelta;
} else {
// MONTHLY or ONE_TIME expenses / windfalls
extraImpactsThisMonth += sign * amount;
}
});
/* ---------- 4. Reflect deltas in this months 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
************************************************/
if (reachedRetirement && retirementSpendMonthly > 0) {
const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings);
currentRetirementSavings -= withdrawal;
// Treat the withdrawal like (taxable) income so the cash-flow works out
baseMonthlyIncome += withdrawal;
}
// From here on well use `livingCost` instead of `monthlyExpenses`
const livingCost = reachedRetirement ? retirementSpendMonthly : monthlyExpenses;
/************************************************
* 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;
}
}
let leftover = netMonthlyIncome - totalMonthlyExpenses;
// baseline contributions
const monthlyRetContrib = isRetiredThisMonth ? 0 : monthlyRetirementContribution;
const monthlyEmergContrib = isRetiredThisMonth ? 0 : monthlyEmergencyContribution;
const baselineContributions = monthlyRetContrib + monthlyEmergContrib;
let effectiveRetirementContribution = 0;
let effectiveEmergencyContribution = 0;
if (leftover >= baselineContributions) {
effectiveRetirementContribution = monthlyRetContrib;
effectiveEmergencyContribution = monthlyEmergContrib;
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 (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;
}
// final loanPaidOffMonth if never set
if (loanBalance <= 0 && !loanPaidOffMonth) {
loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM');
}
/* ---- 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 thats 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)
};
}