Simulator update for retirement

This commit is contained in:
Josh 2025-06-24 16:10:20 +00:00
parent 2f2a6860f4
commit 38088ac38b
4 changed files with 202 additions and 66 deletions

View File

@ -216,8 +216,9 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date, projected_end_date,
college_enrollment_status, college_enrollment_status,
currently_working, currently_working,
// The new field:
career_goals, career_goals,
retirement_start_date,
desired_retirement_income_monthly,
// planned fields // planned fields
planned_monthly_expenses, planned_monthly_expenses,
@ -248,7 +249,9 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date, projected_end_date,
college_enrollment_status, college_enrollment_status,
currently_working, currently_working,
career_goals, -- ADD THIS career_goals,
retirement_start_date,
desired_retirement_income_monthly,
planned_monthly_expenses, planned_monthly_expenses,
planned_monthly_debt_payments, planned_monthly_debt_payments,
planned_monthly_retirement_contribution, planned_monthly_retirement_contribution,
@ -257,14 +260,16 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
planned_surplus_retirement_pct, planned_surplus_retirement_pct,
planned_additional_income planned_additional_income
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
status = VALUES(status), status = VALUES(status),
start_date = VALUES(start_date), start_date = VALUES(start_date),
projected_end_date = VALUES(projected_end_date), projected_end_date = VALUES(projected_end_date),
college_enrollment_status = VALUES(college_enrollment_status), college_enrollment_status = VALUES(college_enrollment_status),
currently_working = VALUES(currently_working), currently_working = VALUES(currently_working),
career_goals = VALUES(career_goals), -- ADD THIS career_goals = VALUES(career_goals),
retirement_start_date = VALUES(retirement_start_date),
desired_retirement_income_monthly = VALUES(desired_retirement_income_monthly),
planned_monthly_expenses = VALUES(planned_monthly_expenses), planned_monthly_expenses = VALUES(planned_monthly_expenses),
planned_monthly_debt_payments = VALUES(planned_monthly_debt_payments), planned_monthly_debt_payments = VALUES(planned_monthly_debt_payments),
planned_monthly_retirement_contribution = VALUES(planned_monthly_retirement_contribution), planned_monthly_retirement_contribution = VALUES(planned_monthly_retirement_contribution),
@ -285,7 +290,9 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
projected_end_date || null, projected_end_date || null,
college_enrollment_status || null, college_enrollment_status || null,
currently_working || null, currently_working || null,
career_goals || null, // pass career_goals here career_goals || null,
retirement_start_date || null,
desired_retirement_income_monthly || null,
planned_monthly_expenses ?? null, planned_monthly_expenses ?? null,
planned_monthly_debt_payments ?? null, planned_monthly_debt_payments ?? null,
planned_monthly_retirement_contribution ?? null, planned_monthly_retirement_contribution ?? null,

View File

