← back to StudentLoanTracker

src/lib/calculators/ibr-calculator.ts

85 lines

/**
 * IBR Plan Calculator (Income-Based Repayment)
 *
 * Two variants:
 * - New IBR (loans after July 1, 2014): 10% of discretionary income, 20-year forgiveness
 * - Old IBR (loans before July 1, 2014): 15% of discretionary income, 25-year forgiveness
 *
 * Key rules:
 * - Discretionary income = AGI - 150% of poverty guideline
 * - Payment capped at 10-year Standard payment
 * - Must demonstrate partial financial hardship to enroll
 * - Interest capitalizes when leaving IBR or losing hardship status
 */

import { LoanInputs, PlanResult, MonthlySnapshot } from './types';
import { getDiscretionaryIncome, getPovertyGuideline, POVERTY_MULTIPLIERS } from './poverty-guidelines';

export function calculateIBR(inputs: LoanInputs, isNewBorrower: boolean = true): PlanResult {
  const { totalBalance, weightedRate, agi, familySize, state } = inputs;

  const paymentRate = isNewBorrower ? 0.10 : 0.15;
  const forgivenessMonths = isNewBorrower ? 240 : 300; // 20 or 25 years

  const povertyGuideline = getPovertyGuideline(familySize, state);
  const discretionaryIncome = getDiscretionaryIncome(
    agi, familySize, state, POVERTY_MULTIPLIERS.IBR
  );

  const monthlyPayment = Math.max(0, (discretionaryIncome * paymentRate) / 12);

  // 10-year Standard payment cap
  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 — IBR DOES capitalize unpaid interest (unlike SAVE)
  let balance = totalBalance;
  let totalPaid = 0;
  let totalInterest = 0;
  const schedule: MonthlySnapshot[] = [];

  for (let month = 1; month <= forgivenessMonths; month++) {
    const interestThisMonth = balance * monthlyRate;
    const payment = Math.min(cappedPayment, balance + interestThisMonth);
    const principalPaid = payment - interestThisMonth;

    totalPaid += payment;
    totalInterest += interestThisMonth;

    if (principalPaid >= 0) {
      balance = Math.max(0, balance - principalPaid);
    } else {
      // Unpaid interest capitalizes (adds to balance)
      balance += Math.abs(principalPaid);
    }

    schedule.push({
      month,
      payment: Math.round(payment * 100) / 100,
      principal: Math.round(Math.max(0, principalPaid) * 100) / 100,
      interest: Math.round(interestThisMonth * 100) / 100,
      balance: Math.round(balance * 100) / 100,
    });

    if (balance <= 0) break;
  }

  return {
    planName: isNewBorrower ? 'IBR (New)' : 'IBR (Old)',
    monthlyPayment: Math.round(cappedPayment * 100) / 100,
    totalPaid: Math.round(totalPaid * 100) / 100,
    totalInterest: Math.round(totalInterest * 100) / 100,
    forgivenessAmount: Math.round(Math.max(0, balance) * 100) / 100,
    forgivenessMonths,
    repaymentMonths: schedule.length,
    discretionaryIncome: Math.round(discretionaryIncome * 100) / 100,
    povertyGuideline,
    paymentSchedule: schedule,
  };
}