← back to StudentLoanTracker
src/lib/calculators/save-calculator.ts
106 lines
/**
* SAVE Plan Calculator (Saving on a Valuable Education)
*
* Key rules:
* - Discretionary income = AGI - 225% of poverty guideline
* - Undergraduate: 5% of discretionary income ÷ 12
* - Graduate: 10% of discretionary income ÷ 12 (blended for mixed loans)
* - Payment capped: never more than 10-year Standard payment
* - Forgiveness: 20 years (undergrad only) or 25 years (any grad loans)
* - Interest subsidy: if payment < monthly interest, unpaid interest does NOT capitalize
* - Balances ≤ $12,000: eligible for forgiveness after 10 years
*/
import { LoanInputs, PlanResult, MonthlySnapshot } from './types';
import { getDiscretionaryIncome, getPovertyGuideline, POVERTY_MULTIPLIERS } from './poverty-guidelines';
const SAVE_PAYMENT_RATE_UNDERGRAD = 0.05;
const SAVE_PAYMENT_RATE_GRAD = 0.10;
const FORGIVENESS_MONTHS_UNDERGRAD = 240; // 20 years
const FORGIVENESS_MONTHS_GRAD = 300; // 25 years
const EARLY_FORGIVENESS_THRESHOLD = 12000;
const EARLY_FORGIVENESS_MONTHS = 120; // 10 years
export function calculateSAVE(inputs: LoanInputs): PlanResult {
const { totalBalance, weightedRate, agi, familySize, state } = inputs;
// Assume undergraduate for simplicity; could be extended with loan-type breakdown
const hasGradLoans = inputs.individualLoans?.some(
(l) => l.type === 'grad_plus'
) ?? false;
const paymentRate = hasGradLoans
? SAVE_PAYMENT_RATE_GRAD
: SAVE_PAYMENT_RATE_UNDERGRAD;
const forgivenessMonths = hasGradLoans
? FORGIVENESS_MONTHS_GRAD
: FORGIVENESS_MONTHS_UNDERGRAD;
// Early forgiveness for small balances
const effectiveForgivenessMonths =
totalBalance <= EARLY_FORGIVENESS_THRESHOLD
? EARLY_FORGIVENESS_MONTHS
: forgivenessMonths;
const povertyGuideline = getPovertyGuideline(familySize, state);
const discretionaryIncome = getDiscretionaryIncome(
agi, familySize, state, POVERTY_MULTIPLIERS.SAVE
);
// Monthly payment = paymentRate% of discretionary income / 12
const monthlyPayment = Math.max(0, (discretionaryIncome * paymentRate) / 12);
// Cap at 10-year standard payment
const monthlyRate = weightedRate / 12;
const standardPayment =
monthlyRate > 0
? (totalBalance * monthlyRate) / (1 - Math.pow(1 + monthlyRate, -120))
: totalBalance / 120;
const cappedPayment = Math.min(monthlyPayment, standardPayment);
// Simulate month-by-month (SAVE: unpaid interest does NOT capitalize)
let balance = totalBalance;
let totalPaid = 0;
let totalInterest = 0;
const schedule: MonthlySnapshot[] = [];
for (let month = 1; month <= effectiveForgivenessMonths; month++) {
const interestThisMonth = balance * monthlyRate;
const payment = Math.min(cappedPayment, balance + interestThisMonth);
const principalPaid = Math.max(0, payment - interestThisMonth);
totalPaid += payment;
totalInterest += interestThisMonth;
// SAVE benefit: unpaid interest does NOT get added to balance
balance = Math.max(0, balance - principalPaid);
schedule.push({
month,
payment: Math.round(payment * 100) / 100,
principal: Math.round(principalPaid * 100) / 100,
interest: Math.round(interestThisMonth * 100) / 100,
balance: Math.round(balance * 100) / 100,
});
if (balance <= 0) break;
}
const forgivenessAmount = Math.max(0, balance);
return {
planName: 'SAVE',
monthlyPayment: Math.round(cappedPayment * 100) / 100,
totalPaid: Math.round(totalPaid * 100) / 100,
totalInterest: Math.round(totalInterest * 100) / 100,
forgivenessAmount: Math.round(forgivenessAmount * 100) / 100,
forgivenessMonths: effectiveForgivenessMonths,
repaymentMonths: schedule.length,
discretionaryIncome: Math.round(discretionaryIncome * 100) / 100,
povertyGuideline,
paymentSchedule: schedule,
};
}