← 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,
  };
}