319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
import moment from 'moment';
|
|
|
|
// Example fields in userProfile that matter here:
|
|
// - academicCalendar: 'semester' | 'quarter' | 'trimester' | 'monthly'
|
|
// - annualFinancialAid: amount of scholarships/grants per year
|
|
// - inCollege, loanDeferralUntilGraduation, graduationDate, etc.
|
|
//
|
|
// Additional logic now for lumps instead of monthly tuition payments.
|
|
|
|
export function simulateFinancialProjection(userProfile) {
|
|
const {
|
|
// Income & expenses
|
|
currentSalary = 0,
|
|
monthlyExpenses = 0,
|
|
monthlyDebtPayments = 0,
|
|
partTimeIncome = 0,
|
|
extraPayment = 0,
|
|
|
|
// Loan info
|
|
studentLoanAmount = 0,
|
|
interestRate = 5, // %
|
|
loanTerm = 10, // years
|
|
loanDeferralUntilGraduation = false,
|
|
|
|
// College & tuition
|
|
inCollege = false,
|
|
programType,
|
|
hoursCompleted = 0,
|
|
creditHoursPerYear,
|
|
calculatedTuition, // e.g. annual tuition
|
|
gradDate, // known graduation date, or null
|
|
startDate, // when sim starts
|
|
academicCalendar = 'monthly', // new
|
|
annualFinancialAid = 0,
|
|
|
|
// Salary after graduation
|
|
expectedSalary = 0,
|
|
|
|
// Savings
|
|
emergencySavings = 0,
|
|
retirementSavings = 0,
|
|
|
|
// Monthly contributions
|
|
monthlyRetirementContribution = 0,
|
|
monthlyEmergencyContribution = 0,
|
|
|
|
// Surplus allocation
|
|
surplusEmergencyAllocation = 50,
|
|
surplusRetirementAllocation = 50,
|
|
|
|
// Potential override
|
|
programLength
|
|
} = userProfile;
|
|
|
|
// 1. Calculate standard monthly loan payment
|
|
const monthlyLoanPayment = calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
|
|
|
|
// 2. Determine how many credit hours remain
|
|
let requiredCreditHours = 120;
|
|
switch (programType) {
|
|
case "Associate's Degree":
|
|
requiredCreditHours = 60;
|
|
break;
|
|
case "Bachelor's Degree":
|
|
requiredCreditHours = 120;
|
|
break;
|
|
case "Master's Degree":
|
|
requiredCreditHours = 30;
|
|
break;
|
|
case "Doctoral Degree":
|
|
requiredCreditHours = 60;
|
|
break;
|
|
default:
|
|
requiredCreditHours = 120;
|
|
}
|
|
const remainingCreditHours = Math.max(0, requiredCreditHours - hoursCompleted);
|
|
const dynamicProgramLength = Math.ceil(remainingCreditHours / creditHoursPerYear);
|
|
const finalProgramLength = programLength || dynamicProgramLength;
|
|
|
|
// 3. Net annual tuition after financial aid
|
|
const netAnnualTuition = Math.max(0, calculatedTuition - annualFinancialAid);
|
|
const totalTuitionCost = netAnnualTuition * finalProgramLength;
|
|
|
|
// 4. Setup lumps per year based on academicCalendar
|
|
let lumpsPerYear = 12; // monthly fallback
|
|
let lumpsSchedule = []; // which months from start of academic year
|
|
|
|
// We'll store an array of month offsets in a single year (0-based)
|
|
// for semester, quarter, trimester
|
|
switch (academicCalendar) {
|
|
case 'semester':
|
|
lumpsPerYear = 2;
|
|
lumpsSchedule = [0, 6]; // months 0 & 6 from start of each academic year
|
|
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()]; // 0..11
|
|
break;
|
|
}
|
|
|
|
// Each academic year is 12 months, for finalProgramLength years => totalAcademicMonths
|
|
const totalAcademicMonths = finalProgramLength * 12;
|
|
// Each lump sum = totalTuitionCost / (lumpsPerYear * finalProgramLength)
|
|
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
|
|
|
|
// 5. We'll loop for up to 20 years
|
|
const maxMonths = 240;
|
|
let date = startDate ? new Date(startDate) : new Date();
|
|
let loanBalance = studentLoanAmount;
|
|
let loanPaidOffMonth = null;
|
|
let currentEmergencySavings = emergencySavings;
|
|
let currentRetirementSavings = retirementSavings;
|
|
let projectionData = [];
|
|
|
|
// Convert gradDate to actual if present
|
|
const graduationDate = gradDate ? new Date(gradDate) : null;
|
|
|
|
for (let month = 0; month < maxMonths; month++) {
|
|
date.setMonth(date.getMonth() + 1);
|
|
|
|
// If loan is fully paid, record if not done already
|
|
if (loanBalance <= 0 && !loanPaidOffMonth) {
|
|
loanPaidOffMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
|
}
|
|
|
|
// Are we still in college? We either trust gradDate or approximate finalProgramLength
|
|
let stillInCollege = false;
|
|
if (inCollege) {
|
|
if (graduationDate) {
|
|
stillInCollege = date < graduationDate;
|
|
} else {
|
|
// approximate by how many months since start
|
|
const simStart = startDate ? new Date(startDate) : new Date();
|
|
const elapsedMonths =
|
|
(date.getFullYear() - simStart.getFullYear()) * 12 +
|
|
(date.getMonth() - simStart.getMonth());
|
|
stillInCollege = (elapsedMonths < totalAcademicMonths);
|
|
}
|
|
}
|
|
|
|
// 6. If we pay lumps: check if this is a "lump" month within the user's academic year
|
|
// We'll find how many academic years have passed since they started
|
|
let tuitionCostThisMonth = 0;
|
|
if (stillInCollege && lumpsPerYear > 0) {
|
|
const simStart = startDate ? new Date(startDate) : new Date();
|
|
const elapsedMonths =
|
|
(date.getFullYear() - simStart.getFullYear()) * 12 +
|
|
(date.getMonth() - simStart.getMonth());
|
|
|
|
// Which academic year index are we in?
|
|
const academicYearIndex = Math.floor(elapsedMonths / 12);
|
|
// Within that year, which month are we in? (0..11)
|
|
const monthInYear = elapsedMonths % 12;
|
|
|
|
// If we find monthInYear in lumpsSchedule, then lumps are due
|
|
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) {
|
|
tuitionCostThisMonth = lumpAmount;
|
|
}
|
|
}
|
|
|
|
// 7. Decide if user defers or pays out of pocket
|
|
// If deferring, add lumps to loan
|
|
if (stillInCollege && loanDeferralUntilGraduation) {
|
|
// Instead of user paying out of pocket, add to loan
|
|
if (tuitionCostThisMonth > 0) {
|
|
loanBalance += tuitionCostThisMonth;
|
|
tuitionCostThisMonth = 0; // paid by the loan
|
|
}
|
|
}
|
|
|
|
// 8. monthly income
|
|
let monthlyIncome = 0;
|
|
if (!inCollege || !stillInCollege) {
|
|
// user has graduated or never in college
|
|
monthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
|
|
} else {
|
|
// in college => currentSalary + partTimeIncome
|
|
monthlyIncome = (currentSalary / 12) + (partTimeIncome / 12);
|
|
}
|
|
|
|
// 9. mandatory expenses (excluding student loan if deferring)
|
|
let thisMonthLoanPayment = 0;
|
|
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth;
|
|
|
|
if (stillInCollege && loanDeferralUntilGraduation) {
|
|
// Accrue interest only
|
|
const interestForMonth = loanBalance * (interestRate / 100 / 12);
|
|
loanBalance += interestForMonth;
|
|
} else {
|
|
// Normal loan repayment if loan > 0
|
|
if (loanBalance > 0) {
|
|
const interestForMonth = loanBalance * (interestRate / 100 / 12);
|
|
const principalForMonth = Math.min(
|
|
loanBalance,
|
|
(monthlyLoanPayment + extraPayment) - interestForMonth
|
|
);
|
|
loanBalance -= principalForMonth;
|
|
loanBalance = Math.max(loanBalance, 0);
|
|
thisMonthLoanPayment = monthlyLoanPayment + extraPayment;
|
|
totalMonthlyExpenses += thisMonthLoanPayment;
|
|
}
|
|
}
|
|
|
|
// 10. leftover after mandatory expenses
|
|
let leftover = monthlyIncome - totalMonthlyExpenses;
|
|
if (leftover < 0) {
|
|
leftover = 0; // can't do partial negative leftover; they simply can't afford it
|
|
}
|
|
|
|
// Baseline monthly contributions
|
|
const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution;
|
|
let effectiveRetirementContribution = 0;
|
|
let effectiveEmergencyContribution = 0;
|
|
|
|
if (leftover >= baselineContributions) {
|
|
effectiveRetirementContribution = monthlyRetirementContribution;
|
|
effectiveEmergencyContribution = monthlyEmergencyContribution;
|
|
leftover -= baselineContributions;
|
|
} else {
|
|
// not enough leftover
|
|
// for real life, we typically set them to 0 if we can't afford them
|
|
// or reduce proportionally. We'll do the simpler approach: set them to 0
|
|
// as requested.
|
|
effectiveRetirementContribution = 0;
|
|
effectiveEmergencyContribution = 0;
|
|
}
|
|
|
|
// 11. Now see if leftover is negative => shortfall from mandatory expenses
|
|
// Actually we zeroed leftover if it was negative. So let's check if the user
|
|
// truly can't afford mandatoryExpenses
|
|
const totalMandatoryPlusContrib = monthlyIncome - leftover;
|
|
const totalWantedContributions = effectiveRetirementContribution + effectiveEmergencyContribution;
|
|
const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
|
|
let shortfall = actualExpensesPaid - monthlyIncome; // if positive => can't pay
|
|
if (shortfall > 0) {
|
|
// We can reduce from emergency savings
|
|
const canCover = Math.min(shortfall, currentEmergencySavings);
|
|
currentEmergencySavings -= canCover;
|
|
shortfall -= canCover;
|
|
if (shortfall > 0) {
|
|
// user is effectively bankrupt
|
|
// we can break out or keep going to show negative net worth
|
|
// For demonstration, let's break
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 12. If leftover > 0 after baseline contributions, allocate surplus
|
|
// (we do it after we've handled shortfall)
|
|
const newLeftover = leftover; // leftover not used for baseline
|
|
let surplusUsed = 0;
|
|
if (newLeftover > 0) {
|
|
// Allocate by percent
|
|
const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation;
|
|
const emergencyPortion = newLeftover * (surplusEmergencyAllocation / totalPct);
|
|
const retirementPortion = newLeftover * (surplusRetirementAllocation / totalPct);
|
|
|
|
currentEmergencySavings += emergencyPortion;
|
|
currentRetirementSavings += retirementPortion;
|
|
surplusUsed = newLeftover;
|
|
}
|
|
|
|
// 13. netSavings is monthlyIncome - actual expenses - all contributions
|
|
// But we must recalc actual final expenses paid
|
|
const finalExpensesPaid = totalMonthlyExpenses + (effectiveRetirementContribution + effectiveEmergencyContribution);
|
|
const netSavings = monthlyIncome - finalExpensesPaid;
|
|
|
|
projectionData.push({
|
|
month: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`,
|
|
monthlyIncome,
|
|
totalExpenses: finalExpensesPaid,
|
|
effectiveRetirementContribution,
|
|
effectiveEmergencyContribution,
|
|
netSavings,
|
|
emergencySavings: currentEmergencySavings,
|
|
retirementSavings: currentRetirementSavings,
|
|
loanBalance: Math.round(loanBalance * 100) / 100,
|
|
loanPaymentThisMonth: thisMonthLoanPayment
|
|
});
|
|
}
|
|
|
|
// Return final
|
|
return {
|
|
projectionData,
|
|
loanPaidOffMonth,
|
|
finalEmergencySavings: currentEmergencySavings,
|
|
finalRetirementSavings: currentRetirementSavings,
|
|
finalLoanBalance: Math.round(loanBalance * 100) / 100
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate the standard monthly loan payment for principal, annualRate (%) and term (years)
|
|
*/
|
|
function calculateLoanPayment(principal, annualRate, years) {
|
|
if (principal <= 0) return 0;
|
|
|
|
const monthlyRate = annualRate / 100 / 12;
|
|
const numPayments = years * 12;
|
|
|
|
if (monthlyRate === 0) {
|
|
// no interest
|
|
return principal / numPayments;
|
|
}
|
|
return (
|
|
(principal * monthlyRate) /
|
|
(1 - Math.pow(1 + monthlyRate, -numPayments))
|
|
);
|
|
}
|