dev1/src/utils/FinancialProjectionService.js

442 lines
15 KiB
JavaScript

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),
};
}