Added taxes to simulation and fixed LoanBalance calculation from inCollege logic.

This commit is contained in:
Josh 2025-04-17 15:51:55 +00:00
parent ab7e318492
commit 2f9dc03f57
3 changed files with 217 additions and 151 deletions

View File

@ -27,7 +27,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData, careerPathId })
loan_term = 10, loan_term = 10,
extra_payment = '', extra_payment = '',
expected_salary = '', expected_salary = '',
is_in_state = true, is_in_state = false,
is_in_district = false, is_in_district = false,
loan_deferral_until_graduation = false, loan_deferral_until_graduation = false,
credit_hours_per_year = '', credit_hours_per_year = '',
@ -335,6 +335,46 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData, careerPathId })
{(college_enrollment_status === 'currently_enrolled' || {(college_enrollment_status === 'currently_enrolled' ||
college_enrollment_status === 'prospective_student') ? ( college_enrollment_status === 'prospective_student') ? (
<> <>
<label>
<input
type="checkbox"
name="is_in_district"
checked={is_in_district}
onChange={handleParentFieldChange}
/>
In District?
</label>
<label>
<input
type="checkbox"
name="is_in_state"
checked={is_in_state}
onChange={handleParentFieldChange}
/>
In State Tuition?
</label>
<label>
<input
type="checkbox"
name="is_online"
checked={is_online}
onChange={handleParentFieldChange}
/>
Program is Fully Online
</label>
<label>
<input
type="checkbox"
name="loan_deferral_until_graduation"
checked={loan_deferral_until_graduation}
onChange={handleParentFieldChange}
/>
Defer Loan Payments until Graduation?
</label>
<label>School Name*</label> <label>School Name*</label>
<input <input
name="selected_school" name="selected_school"
@ -509,45 +549,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData, careerPathId })
placeholder="e.g. 65000" placeholder="e.g. 65000"
/> />
<label>
<input
type="checkbox"
name="is_in_district"
checked={is_in_district}
onChange={handleParentFieldChange}
/>
In District?
</label>
<label>
<input
type="checkbox"
name="is_in_state"
checked={is_in_state}
onChange={handleParentFieldChange}
/>
In State Tuition?
</label>
<label>
<input
type="checkbox"
name="is_online"
checked={is_online}
onChange={handleParentFieldChange}
/>
Program is Fully Online
</label>
<label>
<input
type="checkbox"
name="loan_deferral_until_graduation"
checked={loan_deferral_until_graduation}
onChange={handleParentFieldChange}
/>
Defer Loan Payments until Graduation?
</label>
</> </>
) : ( ) : (
<p>Not currently enrolled or prospective student. Skipping college onboarding.</p> <p>Not currently enrolled or prospective student. Skipping college onboarding.</p>

View File

@ -1,12 +1,70 @@
import moment from 'moment'; import moment from 'moment';
// Example fields in userProfile that matter here: /**
// - academicCalendar: 'semester' | 'quarter' | 'trimester' | 'monthly' * Single-filer federal tax calculation (2023).
// - annualFinancialAid: amount of scholarships/grants per year * Includes standard deduction ($13,850).
// - inCollege, loanDeferralUntilGraduation, graduationDate, etc. * If you need to update the brackets/deduction, edit here.
// */
// Additional logic now for lumps instead of monthly tuition payments. function calculateAnnualFederalTaxSingle(annualIncome) {
// 1. Subtract standard deduction
const STANDARD_DEDUCTION_SINGLE = 13850;
const taxableIncome = Math.max(0, annualIncome - STANDARD_DEDUCTION_SINGLE);
// 2. Define bracket thresholds & rates for single filers (2023)
// The 'limit' is the upper bound for that bracket segment.
// Use Infinity for the top bracket.
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;
// 3. Accumulate tax across brackets
for (let i = 0; i < brackets.length; i++) {
const { limit, rate } = brackets[i];
if (taxableIncome <= limit) {
// only tax the portion within this bracket
tax += (taxableIncome - lastLimit) * rate;
break;
} else {
// tax the entire bracket range, then continue
tax += (limit - lastLimit) * rate;
lastLimit = limit;
}
}
return tax;
}
/**
* 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))
);
}
/**
* Main projection function with bracket-based tax logic for single filers.
*/
export function simulateFinancialProjection(userProfile) { export function simulateFinancialProjection(userProfile) {
const { const {
// Income & expenses // Income & expenses
@ -18,8 +76,8 @@ export function simulateFinancialProjection(userProfile) {
// Loan info // Loan info
studentLoanAmount = 0, studentLoanAmount = 0,
interestRate = 5, // % interestRate = 5, // %
loanTerm = 10, // years loanTerm = 10, // years
loanDeferralUntilGraduation = false, loanDeferralUntilGraduation = false,
// College & tuition // College & tuition
@ -27,10 +85,10 @@ export function simulateFinancialProjection(userProfile) {
programType, programType,
hoursCompleted = 0, hoursCompleted = 0,
creditHoursPerYear, creditHoursPerYear,
calculatedTuition, // e.g. annual tuition calculatedTuition,
gradDate, // known graduation date, or null gradDate,
startDate, // when sim starts startDate,
academicCalendar = 'monthly', // new academicCalendar = 'monthly',
annualFinancialAid = 0, annualFinancialAid = 0,
// Salary after graduation // Salary after graduation
@ -45,15 +103,17 @@ export function simulateFinancialProjection(userProfile) {
monthlyEmergencyContribution = 0, monthlyEmergencyContribution = 0,
// Surplus allocation // Surplus allocation
surplusEmergencyAllocation = 50, surplusEmergencyAllocation = 50,
surplusRetirementAllocation = 50, surplusRetirementAllocation = 50,
// Potential override // Potential override
programLength programLength
} = userProfile; } = userProfile;
// 1. Calculate standard monthly loan payment // 1. Calculate standard monthly loan payment (if not deferring)
const monthlyLoanPayment = calculateLoanPayment(studentLoanAmount, interestRate, loanTerm); let monthlyLoanPayment = loanDeferralUntilGraduation
? 0
: calculateLoanPayment(studentLoanAmount, interestRate, loanTerm);
// 2. Determine how many credit hours remain // 2. Determine how many credit hours remain
let requiredCreditHours = 120; let requiredCreditHours = 120;
@ -83,14 +143,11 @@ export function simulateFinancialProjection(userProfile) {
// 4. Setup lumps per year based on academicCalendar // 4. Setup lumps per year based on academicCalendar
let lumpsPerYear = 12; // monthly fallback let lumpsPerYear = 12; // monthly fallback
let lumpsSchedule = []; // which months from start of academic year let lumpsSchedule = [];
// We'll store an array of month offsets in a single year (0-based)
// for semester, quarter, trimester
switch (academicCalendar) { switch (academicCalendar) {
case 'semester': case 'semester':
lumpsPerYear = 2; lumpsPerYear = 2;
lumpsSchedule = [0, 6]; // months 0 & 6 from start of each academic year lumpsSchedule = [0, 6];
break; break;
case 'quarter': case 'quarter':
lumpsPerYear = 4; lumpsPerYear = 4;
@ -106,102 +163,132 @@ export function simulateFinancialProjection(userProfile) {
lumpsSchedule = [...Array(12).keys()]; // 0..11 lumpsSchedule = [...Array(12).keys()]; // 0..11
break; break;
} }
// Each academic year is 12 months, for finalProgramLength years => totalAcademicMonths
const totalAcademicMonths = finalProgramLength * 12; const totalAcademicMonths = finalProgramLength * 12;
// Each lump sum = totalTuitionCost / (lumpsPerYear * finalProgramLength)
const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength); const lumpAmount = totalTuitionCost / (lumpsPerYear * finalProgramLength);
// 5. We'll loop for up to 20 years // 5. Simulation loop up to 20 years
const maxMonths = 240; const maxMonths = 240;
let date = startDate ? new Date(startDate) : new Date(); let date = startDate ? new Date(startDate) : new Date();
let loanBalance = studentLoanAmount;
let loanBalance = Math.max(studentLoanAmount, 0);
let loanPaidOffMonth = null; let loanPaidOffMonth = null;
let currentEmergencySavings = emergencySavings; let currentEmergencySavings = emergencySavings;
let currentRetirementSavings = retirementSavings; let currentRetirementSavings = retirementSavings;
let projectionData = [];
// Convert gradDate to actual if present let projectionData = [];
let wasInDeferral = inCollege && loanDeferralUntilGraduation;
// If gradDate is provided, parse it to Date
const graduationDate = gradDate ? new Date(gradDate) : null; const graduationDate = gradDate ? new Date(gradDate) : null;
// Keep a map of year => { ytdGross, ytdTaxSoFar } for bracket-based taxes
const taxStateByYear = {};
for (let month = 0; month < maxMonths; month++) { for (let month = 0; month < maxMonths; month++) {
date.setMonth(date.getMonth() + 1); date.setMonth(date.getMonth() + 1);
const currentYear = date.getFullYear();
// If loan is fully paid, record if not done already // If loan is fully paid, record if not already done
if (loanBalance <= 0 && !loanPaidOffMonth) { if (loanBalance <= 0 && !loanPaidOffMonth) {
loanPaidOffMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; loanPaidOffMonth = `${currentYear}-${String(date.getMonth() + 1).padStart(2, '0')}`;
} }
// Are we still in college? We either trust gradDate or approximate finalProgramLength // Determine if user is still in college (trusted gradDate or approximate)
let stillInCollege = false; let stillInCollege = false;
if (inCollege) { if (inCollege) {
if (graduationDate) { if (graduationDate) {
stillInCollege = date < graduationDate; stillInCollege = date < graduationDate;
} else { } else {
// approximate by how many months since start
const simStart = startDate ? new Date(startDate) : new Date(); const simStart = startDate ? new Date(startDate) : new Date();
const elapsedMonths = const elapsedMonths =
(date.getFullYear() - simStart.getFullYear()) * 12 + (date.getFullYear() - simStart.getFullYear()) * 12 +
(date.getMonth() - simStart.getMonth()); (date.getMonth() - simStart.getMonth());
stillInCollege = (elapsedMonths < totalAcademicMonths); stillInCollege = (elapsedMonths < totalAcademicMonths);
} }
console.log(`MONTH ${month} start: inCollege=${stillInCollege}, loanBal=${loanBalance}`);
} }
// 6. If we pay lumps: check if this is a "lump" month within the user's academic year // 6. Check if we owe tuition lumps this month
// We'll find how many academic years have passed since they started
let tuitionCostThisMonth = 0; let tuitionCostThisMonth = 0;
if (stillInCollege && lumpsPerYear > 0) { if (stillInCollege && lumpsPerYear > 0) {
const simStart = startDate ? new Date(startDate) : new Date(); const simStart = startDate ? new Date(startDate) : new Date();
const elapsedMonths = const elapsedMonths =
(date.getFullYear() - simStart.getFullYear()) * 12 + (date.getFullYear() - simStart.getFullYear()) * 12 +
(date.getMonth() - simStart.getMonth()); (date.getMonth() - simStart.getMonth());
// Which academic year index are we in?
const academicYearIndex = Math.floor(elapsedMonths / 12); const academicYearIndex = Math.floor(elapsedMonths / 12);
// Within that year, which month are we in? (0..11)
const monthInYear = elapsedMonths % 12; const monthInYear = elapsedMonths % 12;
console.log(" lumps logic check: academicYearIndex=", academicYearIndex, "monthInYear=", monthInYear);
// If we find monthInYear in lumpsSchedule, then lumps are due
if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) { if (lumpsSchedule.includes(monthInYear) && academicYearIndex < finalProgramLength) {
tuitionCostThisMonth = lumpAmount; tuitionCostThisMonth = lumpAmount;
} }
} }
// 7. Decide if user defers or pays out of pocket // 7. Detect if we are now exiting college this month
// If deferring, add lumps to loan const nowExitingCollege = (wasInDeferral && !stillInCollege);
// 8. If in deferral, lumps get added to the loan principal
if (stillInCollege && loanDeferralUntilGraduation) { if (stillInCollege && loanDeferralUntilGraduation) {
console.log(" deferral is on, lumps => loan?");
// Instead of user paying out of pocket, add to loan
if (tuitionCostThisMonth > 0) { if (tuitionCostThisMonth > 0) {
console.log(" tuitionCostThisMonth=", tuitionCostThisMonth);
loanBalance += tuitionCostThisMonth; loanBalance += tuitionCostThisMonth;
tuitionCostThisMonth = 0; // paid by the loan tuitionCostThisMonth = 0;
} }
} }
// 8. monthly income // 9. Gross monthly income depends on college status
let monthlyIncome = 0; let grossMonthlyIncome = 0;
if (!inCollege || !stillInCollege) { if (!inCollege || !stillInCollege) {
// user has graduated or never in college // Graduated or never was in college => use expectedSalary if given
monthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12; grossMonthlyIncome = (expectedSalary > 0 ? expectedSalary : currentSalary) / 12;
} else { } else {
// in college => currentSalary + partTimeIncome // Still in college => currentSalary + part-time
monthlyIncome = (currentSalary / 12) + (partTimeIncome / 12); grossMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12);
} }
// 9. mandatory expenses (excluding student loan if deferring) /**
* 10. Calculate monthly TAX via bracket-based approach:
* We track year-to-date (YTD) for the current calendar year.
*/
if (!taxStateByYear[currentYear]) {
taxStateByYear[currentYear] = { ytdGross: 0, ytdTaxSoFar: 0 };
}
// Add this months gross to YTD
taxStateByYear[currentYear].ytdGross += grossMonthlyIncome;
// Calculate total tax for YTD
const annualTaxSoFar = calculateAnnualFederalTaxSingle(
taxStateByYear[currentYear].ytdGross
);
// This months tax = (new YTD tax) - (old YTD tax)
const monthlyTax = annualTaxSoFar - taxStateByYear[currentYear].ytdTaxSoFar;
// Update YTD tax
taxStateByYear[currentYear].ytdTaxSoFar = annualTaxSoFar;
// Net monthly income after tax
const netMonthlyIncome = grossMonthlyIncome - monthlyTax;
// 11. Monthly expenses (excluding student loan if deferring)
let thisMonthLoanPayment = 0; let thisMonthLoanPayment = 0;
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth; let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth;
// Re-amortize if we're just now exiting college
if (nowExitingCollege) {
monthlyLoanPayment = calculateLoanPayment(
loanBalance,
interestRate,
10 // fresh 10-year term post-college
);
}
// 12. If still deferring, just accrue interest
if (stillInCollege && loanDeferralUntilGraduation) { if (stillInCollege && loanDeferralUntilGraduation) {
// Accrue interest only
const interestForMonth = loanBalance * (interestRate / 100 / 12); const interestForMonth = loanBalance * (interestRate / 100 / 12);
loanBalance += interestForMonth; loanBalance += interestForMonth;
} else { } else {
// Normal loan repayment if loan > 0 // Normal repayment if loan > 0
if (loanBalance > 0) { if (loanBalance > 0) {
const interestForMonth = loanBalance * (interestRate / 100 / 12); const interestForMonth = loanBalance * (interestRate / 100 / 12);
const principalForMonth = Math.min( const principalForMonth = Math.min(
@ -210,16 +297,15 @@ export function simulateFinancialProjection(userProfile) {
); );
loanBalance -= principalForMonth; loanBalance -= principalForMonth;
loanBalance = Math.max(loanBalance, 0); loanBalance = Math.max(loanBalance, 0);
thisMonthLoanPayment = monthlyLoanPayment + extraPayment; thisMonthLoanPayment = monthlyLoanPayment + extraPayment;
totalMonthlyExpenses += thisMonthLoanPayment; totalMonthlyExpenses += thisMonthLoanPayment;
} }
} }
// 10. leftover after mandatory expenses // 13. leftover after mandatory expenses
let leftover = monthlyIncome - totalMonthlyExpenses; let leftover = netMonthlyIncome - totalMonthlyExpenses;
if (leftover < 0) { if (leftover < 0) leftover = 0;
leftover = 0; // can't do partial negative leftover; they simply can't afford it
}
// Baseline monthly contributions // Baseline monthly contributions
const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution; const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution;
@ -231,91 +317,69 @@ export function simulateFinancialProjection(userProfile) {
effectiveEmergencyContribution = monthlyEmergencyContribution; effectiveEmergencyContribution = monthlyEmergencyContribution;
leftover -= baselineContributions; leftover -= baselineContributions;
} else { } else {
// not enough leftover // Not enough leftover => zero out contributions
// 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; effectiveRetirementContribution = 0;
effectiveEmergencyContribution = 0; effectiveEmergencyContribution = 0;
} }
// 11. Now see if leftover is negative => shortfall from mandatory expenses // Check for shortfall vs. 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 totalWantedContributions = effectiveRetirementContribution + effectiveEmergencyContribution;
const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions; const actualExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
let shortfall = actualExpensesPaid - monthlyIncome; // if positive => can't pay let shortfall = actualExpensesPaid - netMonthlyIncome;
console.log(" end of month: loanBal=", loanBalance, " shortfall=", shortfall);
if (shortfall > 0) { if (shortfall > 0) {
console.log(" Breaking out - bankrupt scenario"); // Attempt to cover from emergency savings
const canCover = Math.min(shortfall, currentEmergencySavings); const canCover = Math.min(shortfall, currentEmergencySavings);
currentEmergencySavings -= canCover; currentEmergencySavings -= canCover;
shortfall -= canCover; shortfall -= canCover;
if (shortfall > 0) { if (shortfall > 0) {
// Even after emergency, we can't cover => break (bankrupt scenario)
break; break;
} }
} }
// 12. If leftover > 0 after baseline contributions, allocate surplus // 14. Surplus allocation if leftover > 0
// (we do it after we've handled shortfall) const newLeftover = leftover;
const newLeftover = leftover; // leftover not used for baseline
let surplusUsed = 0;
if (newLeftover > 0) { if (newLeftover > 0) {
// Allocate by percent // Allocate by percentage
const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation; const totalPct = surplusEmergencyAllocation + surplusRetirementAllocation;
const emergencyPortion = newLeftover * (surplusEmergencyAllocation / totalPct); const emergencyPortion = newLeftover * (surplusEmergencyAllocation / totalPct);
const retirementPortion = newLeftover * (surplusRetirementAllocation / totalPct); const retirementPortion = newLeftover * (surplusRetirementAllocation / totalPct);
currentEmergencySavings += emergencyPortion; currentEmergencySavings += emergencyPortion;
currentRetirementSavings += retirementPortion; currentRetirementSavings += retirementPortion;
surplusUsed = newLeftover;
} }
// 13. netSavings is monthlyIncome - actual expenses - all contributions // 15. netSavings for the month (could be leftover minus contributions, etc.)
// But we must recalc actual final expenses paid // But we already subtracted everything from netMonthlyIncome except what ended up surplus
const finalExpensesPaid = totalMonthlyExpenses + (effectiveRetirementContribution + effectiveEmergencyContribution); const finalExpensesPaid = totalMonthlyExpenses + totalWantedContributions;
const netSavings = monthlyIncome - finalExpensesPaid; const netSavings = netMonthlyIncome - finalExpensesPaid;
// Record in the projection data
projectionData.push({ projectionData.push({
month: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`, month: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`,
monthlyIncome, grossMonthlyIncome: Math.round(grossMonthlyIncome * 100) / 100,
totalExpenses: finalExpensesPaid, monthlyTax: Math.round(monthlyTax * 100) / 100,
effectiveRetirementContribution, netMonthlyIncome: Math.round(netMonthlyIncome * 100) / 100,
effectiveEmergencyContribution, totalExpenses: Math.round(finalExpensesPaid * 100) / 100,
netSavings, effectiveRetirementContribution: Math.round(effectiveRetirementContribution * 100) / 100,
emergencySavings: currentEmergencySavings, effectiveEmergencyContribution: Math.round(effectiveEmergencyContribution * 100) / 100,
retirementSavings: currentRetirementSavings, netSavings: Math.round(netSavings * 100) / 100,
emergencySavings: Math.round(currentEmergencySavings * 100) / 100,
retirementSavings: Math.round(currentRetirementSavings * 100) / 100,
loanBalance: Math.round(loanBalance * 100) / 100, loanBalance: Math.round(loanBalance * 100) / 100,
loanPaymentThisMonth: thisMonthLoanPayment loanPaymentThisMonth: Math.round(thisMonthLoanPayment * 100) / 100
}); });
// Update deferral flag for next iteration
wasInDeferral = (stillInCollege && loanDeferralUntilGraduation);
} }
// Return final // Return the final output
return { return {
projectionData, projectionData,
loanPaidOffMonth, loanPaidOffMonth,
finalEmergencySavings: currentEmergencySavings, finalEmergencySavings: Math.round(currentEmergencySavings * 100) / 100,
finalRetirementSavings: currentRetirementSavings, finalRetirementSavings: Math.round(currentRetirementSavings * 100) / 100,
finalLoanBalance: Math.round(loanBalance * 100) / 100 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))
);
}

Binary file not shown.