@ -286,6 +286,13 @@ export default function ScenarioContainer({
surplusEmergencyAllocation: scenarioOverrides.surplusEmergencyAllocation, surplusEmergencyAllocation: scenarioOverrides.surplusEmergencyAllocation,
surplusRetirementAllocation: scenarioOverrides.surplusRetirementAllocation, surplusRetirementAllocation: scenarioOverrides.surplusRetirementAllocation,
additionalIncome: scenarioOverrides.additionalIncome, additionalIncome: scenarioOverrides.additionalIncome,
retirement_start_date: localScenario.retirement_start_date
|| localScenario.projected_end_date
|| null,
desired_retirement_income_monthly: parseScenarioOverride(
localScenario.desired_retirement_income_monthly,
0
),
studentLoanAmount: collegeData.studentLoanAmount, studentLoanAmount: collegeData.studentLoanAmount,
interestRate: collegeData.interestRate, interestRate: collegeData.interestRate,
@ -931,6 +938,8 @@ export default function ScenarioContainer({
show={showScenarioModal} show={showScenarioModal}
onClose={() => setShowScenarioModal(false)} onClose={() => setShowScenarioModal(false)}
scenario={localScenario} scenario={localScenario}
collegeProfile={collegeProfile}
financialProfile={financialProfile}
onSave={handleScenarioSave} onSave={handleScenarioSave}
/> />

View File

@ -13,7 +13,8 @@ export default function ScenarioEditModal({
show, show,
onClose, onClose,
scenario, scenario,
collegeProfile collegeProfile,
financialProfile
}) { }) {
/********************************************************* /*********************************************************
* 1) CIP / IPEDS data states * 1) CIP / IPEDS data states
@ -128,53 +129,58 @@ export default function ScenarioEditModal({
const s = scenario || {}; const s = scenario || {};
const c = collegeProfile || {}; const c = collegeProfile || {};
const safe = v =>
v === null || v === undefined ? '' : v;
setFormData({ setFormData({
// scenario portion // scenario portion
scenario_title: s.scenario_title || '', scenario_title : safe(s.scenario_title),
career_name: s.career_name || '', career_name : safe(s.career_name),
status: s.status || 'planned', status : safe(s.status || 'planned'),
start_date: s.start_date || '', start_date : safe(s.start_date),
projected_end_date: s.projected_end_date || '', projected_end_date : safe(s.projected_end_date),
college_enrollment_status: s.college_enrollment_status || 'not_enrolled', retirement_start_date: safe(
currently_working: s.currently_working || 'no', (s.retirement_start_date || s.projected_end_date || '')
.toString() // handles Date objects
.substring(0, 10) // keep YYYY-MM-DD
),
desired_retirement_income_monthly :
safe(s.desired_retirement_income_monthly
?? financialProfile?.monthly_expenses),
planned_monthly_expenses: s.planned_monthly_expenses ?? null, planned_monthly_expenses : safe(s.planned_monthly_expenses),
planned_monthly_debt_payments: s.planned_monthly_debt_payments ?? null, planned_monthly_debt_payments : safe(s.planned_monthly_debt_payments),
planned_monthly_retirement_contribution: planned_monthly_retirement_contribution: safe(s.planned_monthly_retirement_contribution),
s.planned_monthly_retirement_contribution ?? null, planned_monthly_emergency_contribution : safe(s.planned_monthly_emergency_contribution),
planned_monthly_emergency_contribution: planned_surplus_emergency_pct : safe(s.planned_surplus_emergency_pct),
s.planned_monthly_emergency_contribution ?? null, planned_surplus_retirement_pct : safe(s.planned_surplus_retirement_pct),
planned_surplus_emergency_pct: s.planned_surplus_emergency_pct ?? null, planned_additional_income : safe(s.planned_additional_income),
planned_surplus_retirement_pct: s.planned_surplus_retirement_pct ?? null,
planned_additional_income: s.planned_additional_income ?? null,
// college portion // college portion
college_profile_id: c.id || null, college_profile_id: safe(c.id || null),
selected_school: c.selected_school || '', selected_school: safe(c.selected_school || ''),
selected_program: c.selected_program || '', selected_program: safe(c.selected_program || ''),
program_type: c.program_type || '', program_type: safe(c.program_type || ''),
academic_calendar: c.academic_calendar || 'monthly', academic_calendar: safe(c.academic_calendar || 'monthly'),
is_in_state: !!c.is_in_state, is_in_state: safe(!!c.is_in_state),
is_in_district: !!c.is_in_district, is_in_district: safe(!!c.is_in_district),
is_online: !!c.is_online, is_online: safe(!!c.is_online),
college_enrollment_status_db: c.college_enrollment_status || 'not_enrolled', college_enrollment_status_db: safe(c.college_enrollment_status || 'not_enrolled'),
annual_financial_aid: c.annual_financial_aid ?? '', annual_financial_aid : safe(c.annual_financial_aid),
existing_college_debt: c.existing_college_debt ?? '', existing_college_debt : safe(c.existing_college_debt),
tuition_paid: c.tuition_paid ?? 0, tuition_paid : safe(c.tuition_paid),
loan_deferral_until_graduation: !!c.loan_deferral_until_graduation, loan_term : safe(c.loan_term ?? 10),
loan_term: c.loan_term ?? 10, interest_rate : safe(c.interest_rate ?? 5),
interest_rate: c.interest_rate ?? 5, extra_payment : safe(c.extra_payment),
extra_payment: c.extra_payment ?? 0, credit_hours_per_year: safe(c.credit_hours_per_year ?? ''),
credit_hours_per_year: c.credit_hours_per_year ?? '', hours_completed: safe(c.hours_completed ?? ''),
hours_completed: c.hours_completed ?? '', program_length: safe(c.program_length ?? ''),
program_length: c.program_length ?? '', credit_hours_required: safe(c.credit_hours_required ?? ''),
credit_hours_required: c.credit_hours_required ?? '', enrollment_date: safe(c.enrollment_date ? c.enrollment_date.substring(0, 10): ''),
enrollment_date: c.enrollment_date ? c.enrollment_date.substring(0, 10): '', expected_graduation: safe(c.expected_graduation ? c.expected_graduation.substring(0, 10): ''),
expected_graduation: c.expected_graduation ? c.expected_graduation.substring(0, 10): '', expected_salary: safe(c.expected_salary ?? '')
expected_salary: c.expected_salary ?? ''
}); });
// Manual / auto tuition // Manual / auto tuition
@ -479,6 +485,8 @@ async function handleSave() {
status : s(formData.status), status : s(formData.status),
start_date : s(formData.start_date), start_date : s(formData.start_date),
projected_end_date : s(formData.projected_end_date), projected_end_date : s(formData.projected_end_date),
retirement_start_date : s(formData.retirement_start_date),
desired_retirement_income_monthly : n(formData.desired_retirement_income_monthly),
planned_monthly_expenses : n(formData.planned_monthly_expenses), planned_monthly_expenses : n(formData.planned_monthly_expenses),
planned_monthly_debt_payments : n(formData.planned_monthly_debt_payments), planned_monthly_debt_payments : n(formData.planned_monthly_debt_payments),
@ -650,6 +658,41 @@ async function handleSave() {
</div> </div>
</div> </div>
{/* Retirement date */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Planned Retirement Date
</label>
<input
type="date"
name="retirement_start_date"
value={formData.retirement_start_date}
onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full"
/>
</div>
{/* Desired retirement income (monthly) */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Desired Retirement Income (monthly $)
</label>
<input
type="number"
name="desired_retirement_income_monthly"
value={formData.desired_retirement_income_monthly}
onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full"
/>
{formData.desired_retirement_income_monthly && (
<p className="text-xs text-gray-500">
$
{(formData.desired_retirement_income_monthly*12)
.toLocaleString()} per year
</p>
)}
</div>
{/* College Enrollment Status */} {/* College Enrollment Status */}
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
@ -694,6 +737,7 @@ async function handleSave() {
type="number" type="number"
name="planned_monthly_expenses" name="planned_monthly_expenses"
value={formData.planned_monthly_expenses} value={formData.planned_monthly_expenses}
placeholder={financialProfile?.monthly_expenses ?? ''}
onChange={handleFormChange} onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full" className="border border-gray-300 rounded p-2 w-full"
/> />
@ -706,6 +750,7 @@ async function handleSave() {
type="number" type="number"
name="planned_monthly_debt_payments" name="planned_monthly_debt_payments"
value={formData.planned_monthly_debt_payments} value={formData.planned_monthly_debt_payments}
placeholder={financialProfile?.monthly_debt_payments ?? ''}
onChange={handleFormChange} onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full" className="border border-gray-300 rounded p-2 w-full"
/> />
@ -718,6 +763,7 @@ async function handleSave() {
type="number" type="number"
name="planned_monthly_retirement_contribution" name="planned_monthly_retirement_contribution"
value={formData.planned_monthly_retirement_contribution} value={formData.planned_monthly_retirement_contribution}
placeholder={financialProfile?.retirement_contribution ?? ''}
onChange={handleFormChange} onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full" className="border border-gray-300 rounded p-2 w-full"
/> />
@ -730,6 +776,7 @@ async function handleSave() {
type="number" type="number"
name="planned_monthly_emergency_contribution" name="planned_monthly_emergency_contribution"
value={formData.planned_monthly_emergency_contribution} value={formData.planned_monthly_emergency_contribution}
placeholder={financialProfile?.emergency_contribution ?? ''}
onChange={handleFormChange} onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full" className="border border-gray-300 rounded p-2 w-full"
/> />
@ -742,6 +789,7 @@ async function handleSave() {
type="number" type="number"
name="planned_surplus_emergency_pct" name="planned_surplus_emergency_pct"
value={formData.planned_surplus_emergency_pct} value={formData.planned_surplus_emergency_pct}
placeholder={financialProfile?.extra_cash_emergency_pct ?? ''}
onChange={handleFormChange} onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full" className="border border-gray-300 rounded p-2 w-full"
/> />
@ -754,6 +802,7 @@ async function handleSave() {
type="number" type="number"
name="planned_surplus_retirement_pct" name="planned_surplus_retirement_pct"
value={formData.planned_surplus_retirement_pct} value={formData.planned_surplus_retirement_pct}
placeholder={financialProfile?.extra_cash_retirement_pct ?? ''}
onChange={handleFormChange} onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full" className="border border-gray-300 rounded p-2 w-full"
/> />
@ -766,6 +815,7 @@ async function handleSave() {
type="number" type="number"
name="planned_additional_income" name="planned_additional_income"
value={formData.planned_additional_income} value={formData.planned_additional_income}
placeholder={financialProfile?.additional_income ?? ''}
onChange={handleFormChange} onChange={handleFormChange}
className="border border-gray-300 rounded p-2 w-full" className="border border-gray-300 rounded p-2 w-full"
/> />

View File

@ -96,6 +96,7 @@ function calculateLoanPayment(principal, annualRate, years) {
); );
} }
/*************************************************** /***************************************************
* MAIN SIMULATION FUNCTION * MAIN SIMULATION FUNCTION
***************************************************/ ***************************************************/
@ -112,8 +113,10 @@ const {
currentSalary: _currentSalary = 0, currentSalary: _currentSalary = 0,
monthlyExpenses: _monthlyExpenses = 0, monthlyExpenses: _monthlyExpenses = 0,
monthlyDebtPayments: _monthlyDebtPayments = 0, monthlyDebtPayments: _monthlyDebtPayments = 0,
partTimeIncome: _partTimeIncome = 0, additionalIncome: _additionalIncome = 0,
extraPayment: _extraPayment = 0, extraPayment: _extraPayment = 0,
desired_retirement_income_monthly : _retSpend = 0,
retirement_start_date : _retStart,
// Student-loan config ---------------------------------------- // Student-loan config ----------------------------------------
studentLoanAmount: _studentLoanAmount = 0, studentLoanAmount: _studentLoanAmount = 0,
@ -163,7 +166,7 @@ const {
const currentSalary = num(_currentSalary); const currentSalary = num(_currentSalary);
const monthlyExpenses = num(_monthlyExpenses); const monthlyExpenses = num(_monthlyExpenses);
const monthlyDebtPayments = num(_monthlyDebtPayments); const monthlyDebtPayments = num(_monthlyDebtPayments);
const partTimeIncome = num(_partTimeIncome); const additionalIncome = num(_additionalIncome);
const extraPayment = num(_extraPayment); const extraPayment = num(_extraPayment);
const studentLoanAmount = num(_studentLoanAmount); const studentLoanAmount = num(_studentLoanAmount);
@ -195,6 +198,17 @@ const randomRangeMax = num(_randomRangeMax);
* Safe suffix distinguishes the sanitized values. * Safe suffix distinguishes the sanitized values.
* -------------------------------------------------*/ * -------------------------------------------------*/
/***************************************************
* 2) CLAMP THE SCENARIO START TO MONTH-BEGIN
***************************************************/
const scenarioStartClamped = moment(startDate || new Date()).startOf('month');
const retirementSpendMonthly = num(_retSpend);
const retirementStartISO =
_retStart ? moment(_retStart).startOf('month')
: scenarioStartClamped.clone().add(simulationYears,'years'); // fallback
/*************************************************** /***************************************************
* HELPER: Retirement Interest Rate * HELPER: Retirement Interest Rate
***************************************************/ ***************************************************/
@ -219,10 +233,27 @@ function getMonthlyInterestRate() {
} }
/*************************************************** /***************************************************
* 2) CLAMP THE SCENARIO START TO MONTH-BEGIN * 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;
}
const scenarioStartClamped = moment(startDate || new Date()).startOf('month');
/*************************************************** /***************************************************
* 3) DETERMINE PROGRAM LENGTH (credit hours) * 3) DETERMINE PROGRAM LENGTH (credit hours)
@ -327,6 +358,10 @@ function getMonthlyInterestRate() {
for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) { for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months'); const currentSimDate = scenarioStartClamped.clone().add(monthIndex, 'months');
const reachedRetirement =
retirementSpendMonthly > 0 &&
currentSimDate.isSameOrAfter(retirementStartISO);
// figure out if we are in the college window // figure out if we are in the college window
let stillInCollege = false; let stillInCollege = false;
if (inCollege && enrollmentDateObj && graduationDateObj) { if (inCollege && enrollmentDateObj && graduationDateObj) {
@ -359,15 +394,18 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
} }
/************************************************ /************************************************
* 7.2 BASE MONTHLY INCOME * 7.2 BASE MONTHLY INCOME (salary -or- retirement draw)
************************************************/ ************************************************/
let baseMonthlyIncome = 0; let baseMonthlyIncome = 0;
if (!stillInCollege) {
// user out of college => expected or current if (reachedRetirement) {
const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings);
currentRetirementSavings -= withdrawal;
baseMonthlyIncome += withdrawal;
} else if (!stillInCollege) {
baseMonthlyIncome = (expectedSalary || currentSalary) / 12; baseMonthlyIncome = (expectedSalary || currentSalary) / 12;
} else { } else {
// in college => might have partTimeIncome + current baseMonthlyIncome = (currentSalary / 12) + (additionalIncome / 12);
baseMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12);
} }
/************************************************ /************************************************
@ -430,17 +468,25 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
stateYTDtax += monthlyStateTax; stateYTDtax += monthlyStateTax;
/************************************************ /************************************************
* 7.5 LOAN + EXPENSES * 7.5 WITHDRAW FROM RETIREMENT
************************************************/ ************************************************/
// Check if we're now exiting college & deferral ended => recalc monthlyLoanPayment
const nowExitingCollege = wasInDeferral && !stillInCollege; if (reachedRetirement && retirementSpendMonthly > 0) {
if (nowExitingCollege) { const withdrawal = Math.min(retirementSpendMonthly, currentRetirementSavings);
// recalc monthlyLoanPayment with the current loanBalance currentRetirementSavings -= withdrawal;
monthlyLoanPayment = calculateLoanPayment(loanBalance, interestRate, loanTerm); // 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 // sum up all monthly expenses
let totalMonthlyExpenses = monthlyExpenses + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth; let totalMonthlyExpenses =
livingCost + monthlyDebtPayments + tuitionCostThisMonth + extraImpactsThisMonth;
if (stillInCollege && loanDeferralUntilGraduation) { if (stillInCollege && loanDeferralUntilGraduation) {
// accumulate interest only // accumulate interest only
@ -460,13 +506,15 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
let leftover = netMonthlyIncome - totalMonthlyExpenses; let leftover = netMonthlyIncome - totalMonthlyExpenses;
// baseline contributions // baseline contributions
const baselineContributions = monthlyRetirementContribution + monthlyEmergencyContribution; const monthlyRetContrib = reachedRetirement ? 0 : monthlyRetirementContribution;
const monthlyEmergContrib = reachedRetirement ? 0 : monthlyEmergencyContribution;
const baselineContributions = monthlyRetContrib + monthlyEmergContrib;
let effectiveRetirementContribution = 0; let effectiveRetirementContribution = 0;
let effectiveEmergencyContribution = 0; let effectiveEmergencyContribution = 0;
if (leftover >= baselineContributions) { if (leftover >= baselineContributions) {
effectiveRetirementContribution = monthlyRetirementContribution; effectiveRetirementContribution = monthlyRetContrib;
effectiveEmergencyContribution = monthlyEmergencyContribution; effectiveEmergencyContribution = monthlyEmergContrib;
leftover -= baselineContributions; leftover -= baselineContributions;
} }
@ -543,9 +591,31 @@ baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM'); loanPaidOffMonth = scenarioStartClamped.clone().add(maxMonths, 'months').format('YYYY-MM');
} }
/* ---- 8) RETIREMENT DRAWDOWN ---------------------------------- */
const monthlySpend = retirementSpendMonthly || 0;
const monthsCovered = monthlySpend > 0
? simulateDrawdown({
startingBalance : currentRetirementSavings,
monthlySpend,
interestStrategy,
flatAnnualRate,
randomRangeMin,
randomRangeMax,
monthlyReturnSamples
})
: 0;
const yearsCovered = Math.round(monthsCovered / 12);
/* ---- 9) RETURN ------------------------------------------------ */
return { return {
projectionData, projectionData,
loanPaidOffMonth, loanPaidOffMonth,
yearsCovered, // <-- add these two
monthsCovered, // (or just yearsCovered if thats all you need)
finalEmergencySavings: +currentEmergencySavings.toFixed(2), finalEmergencySavings: +currentEmergencySavings.toFixed(2),
finalRetirementSavings: +currentRetirementSavings.toFixed(2), finalRetirementSavings: +currentRetirementSavings.toFixed(2),
finalLoanBalance: +loanBalance.toFixed(2), finalLoanBalance: +loanBalance.toFixed(2),