446 lines
15 KiB
JavaScript
446 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 = [],
|
|
|
|
// Simulation duration
|
|
simulationYears = 20
|
|
|
|
} = 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 = simulationYears*12; // 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),
|
|
};
|
|
}
|