From 40e12061c7f2a059ade665cceadb6a9b476a26e0 Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Fri, 23 Oct 2026 14:36:24 -0400 Subject: [PATCH 1/9] Count FFEL debt in spousal proration, use the latest return's AGI, and cap IBR at the couple's standard payment - Add federal_ffel_loan_balance and count eligible FFEL debt in the income-driven repayment balance share (34 CFR 685.209(b)(5), (g)(1)(i)-(ii)) and in the Repayment Assistance Plan's spousal share ((g)(3)(i)). - Add income_driven_repayment_adjusted_gross_income, the AGI on the most recent return (20 U.S.C. 1087e(q)(4)(A)), defaulting to current-year AGI, and use it for RAP, IBR and SAVE. - Cap IBR at the couple's combined 10-year standard payment on eligible Direct and FFEL loans before prorating (685.209(f)(2)-(3), (e)(2)(i)). - Document balance timing, the active-repayment assumption and the tax-dependent restriction. - Add the rulespec-us#1455 companion cases. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../student-loan-idr-restrictions.fixed.md | 1 + policyengine_us/programs.yaml | 6 +- ..._based_repayment_discretionary_income.yaml | 21 ++ .../income_based_repayment_payment.yaml | 163 +++++++++- ...riven_repayment_adjusted_gross_income.yaml | 29 ++ ...income_driven_repayment_balance_share.yaml | 59 +++- ...ome_driven_repayment_standard_payment.yaml | 109 +++++++ .../integration.yaml | 8 +- ...epayment_assistance_plan_base_payment.yaml | 292 +++++++++++++++++- .../repayment_assistance_plan_eligible.yaml | 9 +- .../repayment_assistance_plan_payment.yaml | 116 ++++++- .../save_plan_discretionary_income.yaml | 21 ++ .../save_plan/save_plan_payment.yaml | 27 +- .../federal_ffel_loan_balance.py | 29 ++ .../federal_parent_plus_loan_balance.py | 4 +- .../federal_student_loan_balance.py | 6 +- .../federal_student_loan_interest_rate.py | 3 +- ...me_based_repayment_discretionary_income.py | 10 +- .../income_based_repayment_payment.py | 39 ++- ..._driven_repayment_adjusted_gross_income.py | 28 ++ .../income_driven_repayment_balance_share.py | 36 ++- .../income_driven_repayment_eligible.py | 10 +- ...ncome_driven_repayment_standard_payment.py | 53 ++++ .../repayment_assistance_plan_base_payment.py | 8 +- .../repayment_assistance_plan_eligible.py | 14 +- .../repayment_assistance_plan_payment.py | 25 +- .../save_plan_discretionary_income.py | 10 +- .../save_plan/save_plan_payment.py | 10 +- .../standard_repayment_payment.py | 15 +- .../student_loan_amortization_helpers.py | 20 ++ 30 files changed, 1091 insertions(+), 90 deletions(-) create mode 100644 changelog.d/student-loan-idr-restrictions.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml create mode 100644 policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py create mode 100644 policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py create mode 100644 policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py create mode 100644 policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py diff --git a/changelog.d/student-loan-idr-restrictions.fixed.md b/changelog.d/student-loan-idr-restrictions.fixed.md new file mode 100644 index 00000000000..bfe9e810632 --- /dev/null +++ b/changelog.d/student-loan-idr-restrictions.fixed.md @@ -0,0 +1 @@ +Count FFEL debt in the student loan plans' spousal proration, use the latest return's adjusted gross income, and cap Income-Based Repayment at the couple's combined standard payment. diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 86c8759b02d..82688503296 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1609,7 +1609,7 @@ programs: variable: repayment_assistance_plan_payment parameter_prefix: gov.ed.student_loans.repayment_assistance_plan verified_start_year: 2026 - notes: Monthly payment from July 2026 (base payment, dependent reduction, $10 minimum, spousal proration, final-payment cap) from balance inputs; interest subsidy, principal match and forgiveness not modeled; not subtracted from household net income + notes: Monthly payment from July 2026 (base payment on the latest return's AGI, dependent reduction, $10 minimum, spousal proration over Direct and FFEL debt, final-payment cap) from balance inputs; tax-dependent borrowers treated as ineligible; interest subsidy, principal match and forgiveness not modeled; not subtracted from household net income - id: income_based_repayment name: Income-Based Repayment @@ -1621,7 +1621,7 @@ programs: variable: income_based_repayment_payment parameter_prefix: gov.ed.student_loans.income_based_repayment verified_start_year: 2026 - notes: Monthly payment (15% or 10% for new borrowers of income above 150% of the poverty guideline, spousal proration, 10-year standard payment cap on the current balance, small payment adjustment); bar on borrowers with loans made on or after July 1, 2026, interest and forgiveness not modeled; not subtracted from household net income + notes: Monthly payment (15% or 10% for new borrowers of income above 150% of the poverty guideline, cap at the couple's 10-year standard payment on current Direct and FFEL balances, spousal and FFEL proration, small payment adjustment); tax-dependent borrowers treated as ineligible; bar on borrowers with loans made on or after July 1, 2026, interest and forgiveness not modeled; not subtracted from household net income - id: save_plan name: SAVE plan (counterfactual) @@ -1633,7 +1633,7 @@ programs: variable: save_plan_payment parameter_prefix: gov.ed.student_loans.save_plan verified_start_year: 2026 - notes: Counterfactual monthly payment under 34 CFR 685.209(f)(1) (5% undergraduate and 10% graduate rates on income above 225% of the poverty guideline, spousal proration, small payment adjustment); computed for any period without implying the plan is available; interest and forgiveness not modeled; not subtracted from household net income + notes: Counterfactual monthly payment under 34 CFR 685.209(f)(1) (5% undergraduate and 10% graduate rates on income above 225% of the poverty guideline, spousal and FFEL proration, small payment adjustment); computed for any period without implying the plan is available; interest and forgiveness not modeled; not subtracted from household net income # --- Federal tax credits --- - id: education_tax_credits diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_discretionary_income.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_discretionary_income.yaml index 20f32ed6aec..e1be89b27a4 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_discretionary_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_discretionary_income.yaml @@ -52,3 +52,24 @@ output: # 1.5 x 15,960 = 23,940 > 20,000. income_based_repayment_discretionary_income: 0 + +- name: Case 4, discretionary income uses the latest return's AGI. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + tax_units: + tax_unit: + members: [person1] + # The most recent return shows 40,000; income this year is 80,000. + income_driven_repayment_adjusted_gross_income: 40_000 + adjusted_gross_income: 80_000 + households: + household: + members: [person1] + state_code: TX + output: + # (40,000 - 23,940) / 12 = 1,338.33, as in Case 1. + income_based_repayment_discretionary_income: 1_338.33 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml index 68a74541f60..fe9accb20af 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml @@ -1,8 +1,11 @@ # Monthly payment under 20 U.S.C. 1098e and 34 CFR 685.209(f)(2)-(3), (g)(1): # discretionary income = max(AGI - 1.5 x poverty guideline, 0) -# payment = 15% (new borrowers 10%) x discretionary income / 12 -# x own share of the couple's eligible balance (joint filers) -# capped at the 10-year standard payment; under 5 -> 0; 5 to under 10 -> 10. +# payment = lesser of 15% (new borrowers 10%) x discretionary income / 12 +# and the 10-year standard payment on the eligible Direct and FFEL loans of +# the borrower and, for joint filers, the spouse ((f)(2)-(3), (e)(2)(i)) +# x own Direct balance / couple's Direct and FFEL balance ((g)(1)(i)-(ii)) +# rounded to the cent; under 5 -> 0; 5 to under 10 -> 10; at most the +# borrower's Direct balance. # 2026 guideline in the 48 states: 15,960 + 5,680 per additional person. # Standard payment on 30,000 at 6 percent: 333.06. @@ -393,3 +396,157 @@ # 15% x 399 / 12 = 4.9875, which rounds to 4.99 < 5 -> 0 # (685.209(g)(1)(iii)(A)). income_based_repayment_payment: 0 + +- name: Case 17, spouses with different interest rates are capped together before proration. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0 + person2: + age: 38 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.12 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 80_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # Issue #10411 example. 1.5 x 21,640 = 32,460; 15% x 48,000 / 12 = 600. + # Standard payments: 30,000 / 120 = 250 and + # 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41, so the couple's cap is 680.41. + # min(600, 680.41) = 600, split 50/50: 300 each. Capping each spouse at + # their own standard payment would give 250 + 300 instead. + income_based_repayment_payment: [300, 300] + +- name: Case 18, the couple's combined standard payment caps a high income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0 + person2: + age: 38 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.12 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 96_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 15% x (96,460 - 32,460) / 12 = 800 > 680.4128, so the cap binds: + # 680.4128 x 50% = 340.2064 -> 340.21 each. Separate caps would give + # min(400, 250) + min(400, 430.41) = 250 + 400. + income_based_repayment_payment: [340.21, 340.21] + +- name: Case 19, spouses keep their own new-borrower and other-borrower rates. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + is_income_based_repayment_new_borrower: true + person2: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 92_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # Discretionary income (92,460 - 32,460) / 12 = 5,000. Couple's cap + # 2 x 333.0615 = 666.123. New borrower: min(10% x 5,000, 666.123) x 50% = + # 250. Other borrower: min(15% x 5,000, 666.123) x 50% = 333.06. + income_based_repayment_payment: [250, 333.06] + +- name: Case 20, a spouse's FFEL debt counts in the joint proration and cap. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + federal_student_loan_interest_rate: 0.06 + person2: + # The spouse has only FFEL loans, which this plan does not repay. + age: 38 + federal_ffel_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 60_000 + households: + household: + members: [person1, person2] + state_code: TX + output: + # IBR analogue of rulespec-us#1455 C16. 15% x (60,000 - 32,460) / 12 = + # 344.25, under the cap of 111.02 + 333.06 = 444.08. The borrower pays + # 344.25 x 10,000 / 40,000 = 86.06 (685.209(g)(1)(i)-(ii)); the spouse has + # no Direct Loans and owes nothing under the plan. + income_based_repayment_payment: [86.06, 0] + +- name: Case 21, a borrower's own FFEL loans reduce the Direct Loan payment. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + federal_student_loan_balance: 10_000 + federal_ffel_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 35 + federal_student_loan_balance: 10_000 + federal_ffel_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit1: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 40_000 + tax_unit2: + members: [person2] + filing_status: SINGLE + adjusted_gross_income: 100_000 + households: + household1: + members: [person1] + state_code: TX + household2: + members: [person2] + state_code: TX + output: + # 685.209(g)(1)(ii): x 10,000 / 40,000 = 25%. Cap on all 40,000 of + # eligible loans: 444.08. person1: min(200.75, 444.08) x 25% = 50.19. + # person2: min(950.75, 444.08) x 25% = 111.02. + income_based_repayment_payment: [50.19, 111.02] diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.yaml new file mode 100644 index 00000000000..fca8432fd8d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.yaml @@ -0,0 +1,29 @@ +# AGI on the most recent return (20 U.S.C. 1087e(q)(4)(A)). Without an input it +# defaults to the current year's AGI. + +- name: Case 1, defaults to the current year's adjusted gross income. + period: 2027 + input: + people: + person1: + age: 30 + tax_units: + tax_unit: + members: [person1] + adjusted_gross_income: 60_000 + output: + income_driven_repayment_adjusted_gross_income: 60_000 + +- name: Case 2, an input for the most recent return overrides the current year. + period: 2027 + input: + people: + person1: + age: 30 + tax_units: + tax_unit: + members: [person1] + income_driven_repayment_adjusted_gross_income: 20_000 + adjusted_gross_income: 60_000 + output: + income_driven_repayment_adjusted_gross_income: 20_000 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.yaml index 8f71bf381b9..e3c20acffab 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.yaml @@ -1,4 +1,6 @@ -# Share of the couple's eligible balance (34 CFR 685.209(e)(2)(i), (g)(1)(i)). +# Own Direct balance / couple's Direct and FFEL balance: the (g)(1)(i) share of +# the couple's eligible loans times the (g)(1)(ii) Direct share of the person's +# own eligible loans (34 CFR 685.209(b)(5), (e)(2)(i), (g)(1)(i)-(ii)). - name: Case 1, joint filers who both borrowed split by balance. period: 2026 @@ -54,3 +56,58 @@ filing_status: HEAD_OF_HOUSEHOLD output: income_driven_repayment_balance_share: [1, 0] + +- name: Case 4, a spouse's FFEL loans enter the couple's balance. + period: 2026 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + person2: + age: 38 + federal_ffel_loan_balance: 30_000 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + output: + # 10,000 / (10,000 + 30,000); the spouse has no Direct Loans. + income_driven_repayment_balance_share: [0.25, 0] + +- name: Case 5, a single borrower's own FFEL loans reduce the Direct share. + period: 2026 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + federal_ffel_loan_balance: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + output: + # (g)(1)(ii) alone: 10,000 / 40,000. + income_driven_repayment_balance_share: 0.25 + +- name: Case 6, both spouses hold Direct and FFEL loans. + period: 2026 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_ffel_loan_balance: 10_000 + person2: + age: 38 + federal_student_loan_balance: 20_000 + federal_ffel_loan_balance: 20_000 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + output: + # Couple 80,000. person1: 40,000 / 80,000 x 30,000 / 40,000 = 0.375. + # person2: 40,000 / 80,000 x 20,000 / 40,000 = 0.25. + income_driven_repayment_balance_share: [0.375, 0.25] diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml new file mode 100644 index 00000000000..65915404abc --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml @@ -0,0 +1,109 @@ +# Cap on Income-Based Repayment payments (34 CFR 685.209(f)(2)(ii), (f)(3)(ii)): +# the sum over the head and spouse of each one's 10-year standard payment on +# their eligible Direct and FFEL loans ((b)(5), (e)(2)(i)) at their own rate, +# balance x i / (1 - (1 + i) ^ -120) with i = rate / 12, at least 50 dollars +# and no more than the eligible balance (685.208(b)(1)). + +- name: Case 1, a single borrower's cap is their standard payment. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + output: + # 30,000 x 0.005 / (1 - 1.005 ^ -120) = 333.06. + income_driven_repayment_standard_payment: 333.06 + +- name: Case 2, joint filers add standard payments at each spouse's own rate. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0 + person2: + age: 38 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.12 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + output: + # 30,000 / 120 = 250; 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41. + income_driven_repayment_standard_payment: 680.41 + +- name: Case 3, FFEL loans enter the cap at the person's interest rate. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + federal_ffel_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + output: + # 111.02 on the borrower's Direct Loans + 333.06 on the spouse's FFEL loans. + income_driven_repayment_standard_payment: 444.08 + +- name: Case 4, each person's standard payment is at least the 50 dollar minimum. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + federal_student_loan_balance: 3_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + output: + # 333.06 + max(33.31, 50). + income_driven_repayment_standard_payment: 383.06 + +- name: Case 5, a tax dependent's loans do not enter the cap. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + is_tax_unit_head: true + is_tax_unit_spouse: false + federal_student_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 20 + is_tax_unit_head: false + is_tax_unit_spouse: false + federal_student_loan_balance: 15_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: HEAD_OF_HOUSEHOLD + output: + # Only the head's 20,000: 222.04. + income_driven_repayment_standard_payment: 222.04 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/integration.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/integration.yaml index cb39f5d0ce2..0e74f3d2b8d 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/integration.yaml @@ -39,7 +39,7 @@ # Same preamble passage: "While these borrowers could file separate Federal # income tax returns to address this issue, and each pay $46 per month ($92 # combined), they could then face higher taxes as a result." -- name: Case 2, RISE preamble married couple filing separately pays 46 dollars each. +- name: Case 2, rulespec-us#1455 C18, RISE preamble married couple filing separately pays 46 dollars each. period: 2027 absolute_error_margin: 0.01 input: @@ -99,7 +99,7 @@ 2027-12: 150 2027: 1_800 -- name: Case 4, ED fact sheet borrower pays from July 2026 only. +- name: Case 4, rulespec-us#1455 C15, ED fact sheet borrower pays from July 2026 only. period: 2026 absolute_error_margin: 0.01 input: @@ -113,8 +113,10 @@ filing_status: SINGLE adjusted_gross_income: 45_000 output: - # Nothing before July 2026; 150 a month for July-December: 6 x 150 = 900. + # 4% x 45,000 = 1,800 / 12 = 150. Nothing before July 2026; 150 a month for + # July-December: 6 x 150 = 900. repayment_assistance_plan_payment: + 2026-01: 0 2026-06: 0 2026-07: 150 2026: 900 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.yaml index 55b74a8d2a6..c6b4a270e92 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.yaml @@ -2,14 +2,22 @@ # $120 if AGI is not more than $10,000; otherwise the bracket rate times the # WHOLE AGI, where each bracket runs from "more than" its lower bound to "not # more than" its upper bound (ED rejected a marginal reading at 91 FR 23838). +# +# Cases labelled rulespec-us#1455 Cn or En are the companion cases for the +# Repayment Assistance Plan payment encoding in TheAxiomFoundation/rulespec-us +# issue 1455. They use an unmarried borrower with a 30,000 dollar balance and no +# dependents, so the applicable monthly payment (A) and the borrower's payment +# (P) both equal B / 12, or the 10 dollar minimum. The E cases test both sides +# of each bracket edge. -- name: Case 1, AGI exactly 10,000 receives the flat low-income base payment. +- name: Case 1, rulespec-us#1455 C1, AGI exactly 10,000 receives the flat low-income base payment. period: 2027 absolute_error_margin: 0.01 input: people: person1: age: 30 + federal_student_loan_balance: 30_000 tax_units: tax_unit: members: [person1] @@ -17,14 +25,20 @@ output: # (iv)(I): AGI not more than $10,000 -> $120. repayment_assistance_plan_base_payment: 120 + # 120 / 12 = 10. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: 10 + repayment_assistance_plan_payment: + 2027-01: 10 -- name: Case 2, AGI one dollar over 10,000 drops below the low-income base payment. +- name: Case 2, rulespec-us#1455 C2, AGI one dollar over 10,000 drops below the low-income base payment. period: 2027 absolute_error_margin: 0.01 input: people: person1: age: 30 + federal_student_loan_balance: 30_000 tax_units: tax_unit: members: [person1] @@ -32,14 +46,20 @@ output: # (iv)(II): 1% x 10,001 = 100.01, less than the $120 at AGI 10,000. repayment_assistance_plan_base_payment: 100.01 + # max(100.01 / 12, 10) = max(8.334, 10) = 10. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: 10 + repayment_assistance_plan_payment: + 2027-01: 10 -- name: Case 3, AGI exactly 20,000 stays in the 1 percent bracket. +- name: Case 3, rulespec-us#1455 C3, AGI exactly 20,000 stays in the 1 percent bracket. period: 2027 absolute_error_margin: 0.01 input: people: person1: age: 30 + federal_student_loan_balance: 30_000 tax_units: tax_unit: members: [person1] @@ -47,14 +67,20 @@ output: # (iv)(II) "not more than $20,000": 1% x 20,000 = 200. repayment_assistance_plan_base_payment: 200 + # 200 / 12 = 16 2/3. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: 16.67 + repayment_assistance_plan_payment: + 2027-01: 16.67 -- name: Case 4, AGI one dollar over 20,000 applies 2 percent to the whole AGI. +- name: Case 4, rulespec-us#1455 C5, AGI one dollar over 20,000 applies 2 percent to the whole AGI. period: 2027 absolute_error_margin: 0.01 input: people: person1: age: 30 + federal_student_loan_balance: 30_000 tax_units: tax_unit: members: [person1] @@ -62,6 +88,11 @@ output: # (iv)(III): 2% x 20,001 = 400.02. repayment_assistance_plan_base_payment: 400.02 + # 400.02 / 12 = 33.335. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: 33.335 + repayment_assistance_plan_payment: + 2027-01: 33.335 - name: Case 5, AGI of 55,000 applies 5 percent. period: 2027 @@ -78,13 +109,14 @@ # (iv)(VI): 5% x 55,000 = 2,750. repayment_assistance_plan_base_payment: 2_750 -- name: Case 6, AGI exactly 100,000 stays in the 9 percent bracket. +- name: Case 6, rulespec-us#1455 C6, AGI exactly 100,000 stays in the 9 percent bracket. period: 2027 absolute_error_margin: 0.01 input: people: person1: age: 30 + federal_student_loan_balance: 30_000 tax_units: tax_unit: members: [person1] @@ -92,14 +124,20 @@ output: # (iv)(X) "not more than $100,000": 9% x 100,000 = 9,000. repayment_assistance_plan_base_payment: 9_000 + # 9,000 / 12 = 750. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: 750 + repayment_assistance_plan_payment: + 2027-01: 750 -- name: Case 7, AGI one dollar over 100,000 applies the top 10 percent rate. +- name: Case 7, rulespec-us#1455 C7, AGI one dollar over 100,000 applies the top 10 percent rate. period: 2027 absolute_error_margin: 0.01 input: people: person1: age: 30 + federal_student_loan_balance: 30_000 tax_units: tax_unit: members: [person1] @@ -107,14 +145,20 @@ output: # (iv)(XI): 10% x 100,001 = 10,000.10. repayment_assistance_plan_base_payment: 10_000.1 + # 10,000.10 / 12 = 833.341666... + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: 833.34 + repayment_assistance_plan_payment: + 2027-01: 833.34 -- name: Case 8, negative AGI receives the flat low-income base payment. +- name: Case 8, rulespec-us#1455 C8, negative AGI receives the flat low-income base payment. period: 2027 absolute_error_margin: 0.01 input: people: person1: age: 30 + federal_student_loan_balance: 30_000 tax_units: tax_unit: members: [person1] @@ -122,3 +166,237 @@ output: # (iv)(I): -20,000 is not more than $10,000 -> $120. repayment_assistance_plan_base_payment: 120 + # 120 / 12 = 10. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: 10 + repayment_assistance_plan_payment: + 2027-01: 10 + +- name: Case 9, rulespec-us#1455 C4, AGI 12,000 gives exactly the low-income amount at 1 percent. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit: + members: [person1] + adjusted_gross_income: 12_000 + output: + # (iv)(II): 1% x 12,000 = 120, the same as the (iv)(I) amount. + repayment_assistance_plan_base_payment: 120 + # 120 / 12 = 10 exactly, without the minimum. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: 10 + repayment_assistance_plan_payment: + 2027-01: 10 + +- name: Case 10, rulespec-us#1455 E1, AGI 30,000 stays at 2 percent and 30,001 moves to 3 percent. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + person2: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit1: + members: [person1] + adjusted_gross_income: 30_000 + tax_unit2: + members: [person2] + adjusted_gross_income: 30_001 + output: + # 2% x 30,000 = 600; 3% x 30,001 = 900.03. + repayment_assistance_plan_base_payment: [600, 900.03] + # 600 / 12 = 50; 900.03 / 12 = 75.0025. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: [50, 75.0025] + repayment_assistance_plan_payment: + 2027-01: [50, 75.0025] + +- name: Case 11, rulespec-us#1455 E2, AGI 40,000 stays at 3 percent and 40,001 moves to 4 percent. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + person2: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit1: + members: [person1] + adjusted_gross_income: 40_000 + tax_unit2: + members: [person2] + adjusted_gross_income: 40_001 + output: + # 3% x 40,000 = 1,200; 4% x 40,001 = 1,600.04. + repayment_assistance_plan_base_payment: [1_200, 1_600.04] + # 1,200 / 12 = 100; 1,600.04 / 12 = 133.336666... + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: [100, 133.34] + repayment_assistance_plan_payment: + 2027-01: [100, 133.34] + +- name: Case 12, rulespec-us#1455 E3, AGI 50,000 stays at 4 percent and 50,001 moves to 5 percent. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + person2: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit1: + members: [person1] + adjusted_gross_income: 50_000 + tax_unit2: + members: [person2] + adjusted_gross_income: 50_001 + output: + # 4% x 50,000 = 2,000; 5% x 50,001 = 2,500.05. + repayment_assistance_plan_base_payment: [2_000, 2_500.05] + # 2,000 / 12 = 166 2/3; 2,500.05 / 12 = 208.3375. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: [166.67, 208.3375] + repayment_assistance_plan_payment: + 2027-01: [166.67, 208.3375] + +- name: Case 13, rulespec-us#1455 E4, AGI 60,000 stays at 5 percent and 60,001 moves to 6 percent. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + person2: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit1: + members: [person1] + adjusted_gross_income: 60_000 + tax_unit2: + members: [person2] + adjusted_gross_income: 60_001 + output: + # 5% x 60,000 = 3,000; 6% x 60,001 = 3,600.06. + repayment_assistance_plan_base_payment: [3_000, 3_600.06] + # 3,000 / 12 = 250; 3,600.06 / 12 = 300.005. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: [250, 300.005] + repayment_assistance_plan_payment: + 2027-01: [250, 300.005] + +- name: Case 14, rulespec-us#1455 E5, AGI 70,000 stays at 6 percent and 70,001 moves to 7 percent. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + person2: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit1: + members: [person1] + adjusted_gross_income: 70_000 + tax_unit2: + members: [person2] + adjusted_gross_income: 70_001 + output: + # 6% x 70,000 = 4,200; 7% x 70,001 = 4,900.07. + repayment_assistance_plan_base_payment: [4_200, 4_900.07] + # 4,200 / 12 = 350; 4,900.07 / 12 = 408.339166... + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: [350, 408.34] + repayment_assistance_plan_payment: + 2027-01: [350, 408.34] + +- name: Case 15, rulespec-us#1455 E6, AGI 80,000 stays at 7 percent and 80,001 moves to 8 percent. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + person2: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit1: + members: [person1] + adjusted_gross_income: 80_000 + tax_unit2: + members: [person2] + adjusted_gross_income: 80_001 + output: + # 7% x 80,000 = 5,600; 8% x 80,001 = 6,400.08. + repayment_assistance_plan_base_payment: [5_600, 6_400.08] + # 5,600 / 12 = 466 2/3; 6,400.08 / 12 = 533.34. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: [466.67, 533.34] + repayment_assistance_plan_payment: + 2027-01: [466.67, 533.34] + +- name: Case 16, rulespec-us#1455 E7, AGI 90,000 stays at 8 percent and 90,001 moves to 9 percent. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + person2: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit1: + members: [person1] + adjusted_gross_income: 90_000 + tax_unit2: + members: [person2] + adjusted_gross_income: 90_001 + output: + # 8% x 90,000 = 7,200; 9% x 90,001 = 8,100.09. + repayment_assistance_plan_base_payment: [7_200, 8_100.09] + # 7,200 / 12 = 600; 8,100.09 / 12 = 675.0075. + repayment_assistance_plan_applicable_monthly_payment: + 2027-01: [600, 675.0075] + repayment_assistance_plan_payment: + 2027-01: [600, 675.0075] + +- name: Case 17, the base payment uses the latest return's adjusted gross income. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit: + members: [person1] + # The most recent return shows 20,000; income this year is 60,000. + income_driven_repayment_adjusted_gross_income: 20_000 + adjusted_gross_income: 60_000 + output: + # (q)(4)(A) "for the most recent taxable year": 1% x 20,000 = 200, not + # 5% x 60,000 = 3,000. + repayment_assistance_plan_base_payment: 200 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.yaml index 4742db91fbd..b2b498a7e83 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.yaml @@ -1,8 +1,9 @@ # 20 U.S.C. 1087e(q)(1) and (d)(1)(F): the plan runs "beginning on July 1, 2026" # and excludes excepted (Parent PLUS) loans. 34 CFR 685.209(c)(6), (d)(4): a -# Direct Loan borrower with eligible loans may repay under the plan. Only tax -# unit heads and spouses are borrowers here (a tax dependent's unit AGI is the -# parents' income). +# Direct Loan borrower with eligible loans may repay under the plan. As a +# modeling restriction, only tax unit heads and spouses are borrowers here (a +# tax dependent's unit AGI is the parents' income); the law does not exclude +# tax dependents (rulespec-us#1455 C17). - name: Case 1, head of tax unit with an eligible loan balance is eligible. period: 2027-01 @@ -48,7 +49,7 @@ output: repayment_assistance_plan_eligible: false -- name: Case 4, a tax dependent with a loan balance is not eligible. +- name: Case 4, a tax dependent with a loan balance is not eligible (modeling restriction). period: 2027-01 input: people: diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.yaml index 3050c7e5c2f..0103678c3ba 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.yaml @@ -7,6 +7,11 @@ # eligible balance, min $10 again (685.209(e)(2)(i), (g)(3)(i)) # capped at the balance left to repay ((q)(4)(B)(iii)), with payments earlier # in the year (from July 2026 on) counted against the balance. +# Cases labelled rulespec-us#1455 Cn are companion cases for the payment +# encoding in TheAxiomFoundation/rulespec-us issue 1455 (C1-C8 and the bracket +# edges are in repayment_assistance_plan_base_payment.yaml; C15 and C18 are in +# integration.yaml). C17, a borrower claimed as a tax dependent, is not yet +# modeled. - name: Case 1, AGI 10,000 gives the low-income base payment over 12 months. period: 2027-01 @@ -93,7 +98,7 @@ # 5% x 55,000 = 2,750 / 12 = 229.1667. repayment_assistance_plan_payment: 229.17 -- name: Case 6, head of household with two dependents. +- name: Case 6, rulespec-us#1455 C9, head of household with two dependents. period: 2027-01 absolute_error_margin: 0.01 input: @@ -112,6 +117,7 @@ adjusted_gross_income: 55_000 output: # 229.1667 - 2 x 50 = 129.1667; the children owe nothing. + repayment_assistance_plan_applicable_monthly_payment: 129.17 repayment_assistance_plan_payment: [129.17, 0, 0] - name: Case 7, three dependents take the payment to the 10 dollar minimum. @@ -189,7 +195,7 @@ # 3% x 40,000 = 1,200 / 12 = 100. repayment_assistance_plan_payment: [100, 0] -- name: Case 10, joint filers who both borrowed split the payment by balance. +- name: Case 10, rulespec-us#1455 C11, joint filers who both borrowed split the payment by balance. period: 2027-01 absolute_error_margin: 0.01 input: @@ -229,7 +235,7 @@ # min(229.1667, 150) = 150 ((q)(4)(B)(iii)). repayment_assistance_plan_payment: 150 -- name: Case 12, a borrower with only Parent PLUS loans owes nothing under the plan. +- name: Case 12, rulespec-us#1455 C19, a borrower with only Parent PLUS loans owes nothing under the plan. period: 2027-01 absolute_error_margin: 0.01 input: @@ -269,7 +275,7 @@ 2026-12: 229.17 2026: 1_375 -- name: Case 14, payments within a year stop once they add up to the balance. +- name: Case 14, rulespec-us#1455 C13, payments within a year stop once they add up to the balance. period: 2027 absolute_error_margin: 0.01 input: @@ -293,7 +299,7 @@ 2027-12: 0 2027: 500 -- name: Case 15, the 2026 balance cap counts months from the July start. +- name: Case 15, rulespec-us#1455 C20, the 2026 balance cap counts months from the July start. period: 2026 absolute_error_margin: 0.01 input: @@ -315,7 +321,7 @@ 2026-09: 0 2026: 300 -- name: Case 16, a prorated payment below 10 dollars rises to the minimum. +- name: Case 16, rulespec-us#1455 C12, a prorated payment below 10 dollars rises to the minimum. period: 2027-01 absolute_error_margin: 0.01 input: @@ -333,10 +339,12 @@ adjusted_gross_income: 30_000 output: # 2% x 30,000 = 600 / 12 = 50. Shares 95% and 5%: 47.50 and 2.50; the - # second is raised to 10 (685.209(g)(3)(i)(B)). + # second is raised to 10 (685.209(g)(3)(i)(B)). The payments total 57.50, + # more than the applicable payment. + repayment_assistance_plan_applicable_monthly_payment: 50 repayment_assistance_plan_payment: [47.5, 10] -- name: Case 17, the final payment can be below the 10 dollar minimum. +- name: Case 17, rulespec-us#1455 C14, the final payment can be below the 10 dollar minimum. period: 2027-01 absolute_error_margin: 0.01 input: @@ -352,6 +360,7 @@ output: # 120 / 12 = 10, but only 5 is left to repay ((q)(4)(B)(iii); # 685.209(g)(3)(ii) "except that the final payment may be less than $10"). + repayment_assistance_plan_applicable_monthly_payment: 10 repayment_assistance_plan_payment: 5 - name: Case 18, a tax dependent with a loan balance owes nothing. @@ -404,3 +413,94 @@ output: repayment_assistance_plan_applicable_monthly_payment: 0 repayment_assistance_plan_payment: [0, 0] + +- name: Case 20, rulespec-us#1455 C10, one dependent takes the payment to zero and then the minimum. + period: 2027-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + federal_student_loan_balance: 30_000 + person2: + age: 6 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: HEAD_OF_HOUSEHOLD + adjusted_gross_income: 30_000 + output: + # 2% x 30,000 = 600 / 12 = 50; 50 - 1 x 50 = 0; the minimum raises it to + # 10. The child owes nothing. + repayment_assistance_plan_applicable_monthly_payment: 10 + repayment_assistance_plan_payment: [10, 0] + +- name: Case 21, rulespec-us#1455 C16, a spouse's FFEL debt counts in the joint proration. + period: 2027-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + person2: + # The spouse has only FFEL loans, which the plan cannot repay. + age: 38 + federal_ffel_loan_balance: 30_000 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 60_000 + output: + # 5% x 60,000 = 3,000 / 12 = 250. The couple's eligible loans are 10,000 of + # Direct and 30,000 of FFEL (685.209(b)(5)), so the borrower pays + # 250 x 10,000 / 40,000 = 62.50 (685.209(g)(3)(i)). The spouse has no + # loans the plan can repay and owes nothing under it. + repayment_assistance_plan_applicable_monthly_payment: 250 + repayment_assistance_plan_payment: [62.5, 0] + +- name: Case 22, a borrower's own FFEL loans count toward their joint share. + period: 2027-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + federal_ffel_loan_balance: 30_000 + person2: + age: 38 + federal_student_loan_balance: 40_000 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 60_000 + output: + # A = 250. 685.209(g)(3)(i) divides each borrower's eligible Direct and FFEL + # loans by the couple's: (10,000 + 30,000) / 80,000 = 50% and + # 40,000 / 80,000 = 50%, so each pays 125. Unlike (g)(1)(ii) for the + # other plans, (g)(3) does not shrink person1's share to the Direct part. + repayment_assistance_plan_payment: [125, 125] + +- name: Case 23, the payment uses the latest return's AGI rather than this year's. + period: 2027-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + # Income rose from 20,000 on the most recent return to 60,000. + income_driven_repayment_adjusted_gross_income: 20_000 + adjusted_gross_income: 60_000 + output: + # (q)(4)(A) "for the most recent taxable year": 1% x 20,000 = 200 / 12 = + # 16 2/3, not 5% x 60,000 / 12 = 250. + repayment_assistance_plan_applicable_monthly_payment: 16.67 + repayment_assistance_plan_payment: 16.67 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/save_plan/save_plan_discretionary_income.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/save_plan/save_plan_discretionary_income.yaml index 13c28411edb..1e324a6c1bb 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/save_plan/save_plan_discretionary_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/save_plan/save_plan_discretionary_income.yaml @@ -59,3 +59,24 @@ 2023-07: 1_510.83 2023-08: 599.58 2023-12: 599.58 + +- name: Case 4, discretionary income uses the latest return's AGI. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + tax_units: + tax_unit: + members: [person1] + # The most recent return shows 40,000; income this year is 80,000. + income_driven_repayment_adjusted_gross_income: 40_000 + adjusted_gross_income: 80_000 + households: + household: + members: [person1] + state_code: TX + output: + # (40,000 - 35,910) / 12 = 340.83, as in Case 1. + save_plan_discretionary_income: 340.83 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/save_plan/save_plan_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/save_plan/save_plan_payment.yaml index ad4d0b06317..cea7c8a305c 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/save_plan/save_plan_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/save_plan/save_plan_payment.yaml @@ -1,8 +1,8 @@ # Monthly payment under the SAVE plan as codified in 34 CFR 685.209(f)(1), (g)(1): # discretionary income = max(AGI - 2.25 x poverty guideline, 0) # rate = 5% x undergraduate share + 10% x graduate share (from July 1, 2024) -# payment = rate x discretionary income / 12 x own share of the couple's -# eligible balance (joint filers); under 5 -> 0; 5 to under 10 -> 10. +# payment = rate x discretionary income / 12 x own Direct balance / couple's +# Direct and FFEL balance ((g)(1)(i)-(ii)); under 5 -> 0; 5 to under 10 -> 10. # 2026 guideline in the 48 states: 15,960 + 5,680 per additional person. - name: Case 1, single filer with only undergraduate loans pays 5 percent. @@ -250,3 +250,26 @@ # 2.25 x 15,960 = 35,910; 5% x 1,200 / 12 = 5.00 exactly, which is not less # than 5, so it rises to 10 (685.209(g)(1)(iii)(B)). save_plan_payment: 10 + +- name: Case 12, FFEL loans reduce the share of the payment on Direct Loans. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + federal_student_loan_balance: 10_000 + federal_ffel_loan_balance: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 60_000 + households: + household: + members: [person1] + state_code: TX + output: + # 5% x (60,000 - 35,910) / 12 = 100.375; x 10,000 / 40,000 = 25.09 + # (685.209(g)(1)(ii)). + save_plan_payment: 25.09 diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py new file mode 100644 index 00000000000..0f0c3229d3f --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class federal_ffel_loan_balance(Variable): + value_type = float + entity = Person + label = "Federal Family Education Loan balance" + documentation = ( + "Outstanding principal and interest on the person's Federal Family " + "Education Loan (FFEL) Program loans that are eligible loans under 34 " + "CFR 685.209(b)(5)(ii): every FFEL loan except Federal PLUS Loans made " + "to parents and excepted consolidation loans. The Direct Loan " + "income-driven repayment plans do not repay these loans, but the " + "balance enters the spousal and FFEL adjustments of 685.209(g)(1) and " + "(g)(3) and the 10-year standard payment cap of the Income-Based " + "Repayment plan. FFEL loans consolidated into a Direct Consolidation " + "Loan belong in federal_student_loan_balance instead. This is the " + "opening balance for the year: it is uprated by CPI-U across years " + "with no repayment roll-forward." + ) + unit = USD + quantity_type = STOCK + definition_period = YEAR + uprating = "gov.bls.cpi.cpi_u" + reference = ( + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(5)(ii)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(6)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(1)(ii)", + ) diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py index fc2759fc929..d8ade965028 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py @@ -9,7 +9,9 @@ class federal_parent_plus_loan_balance(Variable): "Outstanding principal and interest on Federal Direct PLUS Loans the " "person borrowed as a parent on behalf of a dependent student, and on " "Direct Consolidation Loans that repaid such loans. These excepted " - "loans cannot be repaid under the Repayment Assistance Plan." + "loans cannot be repaid under the Repayment Assistance Plan. This is " + "the opening balance for the year: it is uprated by CPI-U across years " + "with no repayment roll-forward." ) unit = USD quantity_type = STOCK diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_balance.py index 27cb0c65c89..86210ac5f38 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_balance.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_balance.py @@ -13,7 +13,11 @@ class federal_student_loan_balance(Variable): "are not excepted consolidation loans. Excludes Parent PLUS loans and " "consolidation loans that repaid them (see " "federal_parent_plus_loan_balance), and FFEL or Perkins loans that have " - "not been consolidated into a Direct Consolidation Loan." + "not been consolidated into a Direct Consolidation Loan (see " + "federal_ffel_loan_balance for FFEL loans). This is the opening balance " + "for the year: it is uprated by CPI-U across years with no repayment " + "roll-forward, and within a year only the Repayment Assistance Plan's " + "final-payment cap draws it down." ) unit = USD quantity_type = STOCK diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py index 1a386d8c293..68f5c06e51e 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py @@ -8,7 +8,8 @@ class federal_student_loan_interest_rate(Variable): documentation = ( "Annual interest rate on the loans in federal_student_loan_balance, " "weighted by balance. Used to amortize the balance under the " - "standard repayment plan." + "standard repayment plan. The Income-Based Repayment plan's standard " + "payment cap also applies it to the person's federal_ffel_loan_balance." ) unit = "/1" definition_period = YEAR diff --git a/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_discretionary_income.py b/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_discretionary_income.py index 885494ed508..0f78253e528 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_discretionary_income.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_discretionary_income.py @@ -6,8 +6,10 @@ class income_based_repayment_discretionary_income(Variable): entity = TaxUnit label = "Income-Based Repayment plan discretionary income" documentation = ( - "Adjusted gross income above the plan's multiple of the poverty " - "guideline, per month. Joint filers combine both spouses' income; " + "Adjusted gross income from the most recent return " + "(income_driven_repayment_adjusted_gross_income) above the plan's " + "multiple of the poverty guideline, per month. Joint filers combine " + "both spouses' income; " "married people filing separately are in separate tax units, so only " "the borrower's income counts." ) @@ -21,7 +23,9 @@ class income_based_repayment_discretionary_income(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.ed.student_loans.income_based_repayment - agi = tax_unit("adjusted_gross_income", period.this_year) + agi = tax_unit( + "income_driven_repayment_adjusted_gross_income", period.this_year + ) poverty_guideline = tax_unit( "income_driven_repayment_poverty_guideline", period.this_year ) diff --git a/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py index fc1a55a6671..11ff42e31d7 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py @@ -7,21 +7,27 @@ class income_based_repayment_payment(Variable): label = "Income-Based Repayment plan monthly payment" documentation = ( "Monthly payment owed by a borrower under the Income-Based Repayment " - "plan: the plan rate times discretionary income, prorated for joint " - "filers whose spouse also has eligible loans, capped at the 10-year " - "standard payment, then with the small payment adjustment. The cap " - "uses the current balance, not the balance when the borrower entered " - "the plan. Prorating before capping each spouse at their own standard " - "payment matches capping the couple at their combined standard " - "payment when both spouses' loans carry the same interest rate. The " - "bar on borrowers with a loan made on or after July 1, 2026 is not " - "modeled." + "plan, assuming the borrower is enrolled in the plan and in active " + "repayment, not in deferment, forbearance or default. The payment is " + "the lesser of the borrower's plan rate times discretionary income " + "and the 10-year standard payment on the eligible loans " + "(income_driven_repayment_standard_payment, which for joint filers " + "covers both spouses' Direct and FFEL loans). That amount is prorated " + "to the borrower's Direct Loans (income_driven_repayment_balance_share), " + "rounded to the cent, given the small payment adjustment and capped at " + "the borrower's Direct Loan balance. Spouses keep their own rates, so a " + "new borrower pays 10 percent and the other spouse 15 percent of the " + "same discretionary income before the cap and proration. The cap uses " + "the current balance, not the balance when the borrower entered the " + "plan. The bar on borrowers with a loan made on or after July 1, 2026 " + "is not modeled." ) unit = USD definition_period = MONTH defined_for = "income_driven_repayment_eligible" reference = ( "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1098e&num=0&edition=prelim", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)(i)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(2)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(3)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(1)", @@ -40,20 +46,25 @@ def formula(person, period, parameters): discretionary_income = person.tax_unit( "income_based_repayment_discretionary_income", period ) + # 685.209(f)(2)-(3): the lesser of the income-based amount and the + # standard payment on all of the couple's eligible loans, then the + # (g)(1)(i)-(ii) proration to the borrower's Direct Loans. + standard_payment = person.tax_unit( + "income_driven_repayment_standard_payment", period + ) + capped_payment = min_(rate * discretionary_income, standard_payment) balance_share = person( "income_driven_repayment_balance_share", period.this_year ) - prorated_payment = rate * discretionary_income * balance_share - standard_payment = person("standard_repayment_payment", period) # Payments are set in whole cents. Rounding before the small payment # test keeps an exact $5.00 from falling below the threshold through # floating-point error (e.g. 15% x $400 / 12). - capped_payment = np.round(min_(prorated_payment, standard_payment), 2) + prorated_payment = np.round(capped_payment * balance_share, 2) small_payment = p.income_driven_repayment.small_payment adjusted_payment = where( - capped_payment < small_payment.threshold, + prorated_payment < small_payment.threshold, 0, - max_(capped_payment, small_payment.minimum), + max_(prorated_payment, small_payment.minimum), ) balance = person("federal_student_loan_balance", period.this_year) return min_(adjusted_payment, balance) diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py new file mode 100644 index 00000000000..70c93b2f773 --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class income_driven_repayment_adjusted_gross_income(Variable): + value_type = float + entity = TaxUnit + label = "Income-driven repayment adjusted gross income" + unit = USD + documentation = ( + "Adjusted gross income on the most recent federal income tax return " + "available when the Department of Education sets the monthly payment, " + "which the Repayment Assistance Plan, Income-Based Repayment and SAVE " + "plans use as income: 20 U.S.C. 1087e(q)(4)(A) takes adjusted gross " + "income 'for the most recent taxable year'. That is usually the prior " + "year's return, or documentation of current income the borrower " + "supplies instead. Household and microsimulation inputs usually cover " + "a single year, so reading the prior year would return zero income; " + "when not provided, this defaults to the current year's adjusted " + "gross income. An explicit input overrides it." + ) + definition_period = YEAR + reference = ( + "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1087e&num=0&edition=prelim", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(1)(i)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(l)(4)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(l)(6)", + ) + adds = ["adjusted_gross_income"] diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py index d130506dee3..ef54a7445c3 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py @@ -6,27 +6,43 @@ class income_driven_repayment_balance_share(Variable): entity = Person label = "Income-driven repayment share of the couple's eligible balance" documentation = ( - "The person's share of the eligible federal student loan balance held " - "by the head and spouse of their tax unit. Joint filers whose spouse " - "also has eligible loans owe this share of the income-driven payment. " - "Married people filing separately are in separate tax units, so their " - "share is 1." + "Share of the Income-Based Repayment or SAVE plan payment that the " + "person owes on their Direct Loans. 34 CFR 685.209(g)(1)(i) first " + "prorates the payment by the person's share of the eligible loans of " + "the head and spouse of their tax unit, where eligible loans are " + "Direct and FFEL loans (federal_student_loan_balance plus " + "federal_ffel_loan_balance). 685.209(g)(1)(ii) then multiplies by the " + "Direct Loan share of the person's own eligible loans, since these " + "plans do not repay FFEL loans. The product is the person's Direct " + "Loan balance divided by the couple's combined Direct and FFEL " + "balance. Married people filing separately are in separate tax units, " + "so only their own loans count." ) unit = "/1" definition_period = YEAR reference = ( + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(5)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)(i)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(1)(i)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(1)(ii)", ) def formula(person, period, parameters): - balance = person("federal_student_loan_balance", period) is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) - eligible_balance = where(is_head_or_spouse, balance, 0) - couple_balance = person.tax_unit.sum(eligible_balance) + direct_balance = where( + is_head_or_spouse, person("federal_student_loan_balance", period), 0 + ) + ffel_balance = where( + is_head_or_spouse, person("federal_ffel_loan_balance", period), 0 + ) + # (g)(1)(i) x (g)(1)(ii): + # (own Direct + own FFEL) / couple's (Direct + FFEL) + # x own Direct / (own Direct + own FFEL) + # = own Direct / couple's (Direct + FFEL). + couple_balance = person.tax_unit.sum(direct_balance + ffel_balance) return np.divide( - eligible_balance, + direct_balance, couple_balance, - out=np.zeros_like(eligible_balance), + out=np.zeros_like(direct_balance), where=couple_balance > 0, ) diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_eligible.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_eligible.py index 51500ccae98..386103a12c5 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_eligible.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_eligible.py @@ -7,7 +7,12 @@ class income_driven_repayment_eligible(Variable): label = "Eligible for income-driven repayment of federal student loans" documentation = ( "The person has loans that the Income-Based Repayment and SAVE plans " - "can repay and is the head or spouse of their tax unit." + "can repay and is the head or spouse of their tax unit. Modeling " + "restriction: a borrower claimed as someone else's tax dependent is " + "treated as ineligible and owes $0, because their tax unit's adjusted " + "gross income is the claiming taxpayer's, not the borrower's own. The " + "law does not exclude these borrowers (34 CFR 685.209(c)(2)-(3)); " + "their payment would come from their own income." ) definition_period = MONTH reference = ( @@ -19,7 +24,6 @@ def formula(person, period, parameters): has_eligible_loans = ( person("federal_student_loan_balance", period.this_year) > 0 ) - # NOTE: Tax dependents are excluded because their tax unit's AGI is - # their parents' income, not the borrower's own return. + # Modeling restriction: tax dependents are excluded (see documentation). is_head_or_spouse = person("is_tax_unit_head_or_spouse", period.this_year) return has_eligible_loans & is_head_or_spouse diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py new file mode 100644 index 00000000000..d467555ce4c --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py @@ -0,0 +1,53 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ed.student_loans.student_loan_amortization_helpers import ( + amortized_monthly_payment, +) + + +class income_driven_repayment_standard_payment(Variable): + value_type = float + entity = TaxUnit + label = "Income-driven repayment 10-year standard payment cap" + documentation = ( + "What the borrower would pay on the 10-year standard repayment plan " + "based on the eligible loan balances and interest rates, the cap on " + "Income-Based Repayment plan payments. Eligible loans are Direct and " + "FFEL loans other than parent PLUS loans and excepted consolidation " + "loans. A joint filer's spouse's eligible loans count too, so this is " + "the sum over the head and spouse of the tax unit of each one's " + "10-year standard payment on their federal_student_loan_balance plus " + "federal_ffel_loan_balance, amortized at their own " + "federal_student_loan_interest_rate. Each person's standard payment " + "is at least the standard plan minimum and no more than their eligible " + "balance. Married people filing separately are in separate tax units, " + "so only the borrower's loans count. The cap uses the current balance, " + "not the balance when the borrower began paying under the plan." + ) + unit = USD + definition_period = MONTH + reference = ( + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(5)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)(i)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(2)(ii)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(3)(ii)", + "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(1)", + ) + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.ed.student_loans.standard_repayment + person = tax_unit.members + direct_balance = person("federal_student_loan_balance", period.this_year) + ffel_balance = person("federal_ffel_loan_balance", period.this_year) + eligible_balance = direct_balance + ffel_balance + annual_rate = person("federal_student_loan_interest_rate", period.this_year) + number_of_payments = p.repayment_period * MONTHS_IN_YEAR + amortized_payment = amortized_monthly_payment( + eligible_balance, annual_rate, number_of_payments + ) + # As in standard_repayment_payment, the minimum does not apply to a + # final payment below it. + standard_payment = min_( + max_(amortized_payment, p.minimum_payment), eligible_balance + ) + is_head_or_spouse = person("is_tax_unit_head_or_spouse", period.this_year) + return tax_unit.sum(where(is_head_or_spouse, standard_payment, 0)) diff --git a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.py b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.py index 8f0f1098dcd..26ee1e44637 100644 --- a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.py @@ -5,6 +5,12 @@ class repayment_assistance_plan_base_payment(Variable): value_type = float entity = TaxUnit label = "Repayment Assistance Plan base payment" + documentation = ( + "Annual base payment from the bracket rate on the whole adjusted " + "gross income of the most recent return " + "(income_driven_repayment_adjusted_gross_income), or the low-income " + "amount when that income is not more than the first threshold." + ) unit = USD definition_period = YEAR reference = ( @@ -17,7 +23,7 @@ def formula(tax_unit, period, parameters): p = parameters( period ).gov.ed.student_loans.repayment_assistance_plan.base_payment - agi = tax_unit("adjusted_gross_income", period) + agi = tax_unit("income_driven_repayment_adjusted_gross_income", period) # The low-income limit is the first finite threshold of the rate scale. low_income = agi <= p.rate.thresholds[1] income_based_payment = p.rate.calc(agi, right=True) * agi diff --git a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.py b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.py index f9baf7f2326..f8e6f1448ab 100644 --- a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.py +++ b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.py @@ -5,6 +5,16 @@ class repayment_assistance_plan_eligible(Variable): value_type = bool entity = Person label = "Eligible for the Repayment Assistance Plan" + documentation = ( + "The person has loans that the Repayment Assistance Plan can repay, " + "is the head or spouse of their tax unit, and the plan is in effect " + "(from July 1, 2026). Modeling restriction: a borrower claimed as " + "someone else's tax dependent is treated as ineligible and owes $0, " + "because their tax unit's adjusted gross income is the claiming " + "taxpayer's, not the borrower's own. The law does not exclude these " + "borrowers (34 CFR 685.209(c)(6)): their base payment would come from " + "their own income, and the $10 minimum payment would apply." + ) definition_period = MONTH reference = ( "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1087e&num=0&edition=prelim", @@ -17,7 +27,7 @@ def formula(person, period, parameters): has_eligible_loans = ( person("federal_student_loan_balance", period.this_year) > 0 ) - # NOTE: Tax dependents are excluded because their tax unit's AGI is - # their parents' income, not the borrower's own return. + # Modeling restriction: tax dependents are excluded (see documentation + # and rulespec-us#1455 companion case C17). is_head_or_spouse = person("is_tax_unit_head_or_spouse", period.this_year) return p.in_effect & has_eligible_loans & is_head_or_spouse diff --git a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.py b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.py index 5ff5cf3da60..c6a69a5fed1 100644 --- a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.py @@ -7,9 +7,18 @@ class repayment_assistance_plan_payment(Variable): label = "Repayment Assistance Plan monthly payment" documentation = ( "Monthly payment owed by a borrower under the Repayment Assistance " - "Plan, after spousal proration and the final-payment cap. Payments in " - "a year stop once they add up to the borrower's loan balance; interest " - "accrual is not modeled." + "Plan, after spousal proration and the final-payment cap, assuming the " + "borrower is enrolled in the plan and in active repayment, not in " + "deferment, forbearance or default. Joint filers owe the applicable " + "monthly payment times their share of the couple's eligible loans, " + "which per 34 CFR 685.209(b)(5) and (g)(3)(i) are Direct and FFEL " + "loans (federal_student_loan_balance plus federal_ffel_loan_balance), " + "and at least the minimum payment. The plan cannot repay FFEL loans " + "(685.209(d)(4)), and (g)(3) has no FFEL adjustment like (g)(1)(ii), " + "so a borrower's own FFEL loans raise their share of the payment on " + "their Direct Loans. Payments in a year stop once they add up to the " + "borrower's opening Direct Loan balance; interest accrual is not " + "modeled." ) unit = USD definition_period = MONTH @@ -17,6 +26,7 @@ class repayment_assistance_plan_payment(Variable): reference = ( "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1087e&num=0&edition=prelim", "https://www.govinfo.gov/content/pkg/PLAW-119publ21/pdf/PLAW-119publ21.pdf#page=274", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(5)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(3)", ) @@ -24,10 +34,11 @@ class repayment_assistance_plan_payment(Variable): def formula(person, period, parameters): p = parameters(period).gov.ed.student_loans.repayment_assistance_plan balance = person("federal_student_loan_balance", period.this_year) - # Joint filers whose spouse also has eligible loans split the payment by - # each spouse's share of the couple's eligible balance. - eligible = person("repayment_assistance_plan_eligible", period) - eligible_balance = where(eligible, balance, 0) + # Joint filers split the payment by each spouse's share of the couple's + # eligible Direct and FFEL loans (685.209(b)(5), (g)(3)(i)). + is_head_or_spouse = person("is_tax_unit_head_or_spouse", period.this_year) + ffel_balance = person("federal_ffel_loan_balance", period.this_year) + eligible_balance = where(is_head_or_spouse, balance + ffel_balance, 0) couple_balance = person.tax_unit.sum(eligible_balance) balance_share = np.divide( eligible_balance, diff --git a/policyengine_us/variables/gov/ed/student_loans/save_plan/save_plan_discretionary_income.py b/policyengine_us/variables/gov/ed/student_loans/save_plan/save_plan_discretionary_income.py index 93dca8091fe..06c703febca 100644 --- a/policyengine_us/variables/gov/ed/student_loans/save_plan/save_plan_discretionary_income.py +++ b/policyengine_us/variables/gov/ed/student_loans/save_plan/save_plan_discretionary_income.py @@ -6,8 +6,10 @@ class save_plan_discretionary_income(Variable): entity = TaxUnit label = "SAVE plan discretionary income" documentation = ( - "Adjusted gross income above the plan's multiple of the poverty " - "guideline, per month. Joint filers combine both spouses' income; " + "Adjusted gross income from the most recent return " + "(income_driven_repayment_adjusted_gross_income) above the plan's " + "multiple of the poverty guideline, per month. Joint filers combine " + "both spouses' income; " "married people filing separately are in separate tax units, so only " "the borrower's income counts." ) @@ -21,7 +23,9 @@ class save_plan_discretionary_income(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.ed.student_loans.save_plan - agi = tax_unit("adjusted_gross_income", period.this_year) + agi = tax_unit( + "income_driven_repayment_adjusted_gross_income", period.this_year + ) poverty_guideline = tax_unit( "income_driven_repayment_poverty_guideline", period.this_year ) diff --git a/policyengine_us/variables/gov/ed/student_loans/save_plan/save_plan_payment.py b/policyengine_us/variables/gov/ed/student_loans/save_plan/save_plan_payment.py index 2adc72bce75..7752f7e3dbe 100644 --- a/policyengine_us/variables/gov/ed/student_loans/save_plan/save_plan_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/save_plan/save_plan_payment.py @@ -9,9 +9,12 @@ class save_plan_payment(Variable): "Monthly payment a borrower would owe under the Saving on a Valuable " "Education (SAVE) plan as codified in 34 CFR 685.209(f)(1): the " "undergraduate and graduate rates weighted by the borrower's original " - "balance, times discretionary income, prorated for joint filers whose " - "spouse also has eligible loans, then with the small payment " - "adjustment. Courts have blocked the SAVE plan, so this is a " + "balance, times discretionary income, prorated to the borrower's " + "Direct Loans by income_driven_repayment_balance_share (which counts " + "a joint filer's spouse's loans and both spouses' FFEL loans), then " + "with the small payment adjustment. It assumes the borrower is " + "enrolled in the plan and in active repayment, not in deferment, " + "forbearance or default. Courts have blocked the SAVE plan, so this is a " "counterfactual: it is computed for any period and does not mean the " "plan is available. The pre-2023 REPAYE rule that counted a " "separately filing spouse's income is not modeled." @@ -20,6 +23,7 @@ class save_plan_payment(Variable): definition_period = MONTH defined_for = "income_driven_repayment_eligible" reference = ( + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)(i)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(1)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(1)", "https://www.govinfo.gov/content/pkg/FR-2023-07-10/pdf/2023-13112.pdf#page=82", diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py index 767c82d901a..bbe492e3a15 100644 --- a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ed.student_loans.student_loan_amortization_helpers import ( + amortized_monthly_payment, +) class standard_repayment_payment(Variable): @@ -24,17 +27,9 @@ def formula(person, period, parameters): p = parameters(period).gov.ed.student_loans.standard_repayment balance = person("federal_student_loan_balance", period.this_year) annual_rate = person("federal_student_loan_interest_rate", period.this_year) - monthly_rate = annual_rate / MONTHS_IN_YEAR number_of_payments = p.repayment_period * MONTHS_IN_YEAR - # Level payment that repays the balance in n months at monthly rate i: - # balance x i / (1 - (1 + i) ^ -n), or balance / n at a zero rate. - discount = 1 - (1 + monthly_rate) ** -number_of_payments - payment_per_dollar = np.divide( - monthly_rate, - discount, - out=np.ones_like(monthly_rate) / number_of_payments, - where=monthly_rate > 0, + amortized_payment = amortized_monthly_payment( + balance, annual_rate, number_of_payments ) - amortized_payment = balance * payment_per_dollar # The minimum does not apply to a final payment below it. return min_(max_(amortized_payment, p.minimum_payment), balance) diff --git a/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py b/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py new file mode 100644 index 00000000000..bae45d6fcc8 --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +def amortized_monthly_payment(balance, annual_rate, number_of_payments): + """Level monthly payment that repays `balance` in `number_of_payments` + months at `annual_rate`, compounded monthly. + + The payment is balance x i / (1 - (1 + i) ^ -n), where i is the annual + rate divided by 12 and n is the number of payments, or balance / n at a + zero rate. `number_of_payments` can be a scalar or an array. + """ + monthly_rate = annual_rate / MONTHS_IN_YEAR + discount = 1 - (1 + monthly_rate) ** -number_of_payments + payment_per_dollar = np.divide( + monthly_rate, + discount, + out=np.ones_like(monthly_rate) / number_of_payments, + where=monthly_rate > 0, + ) + return balance * payment_per_dollar From 18ad149c385522dd4f6eb2483a7489f2ee4135d5 Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Fri, 23 Oct 2026 15:00:25 -0400 Subject: [PATCH 2/9] Describe the RAP low-income threshold as the rate scale's first finite threshold Co-Authored-By: Claude Opus 5.5 (1M context) --- .../repayment_assistance_plan_base_payment.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.py b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.py index 26ee1e44637..824c4bba7d2 100644 --- a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.py @@ -9,7 +9,8 @@ class repayment_assistance_plan_base_payment(Variable): "Annual base payment from the bracket rate on the whole adjusted " "gross income of the most recent return " "(income_driven_repayment_adjusted_gross_income), or the low-income " - "amount when that income is not more than the first threshold." + "amount when that income is not more than the rate scale's first " + "finite threshold." ) unit = USD definition_period = YEAR From 4df143982475aa0c2c748d8f47ee77713b5f9395 Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Mon, 26 Oct 2026 09:28:25 -0400 Subject: [PATCH 3/9] Add the parent PLUS standard repayment payment Add parent_plus_standard_repayment_payment, the 10-year standard payment on federal_parent_plus_loan_balance under 34 CFR 685.208(b)(1), through the shared amortization helper with the standard plan's $50 minimum and balance cap. It is kept apart from standard_repayment_payment so the income-driven caps stay on the loans those plans repay (#10411 item 6). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../student-loan-idr-restrictions.fixed.md | 2 +- ...arent_plus_standard_repayment_payment.yaml | 88 +++++++++++++++++++ .../federal_parent_plus_loan_balance.py | 7 +- .../federal_student_loan_interest_rate.py | 4 +- .../parent_plus_standard_repayment_payment.py | 43 +++++++++ 5 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml create mode 100644 policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py diff --git a/changelog.d/student-loan-idr-restrictions.fixed.md b/changelog.d/student-loan-idr-restrictions.fixed.md index bfe9e810632..6284b7ee7b2 100644 --- a/changelog.d/student-loan-idr-restrictions.fixed.md +++ b/changelog.d/student-loan-idr-restrictions.fixed.md @@ -1 +1 @@ -Count FFEL debt in the student loan plans' spousal proration, use the latest return's adjusted gross income, and cap Income-Based Repayment at the couple's combined standard payment. +Count FFEL debt in the student loan plans' spousal proration, use the latest return's adjusted gross income, and cap Income-Based Repayment at the couple's combined standard payment. Add the 10-year standard payment on parent PLUS loans. diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml new file mode 100644 index 00000000000..6763baac663 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml @@ -0,0 +1,88 @@ +# 10-year level payment on parent PLUS loans under 34 CFR 685.208(b)(1): +# balance x i / (1 - (1 + i) ^ -120), i = rate / 12, at least 50 dollars and no +# more than the balance. + +- name: Case 1, 30,000 dollars of parent PLUS loans at 6 percent. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_parent_plus_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + output: + # Issue #10411 item 6: 30,000 x 0.005 / (1 - 1.005 ^ -120) = 333.06. The + # parent has no loans the income-driven plans or the Direct Loan standard + # payment cover. + parent_plus_standard_repayment_payment: 333.06 + standard_repayment_payment: 0 + income_driven_repayment_standard_payment: 0 + +- name: Case 2, a small parent PLUS balance pays the 50 dollar minimum. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_parent_plus_loan_balance: 3_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + output: + # 3,000 x 0.005 / (1 - 1.005 ^ -120) = 33.31, below the 50 minimum. + parent_plus_standard_repayment_payment: 50 + +- name: Case 3, a parent PLUS balance below the minimum is the final payment. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_parent_plus_loan_balance: 30 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + output: + parent_plus_standard_repayment_payment: 30 + +- name: Case 4, parent PLUS and Direct Loans keep separate standard payments. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 12_000 + federal_parent_plus_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + output: + # Parent PLUS: 333.06. Direct Loans: 12,000 x 0.005 / (1 - 1.005 ^ -120) = + # 133.22, which alone sets the income-driven repayment cap. + parent_plus_standard_repayment_payment: 333.06 + standard_repayment_payment: 133.22 + income_driven_repayment_standard_payment: 133.22 + +- name: Case 5, no parent PLUS balance means no payment. + period: 2026-01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + output: + parent_plus_standard_repayment_payment: 0 diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py index d8ade965028..3123a3b5753 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py @@ -9,9 +9,10 @@ class federal_parent_plus_loan_balance(Variable): "Outstanding principal and interest on Federal Direct PLUS Loans the " "person borrowed as a parent on behalf of a dependent student, and on " "Direct Consolidation Loans that repaid such loans. These excepted " - "loans cannot be repaid under the Repayment Assistance Plan. This is " - "the opening balance for the year: it is uprated by CPI-U across years " - "with no repayment roll-forward." + "loans cannot be repaid under the Repayment Assistance Plan; " + "parent_plus_standard_repayment_payment repays them under the " + "standard plan. This is the opening balance for the year: it is " + "uprated by CPI-U across years with no repayment roll-forward." ) unit = USD quantity_type = STOCK diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py index 68f5c06e51e..e3c1f2cee9e 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py @@ -9,7 +9,9 @@ class federal_student_loan_interest_rate(Variable): "Annual interest rate on the loans in federal_student_loan_balance, " "weighted by balance. Used to amortize the balance under the " "standard repayment plan. The Income-Based Repayment plan's standard " - "payment cap also applies it to the person's federal_ffel_loan_balance." + "payment cap also applies it to the person's federal_ffel_loan_balance, " + "and parent_plus_standard_repayment_payment to their " + "federal_parent_plus_loan_balance." ) unit = "/1" definition_period = YEAR diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py new file mode 100644 index 00000000000..b67f0ee49cb --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py @@ -0,0 +1,43 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ed.student_loans.student_loan_amortization_helpers import ( + amortized_monthly_payment, +) + + +class parent_plus_standard_repayment_payment(Variable): + value_type = float + entity = Person + label = "Parent PLUS standard repayment plan monthly payment" + documentation = ( + "Fixed monthly payment that repays the person's parent PLUS loans " + "(federal_parent_plus_loan_balance) over the 10-year standard " + "repayment plan, which covers Direct PLUS Loan borrowers, parents " + "included. The balance is amortized at the person's " + "federal_student_loan_interest_rate, a single rate for all of their " + "federal loans, and the payment is at least the plan minimum and no " + "more than the balance. It assumes the borrower is in active " + "repayment. This is separate from standard_repayment_payment, so the " + "income-driven repayment caps stay on the loans those plans can " + "repay. Parent PLUS loans made on or after July 1, 2026, which repay " + "under the tiered standard plan, are not modeled here, nor is " + "repayment of consolidated parent PLUS loans under the " + "Income-Contingent Repayment plan." + ) + unit = USD + definition_period = MONTH + reference = ( + "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1087e&num=0&edition=prelim", + "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(1)", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.ed.student_loans.standard_repayment + balance = person("federal_parent_plus_loan_balance", period.this_year) + annual_rate = person("federal_student_loan_interest_rate", period.this_year) + number_of_payments = p.repayment_period * MONTHS_IN_YEAR + amortized_payment = amortized_monthly_payment( + balance, annual_rate, number_of_payments + ) + # As in standard_repayment_payment, the minimum does not apply to a + # final payment below it. + return min_(max_(amortized_payment, p.minimum_payment), balance) From e41cd091719edc60ff609f32bb4518bc3bf90516 Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Mon, 26 Oct 2026 11:27:39 -0400 Subject: [PATCH 4/9] Hold federal student loan balances at their dataset value Drop CPI-U uprating from federal_student_loan_balance, federal_parent_plus_loan_balance and federal_ffel_loan_balance, and exempt them from the default dollar-input uprating in default_uprating.py. A loan stock is not a price- or income-indexed flow, and repayment is not rolled forward, so with auto carry-over a later year reads the stored balance unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../student-loan-idr-restrictions.fixed.md | 2 +- docs-quarto/methodology/spm-poverty.qmd | 2 +- ...ome_driven_repayment_standard_payment.yaml | 21 ++++++++++++++++ ...arent_plus_standard_repayment_payment.yaml | 21 ++++++++++++++++ .../standard_repayment_payment.yaml | 24 +++++++++++++++++++ policyengine_us/tools/default_uprating.py | 15 ++++++++++-- .../federal_ffel_loan_balance.py | 6 ++--- .../federal_parent_plus_loan_balance.py | 6 ++--- .../federal_student_loan_balance.py | 8 +++---- 9 files changed, 91 insertions(+), 14 deletions(-) diff --git a/changelog.d/student-loan-idr-restrictions.fixed.md b/changelog.d/student-loan-idr-restrictions.fixed.md index 6284b7ee7b2..92b9bb20e55 100644 --- a/changelog.d/student-loan-idr-restrictions.fixed.md +++ b/changelog.d/student-loan-idr-restrictions.fixed.md @@ -1 +1 @@ -Count FFEL debt in the student loan plans' spousal proration, use the latest return's adjusted gross income, and cap Income-Based Repayment at the couple's combined standard payment. Add the 10-year standard payment on parent PLUS loans. +Count FFEL debt in the student loan plans' spousal proration, use the latest return's adjusted gross income, and cap Income-Based Repayment at the couple's combined standard payment. Add the 10-year standard payment on parent PLUS loans, and hold federal student loan balances at their dataset value in later years instead of uprating them. diff --git a/docs-quarto/methodology/spm-poverty.qmd b/docs-quarto/methodology/spm-poverty.qmd index acfebcc638e..f0ea3e3acb4 100644 --- a/docs-quarto/methodology/spm-poverty.qmd +++ b/docs-quarto/methodology/spm-poverty.qmd @@ -50,7 +50,7 @@ The [MOOP decomposition page](moop-decomposition.qmd) records the design and the Population results use the Microcosm US dataset. The current default is Build P: 57,240 Current Population Survey Annual Social and Economic Supplement households with 166,321 people, whose weights are fitted to 5,659 administrative and demographic targets (IRS Statistics of Income by state and by income, Census population estimates by age, sex and state, CMS Medicaid and Marketplace enrollment, USDA SNAP participation and benefits, SSA SSI, TANF, state tax collections and BEA national accounts). Poverty rates are never a target. That is a rule of the calibration, not an accident: a model whose weights were fitted to survey-based poverty rates could not be checked against them. -2025 values are the 2024 survey values carried forward by the model's uprating parameters: each uprated input grows by the ratio of its uprating series between the two years. Household weights grow with total population, so an input that follows a national total grows by that total divided by population ([`per_capita_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/per_capita_uprating.py)); its weighted total then tracks the national total. The main income sources (employment, self-employment and partnership income, Social Security, pensions and retirement account distributions, capital gains, dividends, interest, rental and farm income, alimony received and paid, and unemployment compensation) follow [IRS Statistics of Income series](https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/parameters/calibration/gov/irs/soi). Each series holds IRS totals through its last recorded year and a CBO projection extends it from there, so the growth applied from 2024 to 2025 is CBO's, per capita. These health inputs follow [CMS's projection of per-capita out-of-pocket health spending](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml), which is already a per-person series: the other medical expenses and over-the-counter health expenses that enter SPM resources; and physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses. CMS counts premiums outside out-of-pocket spending, so premium inputs follow premium series instead, each a per-person series that grows at its last published one-year rate to 2100 and holds the 2100 value after that. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Health savings account payroll contributions, which the Microcosm data do not fill, follow [the HSA contribution limit for family coverage](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml) in 26 U.S.C. 223(b)(2). Section 223(g) indexes that limit to chained CPI-U, and after the IRS's last published year the model computes it from the statute, so a contribution at the limit stays at the limit. Employer-sponsored insurance premiums, pre-tax health insurance premiums and the premiums people report paying (health insurance premiums excluding Medicare Part B, and the other health insurance premiums that enter SPM resources) follow [CMS's employer-sponsored insurance premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml), which CMS projects to 2034. These are the employer's share of employer plans, the employee's share paid through payroll, and the premiums survey respondents report, most of which people with employer coverage pay; with one series for all three, a premium the data move from the reported inputs to the pre-tax input keeps growing the same way. Health insurance premiums supplied directly, which the survey data hold only for self-employed people under 65 without employer coverage, follow the closest CMS series for that group, [CMS's direct purchase premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml) (individually bought plans, Marketplace plans and Medigap), also projected to 2034. Long-term care insurance premiums, which no dataset fills and for which CMS's health expenditure projections have no premium series, follow [the medical care component of CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml) by calendar year, which CBO projects to 2036. It stands in for the index that sets the deductible limits on those premiums, the chained CPI-U medical care component for August of the preceding year (26 U.S.C. 213(d)(10)(B)), which the CBO and CMS projections do not provide. Reported Medicare Part B premiums follow [the Part B standard premium](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml), which the Medicare Trustees project to 2035; within an income tier, a premium with its income-related adjustment is a fixed multiple of the standard premium before rounding. The series is a growth proxy, not a forecast of each person's premium: hold-harmless limits and Medicare Advantage premium reductions move some premiums differently. Veterans' benefits follow the [Social Security cost-of-living index](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/ssa/uprating.yaml). Every other uprated dollar input follows [CBO's projection of total adjusted gross income](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml), the default that [`default_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/default_uprating.py) assigns, per capita like every national total. Inputs on the default include tax units' first and second home mortgage interest and balances, home equity, personal property, SPM unit assets, mortgage payments, charitable donations, casualty losses, educator expenses, tax preparation fees, energy efficiency and clean energy property expenditures, state unemployment insurance wage and earnings inputs, employer payroll totals and local earnings and wage tax inputs. Household weights follow the model's [total population series](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/census/populations/total.yaml), which takes its values from CBO's demographic outlook; person and SPM unit weights read the household weight. Inputs without an uprating parameter (ages, take-up indicators, tenure, geography) carry over unchanged, so household composition is held at its 2024 state. The population is projected, not resampled. +2025 values are the 2024 survey values carried forward by the model's uprating parameters: each uprated input grows by the ratio of its uprating series between the two years. Household weights grow with total population, so an input that follows a national total grows by that total divided by population ([`per_capita_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/per_capita_uprating.py)); its weighted total then tracks the national total. The main income sources (employment, self-employment and partnership income, Social Security, pensions and retirement account distributions, capital gains, dividends, interest, rental and farm income, alimony received and paid, and unemployment compensation) follow [IRS Statistics of Income series](https://github.com/PolicyEngine/policyengine-us/tree/main/policyengine_us/parameters/calibration/gov/irs/soi). Each series holds IRS totals through its last recorded year and a CBO projection extends it from there, so the growth applied from 2024 to 2025 is CBO's, per capita. These health inputs follow [CMS's projection of per-capita out-of-pocket health spending](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/moop_per_capita.yaml), which is already a per-person series: the other medical expenses and over-the-counter health expenses that enter SPM resources; and physician services, inpatient, outpatient, emergency room, urgent care, ambulance, imaging, lab and prescription expenses. CMS counts premiums outside out-of-pocket spending, so premium inputs follow premium series instead, each a per-person series that grows at its last published one-year rate to 2100 and holds the 2100 value after that. These expenses, assets and debts follow [CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u.yaml): rent, child care and other care costs, child support paid and received, utilities, phone and broadband, tuition, homeowners insurance and association fees, transportation costs, real estate taxes and the energy subsidy; the person-level home mortgage interest input, student loan interest, investment interest expense and vehicle loan interest; bank account, stock and bond assets and the value of the primary residence; the value, equity and debt of business assets, rental property and other real estate; and vehicle equity and debt, the auto loan balance and net worth. These pay and wage-replacement inputs follow the IRS employment income series: hourly wages, tips, overtime premiums, military and government pay, the W-2 wages of qualified businesses, desired 401(k), 403(b) and IRA contributions, workers' compensation and disability benefits. Desired self-employed pension contributions follow the IRS self-employment income series. Health savings account payroll contributions, which the Microcosm data do not fill, follow [the HSA contribution limit for family coverage](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/irs/gross_income/health_savings_account_contributions/limit/family.yaml) in 26 U.S.C. 223(b)(2). Section 223(g) indexes that limit to chained CPI-U, and after the IRS's last published year the model computes it from the statute, so a contribution at the limit stays at the limit. Employer-sponsored insurance premiums, pre-tax health insurance premiums and the premiums people report paying (health insurance premiums excluding Medicare Part B, and the other health insurance premiums that enter SPM resources) follow [CMS's employer-sponsored insurance premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/esi_premium_per_enrollee.yaml), which CMS projects to 2034. These are the employer's share of employer plans, the employee's share paid through payroll, and the premiums survey respondents report, most of which people with employer coverage pay; with one series for all three, a premium the data move from the reported inputs to the pre-tax input keeps growing the same way. Health insurance premiums supplied directly, which the survey data hold only for self-employed people under 65 without employer coverage, follow the closest CMS series for that group, [CMS's direct purchase premium per enrollee](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/direct_purchase_premium_per_enrollee.yaml) (individually bought plans, Marketplace plans and Medigap), also projected to 2034. Long-term care insurance premiums, which no dataset fills and for which CMS's health expenditure projections have no premium series, follow [the medical care component of CPI-U](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/bls/cpi/cpi_u_medical_care.yaml) by calendar year, which CBO projects to 2036. It stands in for the index that sets the deductible limits on those premiums, the chained CPI-U medical care component for August of the preceding year (26 U.S.C. 213(d)(10)(B)), which the CBO and CMS projections do not provide. Reported Medicare Part B premiums follow [the Part B standard premium](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/hhs/cms/medicare_part_b_premium_per_enrollee.yaml), which the Medicare Trustees project to 2035; within an income tier, a premium with its income-related adjustment is a fixed multiple of the standard premium before rounding. The series is a growth proxy, not a forecast of each person's premium: hold-harmless limits and Medicare Advantage premium reductions move some premiums differently. Veterans' benefits follow the [Social Security cost-of-living index](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/gov/ssa/uprating.yaml). Every other uprated dollar input follows [CBO's projection of total adjusted gross income](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/cbo/income_by_source.yaml), the default that [`default_uprating.py`](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/tools/default_uprating.py) assigns, per capita like every national total. Inputs on the default include tax units' first and second home mortgage interest and balances, home equity, personal property, SPM unit assets, mortgage payments, charitable donations, casualty losses, educator expenses, tax preparation fees, energy efficiency and clean energy property expenditures, state unemployment insurance wage and earnings inputs, employer payroll totals and local earnings and wage tax inputs. Household weights follow the model's [total population series](https://github.com/PolicyEngine/policyengine-us/blob/main/policyengine_us/parameters/calibration/gov/census/populations/total.yaml), which takes its values from CBO's demographic outlook; person and SPM unit weights read the household weight. Inputs without an uprating parameter (ages, take-up indicators, tenure, geography, and federal student loan balances, which `default_uprating.py` exempts from the default) carry over unchanged, so household composition is held at its 2024 state. The population is projected, not resampled. ## What the model does not do diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml index 65915404abc..4ce2015e5d8 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml @@ -107,3 +107,24 @@ output: # Only the head's 20,000: 222.04. income_driven_repayment_standard_payment: 222.04 + +- name: Case 6, an FFEL balance given for 2024 reads unchanged in 2026. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_ffel_loan_balance: + 2024: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + output: + # Held at 30,000 with no uprating; the cap amortizes it: 333.06. + federal_ffel_loan_balance: + 2025: 30_000 + 2026: 30_000 + income_driven_repayment_standard_payment: + 2026-01: 333.06 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml index 6763baac663..19e2849d028 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml @@ -86,3 +86,24 @@ members: [person1] output: parent_plus_standard_repayment_payment: 0 + +- name: Case 6, a parent PLUS balance given for 2024 reads unchanged in 2026. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_parent_plus_loan_balance: + 2024: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + output: + # Held at 30,000 with no uprating; the payment amortizes it as in Case 1. + federal_parent_plus_loan_balance: + 2025: 30_000 + 2026: 30_000 + parent_plus_standard_repayment_payment: + 2026-01: 333.06 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/standard_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/standard_repayment_payment.yaml index 4c25393c47c..7bb582abf80 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/standard_repayment_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/standard_repayment_payment.yaml @@ -76,3 +76,27 @@ members: [person1] output: standard_repayment_payment: 0 + +- name: Case 6, a balance given for 2024 reads unchanged in 2026. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + # As in a dataset dated 2024 that a simulation reads in 2026. + federal_student_loan_balance: + 2024: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + output: + # The balance is held at 30,000: no price or income uprating applies + # (CPI-U would give about 31,626 in 2026). The payment amortizes the + # unchanged balance: 333.06, as in Case 1. + federal_student_loan_balance: + 2025: 30_000 + 2026: 30_000 + standard_repayment_payment: + 2026-01: 333.06 diff --git a/policyengine_us/tools/default_uprating.py b/policyengine_us/tools/default_uprating.py index d26a56d6d5a..150a6c44557 100644 --- a/policyengine_us/tools/default_uprating.py +++ b/policyengine_us/tools/default_uprating.py @@ -103,6 +103,15 @@ ] +# Loan balances are stocks held at their dataset value: no price or income +# index applies, and repayment is not rolled forward. +NO_DEFAULT_UPRATING_VARIABLES = { + "federal_student_loan_balance", + "federal_parent_plus_loan_balance", + "federal_ffel_loan_balance", +} + + def _is_float_dollar_input(variable) -> bool: return ( variable.is_input_variable() @@ -113,8 +122,10 @@ def _is_float_dollar_input(variable) -> bool: def add_default_uprating(system): for variable in system.variables.values(): - should_default = variable.is_input_variable() and ( - variable.name in INPUT_VARIABLES or _is_float_dollar_input(variable) + should_default = ( + variable.name not in NO_DEFAULT_UPRATING_VARIABLES + and variable.is_input_variable() + and (variable.name in INPUT_VARIABLES or _is_float_dollar_input(variable)) ) if should_default and variable.uprating is None: variable.uprating = DEFAULT_DOLLAR_INPUT_UPRATING diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py index 0f0c3229d3f..9f1ebf2f1e8 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py @@ -15,13 +15,13 @@ class federal_ffel_loan_balance(Variable): "(g)(3) and the 10-year standard payment cap of the Income-Based " "Repayment plan. FFEL loans consolidated into a Direct Consolidation " "Loan belong in federal_student_loan_balance instead. This is the " - "opening balance for the year: it is uprated by CPI-U across years " - "with no repayment roll-forward." + "opening balance for the year. Later years hold it at its dataset " + "(input) value, with no price or income uprating and no repayment " + "roll-forward." ) unit = USD quantity_type = STOCK definition_period = YEAR - uprating = "gov.bls.cpi.cpi_u" reference = ( "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(5)(ii)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(6)", diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py index 3123a3b5753..98a65f9bbfe 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py @@ -11,13 +11,13 @@ class federal_parent_plus_loan_balance(Variable): "Direct Consolidation Loans that repaid such loans. These excepted " "loans cannot be repaid under the Repayment Assistance Plan; " "parent_plus_standard_repayment_payment repays them under the " - "standard plan. This is the opening balance for the year: it is " - "uprated by CPI-U across years with no repayment roll-forward." + "standard plan. This is the opening balance for the year. Later years " + "hold it at its dataset (input) value, with no price or income " + "uprating and no repayment roll-forward." ) unit = USD quantity_type = STOCK definition_period = YEAR - uprating = "gov.bls.cpi.cpi_u" reference = ( "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1087e&num=0&edition=prelim", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(7)", diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_balance.py index 86210ac5f38..f2400e73791 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_balance.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_balance.py @@ -15,14 +15,14 @@ class federal_student_loan_balance(Variable): "federal_parent_plus_loan_balance), and FFEL or Perkins loans that have " "not been consolidated into a Direct Consolidation Loan (see " "federal_ffel_loan_balance for FFEL loans). This is the opening balance " - "for the year: it is uprated by CPI-U across years with no repayment " - "roll-forward, and within a year only the Repayment Assistance Plan's " - "final-payment cap draws it down." + "for the year. Later years hold it at its dataset (input) value, with " + "no price or income uprating and no repayment roll-forward; within a " + "year, only the Repayment Assistance Plan's final-payment cap draws it " + "down." ) unit = USD quantity_type = STOCK definition_period = YEAR - uprating = "gov.bls.cpi.cpi_u" reference = ( "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(c)(6)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(d)(4)", From d5632317e57ba32aa9c50255eec7a034ecfa2242 Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Mon, 26 Oct 2026 12:04:07 -0400 Subject: [PATCH 5/9] Carry the latest-return AGI, FFEL proration and couple's cap to PAYE and ICR - PAYE and ICR discretionary income and ICR's income percentage factor read income_driven_repayment_adjusted_gross_income. - PAYE caps at the couple's standard payment on eligible Direct and FFEL loans before prorating (685.209(f)(2), (g)(1)), as IBR does, and keeps the FFEL proration through the shared balance share. - Add PAYE cases for spouses at 0% and 12%, a binding couple's cap, a spouse's FFEL debt, and the latest-return AGI for PAYE and ICR. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../student-loan-idr-restrictions.fixed.md | 2 +- policyengine_us/programs.yaml | 8 +- ...ingent_repayment_discretionary_income.yaml | 22 ++++ ...nt_repayment_income_percentage_factor.yaml | 19 ++++ .../pay_as_you_earn_discretionary_income.yaml | 22 ++++ .../pay_as_you_earn_payment.yaml | 103 +++++++++++++++++- .../federal_ffel_loan_balance.py | 3 +- .../federal_student_loan_interest_rate.py | 5 +- ...ntingent_repayment_discretionary_income.py | 10 +- ...gent_repayment_income_percentage_factor.py | 8 +- ..._driven_repayment_adjusted_gross_income.py | 5 +- .../income_driven_repayment_balance_share.py | 5 +- ...ncome_driven_repayment_standard_payment.py | 3 +- .../pay_as_you_earn_discretionary_income.py | 10 +- .../pay_as_you_earn_payment.py | 39 +++++-- 15 files changed, 225 insertions(+), 39 deletions(-) diff --git a/changelog.d/student-loan-idr-restrictions.fixed.md b/changelog.d/student-loan-idr-restrictions.fixed.md index 92b9bb20e55..130defa2d74 100644 --- a/changelog.d/student-loan-idr-restrictions.fixed.md +++ b/changelog.d/student-loan-idr-restrictions.fixed.md @@ -1 +1 @@ -Count FFEL debt in the student loan plans' spousal proration, use the latest return's adjusted gross income, and cap Income-Based Repayment at the couple's combined standard payment. Add the 10-year standard payment on parent PLUS loans, and hold federal student loan balances at their dataset value in later years instead of uprating them. +Count FFEL debt in the student loan plans' spousal proration, use the latest return's adjusted gross income, and cap Income-Based Repayment and Pay As You Earn at the couple's combined standard payment. Add the 10-year standard payment on parent PLUS loans, and hold federal student loan balances at their dataset value in later years instead of uprating them. diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 3a80cb7f067..fcf914f0949 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1621,7 +1621,7 @@ programs: variable: income_based_repayment_payment parameter_prefix: gov.ed.student_loans.income_based_repayment verified_start_year: 2026 - notes: Monthly payment (15% or 10% for new borrowers of income above 150% of the poverty guideline, cap at the couple's 10-year standard payment on current Direct and FFEL balances, spousal and FFEL proration, small payment adjustment); zero for borrowers with a Direct Loan made on or after July 1, 2026; tax-dependent borrowers treated as ineligible; interest and forgiveness not modeled; not subtracted from household net income + notes: Monthly payment (15% or 10% for new borrowers of income above 150% of the poverty guideline on the latest return's AGI, cap at the couple's 10-year standard payment on current Direct and FFEL balances, spousal and FFEL proration, small payment adjustment); zero for borrowers with a Direct Loan made on or after July 1, 2026; tax-dependent borrowers treated as ineligible; interest and forgiveness not modeled; not subtracted from household net income - id: save_plan name: SAVE plan (counterfactual) @@ -1633,7 +1633,7 @@ programs: variable: save_plan_payment parameter_prefix: gov.ed.student_loans.save_plan verified_start_year: 2026 - notes: Counterfactual monthly payment under 34 CFR 685.209(f)(1) (5% undergraduate and 10% graduate rates on income above 225% of the poverty guideline, spousal and FFEL proration, small payment adjustment); computed for any period without implying the plan is available; interest and forgiveness not modeled; not subtracted from household net income + notes: Counterfactual monthly payment under 34 CFR 685.209(f)(1) (5% undergraduate and 10% graduate rates on income above 225% of the poverty guideline on the latest return's AGI, spousal and FFEL proration, small payment adjustment); computed for any period without implying the plan is available; interest and forgiveness not modeled; not subtracted from household net income - id: pay_as_you_earn name: Pay As You Earn @@ -1645,7 +1645,7 @@ programs: variable: pay_as_you_earn_payment parameter_prefix: gov.ed.student_loans.pay_as_you_earn verified_start_year: 2026 - notes: Monthly payment (10% of income above 150% of the poverty guideline, spousal and FFEL proration, 10-year standard payment cap on the current balance, small payment adjustment); zero for borrowers with a Direct Loan made on or after July 1, 2026; enrollment limits (new borrower, repaying under PAYE on July 1, 2024) and the June 30, 2028 end not modeled; interest and forgiveness not modeled; not subtracted from household net income + notes: Monthly payment (10% of income above 150% of the poverty guideline on the latest return's AGI, cap at the couple's 10-year standard payment on current Direct and FFEL balances, spousal and FFEL proration, small payment adjustment); tax-dependent borrowers treated as ineligible; zero for borrowers with a Direct Loan made on or after July 1, 2026; enrollment limits (new borrower, repaying under PAYE on July 1, 2024) and the June 30, 2028 end not modeled; interest and forgiveness not modeled; not subtracted from household net income - id: income_contingent_repayment name: Income-Contingent Repayment @@ -1657,7 +1657,7 @@ programs: variable: income_contingent_repayment_payment parameter_prefix: gov.ed.student_loans.income_contingent_repayment verified_start_year: 2026 - notes: Monthly payment (lesser of the 12-year fixed payment on the current balance times the income percentage factor from the 2025 and 2026 Federal Register notices, and 20% of income above the poverty guideline; $5 minimum); zero for borrowers with a Direct Loan made on or after July 1, 2026; joint repayment by married borrowers, enrollment limits and the June 30, 2028 end not modeled; interest and forgiveness not modeled; not subtracted from household net income + notes: Monthly payment (lesser of the 12-year fixed payment on the current balance times the income percentage factor from the 2025 and 2026 Federal Register notices, and 20% of income above the poverty guideline, both on the latest return's AGI; $5 minimum); tax-dependent borrowers treated as ineligible; zero for borrowers with a Direct Loan made on or after July 1, 2026; joint repayment by married borrowers, enrollment limits and the June 30, 2028 end not modeled; interest and forgiveness not modeled; not subtracted from household net income - id: tiered_standard_repayment name: Tiered standard repayment plan diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_discretionary_income.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_discretionary_income.yaml index 96b61be8b40..127ff4c4391 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_discretionary_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_discretionary_income.yaml @@ -62,3 +62,25 @@ state_code: TX output: income_contingent_repayment_discretionary_income: 0 + +- name: Case 4, discretionary income uses the latest return's AGI. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + # The most recent return shows 41,769; income this year is 80,000. + income_driven_repayment_adjusted_gross_income: 41_769 + adjusted_gross_income: 80_000 + households: + household: + members: [person1] + state_code: TX + output: + # (41,769 - 15,960) / 12 = 2,150.75, as in Case 1. + income_contingent_repayment_discretionary_income: 2_150.75 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_income_percentage_factor.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_income_percentage_factor.yaml index 40f71f8cb42..47f234c9ba7 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_income_percentage_factor.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_income_percentage_factor.yaml @@ -149,3 +149,22 @@ # 13,717: 50.52%. income_contingent_repayment_income_percentage_factor: 2026-10: [0.55, 2, 0.5052] + +- name: Case 8, the factor uses the latest return's AGI. + period: 2026 + absolute_error_margin: 0.000001 + input: + people: + person1: + age: 30 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + # The most recent return shows 35,104; income this year is 80,000. + income_driven_repayment_adjusted_gross_income: 35_104 + adjusted_gross_income: 80_000 + output: + # The 35,104 row's factor, as in Case 1. + income_contingent_repayment_income_percentage_factor: + 2026-10: 0.7189 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_discretionary_income.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_discretionary_income.yaml index 166dcc7851c..62b235c62b9 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_discretionary_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_discretionary_income.yaml @@ -40,3 +40,25 @@ state_code: TX output: pay_as_you_earn_discretionary_income: 0 + +- name: Case 3, discretionary income uses the latest return's AGI. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + # The most recent return shows 40,000; income this year is 80,000. + income_driven_repayment_adjusted_gross_income: 40_000 + adjusted_gross_income: 80_000 + households: + household: + members: [person1] + state_code: TX + output: + # (40,000 - 23,940) / 12 = 1,338.33, as in Case 1. + pay_as_you_earn_discretionary_income: 1_338.33 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml index 83c9f78b3e3..39ce80d0e6c 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml @@ -1,8 +1,11 @@ # Monthly payment under 34 CFR 685.209(b)(4)(ii), (f)(2), (g)(1): # discretionary income = max(AGI - 1.5 x poverty guideline, 0) -# payment = 10% x discretionary income / 12 -# x own share of the couple's eligible balance (joint filers) -# capped at the 10-year standard payment; under 5 -> 0; 5 to under 10 -> 10. +# payment = lesser of 10% x discretionary income / 12 and the 10-year +# standard payment on the eligible Direct and FFEL loans of the borrower +# and, for joint filers, the spouse ((f)(2), (e)(2)(i)) +# x own Direct balance / couple's Direct and FFEL balance ((g)(1)(i)-(ii)) +# rounded to the cent; under 5 -> 0; 5 to under 10 -> 10; at most the +# borrower's Direct balance. # 2026 guideline in the 48 states: 15,960 + 5,680 per additional person. # Standard payment at 6 percent: 30,000 -> 333.06; 40,000 -> 444.08; # 20,000 -> 222.04; 10,000 -> 111.02. @@ -79,7 +82,7 @@ # 222.04. pay_as_you_earn_payment: [264.11, 132.06] -- name: Case 4, joint filers who both borrowed are each capped at their own standard payment. +- name: Case 4, the couple's combined standard payment caps joint filers who both borrowed. period: 2026-01 absolute_error_margin: 0.01 input: @@ -102,8 +105,9 @@ members: [person1, person2] state_code: TX output: - # 10% x (150,000 - 32,460) / 12 = 979.50; shares 80% and 20%: 783.60 and - # 195.90, above the caps of 444.08 and 111.02. + # 10% x (150,000 - 32,460) / 12 = 979.50 > the couple's cap of + # 444.08 + 111.02 = 555.10; shares 80% and 20% of 555.10: 444.08 and + # 111.02. pay_as_you_earn_payment: [444.08, 111.02] - name: Case 5, a payment under 5 dollars becomes zero. @@ -226,3 +230,90 @@ # 1.5 x 15,960 = 23,940; 10% x 600 / 12 = 5.00 exactly, which is not less # than 5, so it rises to 10 (685.209(g)(1)(iii)(B)). pay_as_you_earn_payment: 10 + +- name: Case 10, spouses with different interest rates are capped together before proration. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0 + person2: + age: 38 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.12 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 104_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # Issue #10411 example. 1.5 x 21,640 = 32,460; 10% x 72,000 / 12 = 600. + # Standard payments: 30,000 / 120 = 250 and + # 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41; the couple's cap is 680.41. + # min(600, 680.41) = 600, split 50/50: 300 each. Capping each spouse at + # their own standard payment would give 250 + 300 instead. + pay_as_you_earn_payment: [300, 300] + +- name: Case 11, the couple's combined standard payment caps a high income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0 + person2: + age: 38 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.12 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 128_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 10% x (128,460 - 32,460) / 12 = 800 > 680.4128, so the cap binds: + # 680.4128 x 50% = 340.2064 -> 340.21 each. Separate caps would give + # min(400, 250) + min(400, 430.41) = 250 + 400. + pay_as_you_earn_payment: [340.21, 340.21] + +- name: Case 12, a spouse's FFEL debt counts in the joint proration. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + federal_student_loan_interest_rate: 0.06 + person2: + # The spouse has only FFEL loans, which this plan does not repay. + age: 38 + federal_ffel_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 61_000 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 10% x (61,000 - 32,460) / 12 = 237.83, under the cap of + # 111.02 + 333.06 = 444.08. The borrower pays 237.83 x 10,000 / 40,000 = + # 59.46 (685.209(g)(1)(i)-(ii)); the spouse owes nothing. + pay_as_you_earn_payment: [59.46, 0] diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py index 9f1ebf2f1e8..0b1073ccfb3 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py @@ -13,7 +13,8 @@ class federal_ffel_loan_balance(Variable): "income-driven repayment plans do not repay these loans, but the " "balance enters the spousal and FFEL adjustments of 685.209(g)(1) and " "(g)(3) and the 10-year standard payment cap of the Income-Based " - "Repayment plan. FFEL loans consolidated into a Direct Consolidation " + "Repayment and Pay As You Earn plans. FFEL loans consolidated into a " + "Direct Consolidation " "Loan belong in federal_student_loan_balance instead. This is the " "opening balance for the year. Later years hold it at its dataset " "(input) value, with no price or income uprating and no repayment " diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py index e3c1f2cee9e..12fc9bb2e87 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py @@ -8,8 +8,9 @@ class federal_student_loan_interest_rate(Variable): documentation = ( "Annual interest rate on the loans in federal_student_loan_balance, " "weighted by balance. Used to amortize the balance under the " - "standard repayment plan. The Income-Based Repayment plan's standard " - "payment cap also applies it to the person's federal_ffel_loan_balance, " + "standard repayment plan. The Income-Based Repayment and Pay As You " + "Earn plans' standard payment cap also applies it to the person's " + "federal_ffel_loan_balance, " "and parent_plus_standard_repayment_payment to their " "federal_parent_plus_loan_balance." ) diff --git a/policyengine_us/variables/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_discretionary_income.py b/policyengine_us/variables/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_discretionary_income.py index e1a2470835a..6a48471812f 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_discretionary_income.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_discretionary_income.py @@ -6,8 +6,10 @@ class income_contingent_repayment_discretionary_income(Variable): entity = TaxUnit label = "Income-Contingent Repayment plan discretionary income" documentation = ( - "Adjusted gross income above the plan's multiple of the poverty " - "guideline, per month. Joint filers combine both spouses' income; " + "Adjusted gross income from the most recent return " + "(income_driven_repayment_adjusted_gross_income) above the plan's " + "multiple of the poverty guideline, per month. Joint filers combine " + "both spouses' income; " "married people filing separately are in separate tax units, so only " "the borrower's income counts. Married borrowers who file separately " "but elect to repay jointly, and so combine income, are not modeled." @@ -21,7 +23,9 @@ class income_contingent_repayment_discretionary_income(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.ed.student_loans.income_contingent_repayment - agi = tax_unit("adjusted_gross_income", period.this_year) + agi = tax_unit( + "income_driven_repayment_adjusted_gross_income", period.this_year + ) poverty_guideline = tax_unit( "income_driven_repayment_poverty_guideline", period.this_year ) diff --git a/policyengine_us/variables/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_income_percentage_factor.py b/policyengine_us/variables/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_income_percentage_factor.py index a20b2453912..5eff5ed37d6 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_income_percentage_factor.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_contingent_repayment/income_contingent_repayment_income_percentage_factor.py @@ -8,7 +8,9 @@ class income_contingent_repayment_income_percentage_factor(Variable): documentation = ( "Factor from the Department of Education's annual notice that scales " "the 12-year fixed payment under the Income-Contingent Repayment plan, " - "based on adjusted gross income. Single filers use the single table; " + "based on adjusted gross income from the most recent return " + "(income_driven_repayment_adjusted_gross_income). Single filers use " + "the single table; " "joint, separate, head of household and surviving spouse filers use " "the married or head of household table. An income between two rows " "of the table is linearly interpolated and rounded to the nearest " @@ -27,7 +29,9 @@ def formula(tax_unit, period, parameters): p = parameters( period ).gov.ed.student_loans.income_contingent_repayment.income_percentage_factor - agi = tax_unit("adjusted_gross_income", period.this_year) + agi = tax_unit( + "income_driven_repayment_adjusted_gross_income", period.this_year + ) filing_status = tax_unit("filing_status", period.this_year) is_single = filing_status == filing_status.possible_values.SINGLE # The table rows are points to interpolate between, not brackets. diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py index 70c93b2f773..fd7893ad52c 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py @@ -9,8 +9,9 @@ class income_driven_repayment_adjusted_gross_income(Variable): documentation = ( "Adjusted gross income on the most recent federal income tax return " "available when the Department of Education sets the monthly payment, " - "which the Repayment Assistance Plan, Income-Based Repayment and SAVE " - "plans use as income: 20 U.S.C. 1087e(q)(4)(A) takes adjusted gross " + "which the Repayment Assistance Plan and the Income-Based Repayment, " + "SAVE, Pay As You Earn and Income-Contingent Repayment plans use as " + "income: 20 U.S.C. 1087e(q)(4)(A) takes adjusted gross " "income 'for the most recent taxable year'. That is usually the prior " "year's return, or documentation of current income the borrower " "supplies instead. Household and microsimulation inputs usually cover " diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py index ef54a7445c3..c199aa4deaf 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py @@ -6,8 +6,9 @@ class income_driven_repayment_balance_share(Variable): entity = Person label = "Income-driven repayment share of the couple's eligible balance" documentation = ( - "Share of the Income-Based Repayment or SAVE plan payment that the " - "person owes on their Direct Loans. 34 CFR 685.209(g)(1)(i) first " + "Share of the Income-Based Repayment, SAVE or Pay As You Earn plan " + "payment that the person owes on their Direct Loans. 34 CFR " + "685.209(g)(1)(i) first " "prorates the payment by the person's share of the eligible loans of " "the head and spouse of their tax unit, where eligible loans are " "Direct and FFEL loans (federal_student_loan_balance plus " diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py index d467555ce4c..1612fe29d21 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py @@ -11,7 +11,8 @@ class income_driven_repayment_standard_payment(Variable): documentation = ( "What the borrower would pay on the 10-year standard repayment plan " "based on the eligible loan balances and interest rates, the cap on " - "Income-Based Repayment plan payments. Eligible loans are Direct and " + "Income-Based Repayment and Pay As You Earn plan payments. Eligible " + "loans are Direct and " "FFEL loans other than parent PLUS loans and excepted consolidation " "loans. A joint filer's spouse's eligible loans count too, so this is " "the sum over the head and spouse of the tax unit of each one's " diff --git a/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_discretionary_income.py b/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_discretionary_income.py index 19dc5c5fa43..284cc6e776c 100644 --- a/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_discretionary_income.py +++ b/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_discretionary_income.py @@ -6,8 +6,10 @@ class pay_as_you_earn_discretionary_income(Variable): entity = TaxUnit label = "Pay As You Earn plan discretionary income" documentation = ( - "Adjusted gross income above the plan's multiple of the poverty " - "guideline, per month. Joint filers combine both spouses' income; " + "Adjusted gross income from the most recent return " + "(income_driven_repayment_adjusted_gross_income) above the plan's " + "multiple of the poverty guideline, per month. Joint filers combine " + "both spouses' income; " "married people filing separately are in separate tax units, so only " "the borrower's income counts." ) @@ -20,7 +22,9 @@ class pay_as_you_earn_discretionary_income(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.ed.student_loans.pay_as_you_earn - agi = tax_unit("adjusted_gross_income", period.this_year) + agi = tax_unit( + "income_driven_repayment_adjusted_gross_income", period.this_year + ) poverty_guideline = tax_unit( "income_driven_repayment_poverty_guideline", period.this_year ) diff --git a/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py b/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py index a512101bfbb..f03de8cd401 100644 --- a/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py @@ -6,17 +6,24 @@ class pay_as_you_earn_payment(Variable): entity = Person label = "Pay As You Earn plan monthly payment" documentation = ( - "Monthly payment owed by a borrower under the Pay As You Earn plan: " - "the plan rate times discretionary income, prorated for joint filers " - "whose spouse also has eligible loans, capped at the 10-year standard " - "payment, then with the small payment adjustment. As with the " - "Income-Based Repayment plan, the cap uses the current balance, not " - "the balance when the borrower entered the plan. A borrower with a " + "Monthly payment owed by a borrower under the Pay As You Earn plan, " + "assuming the borrower is enrolled in the plan and in active " + "repayment, not in deferment, forbearance or default. The payment is " + "the lesser of the plan rate times discretionary income and the " + "10-year standard payment on the eligible loans " + "(income_driven_repayment_standard_payment, which for joint filers " + "covers both spouses' Direct and FFEL loans, each at their own " + "interest rate). That amount is prorated to the borrower's Direct " + "Loans (income_driven_repayment_balance_share), rounded to the cent, " + "given the small payment adjustment and capped at the borrower's " + "Direct Loan balance. As with the Income-Based Repayment plan, the " + "cap uses the current balance, not the balance when the borrower " + "entered the plan. A borrower with a " "Direct Loan made on or after July 1, 2026 cannot use the plan and " "owes nothing under it. The other limits on who can repay under the " "plan are not modeled: being a new borrower under 34 CFR " "685.209(b)(13)(i), repaying under the plan on July 1, 2024, and the " - "plan's end on June 30, 2028 (685.209(c)(4)). The capped payment is " + "plan's end on June 30, 2028 (685.209(c)(4)). The prorated payment is " "rounded to the cent before the small payment adjustment, a modeling " "choice: a calculated payment just under $5, such as $4.996, rounds " "to $5.00 and becomes $10 rather than $0 under 685.209(g)(1)(iii), " @@ -29,6 +36,7 @@ class pay_as_you_earn_payment(Variable): "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1087e&num=0&edition=prelim", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(c)(4)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(d)(5)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)(i)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(2)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(1)", "https://www.govinfo.gov/content/pkg/FR-2026-05-01/pdf/2026-08556.pdf#page=123", @@ -39,21 +47,28 @@ def formula(person, period, parameters): discretionary_income = person.tax_unit( "pay_as_you_earn_discretionary_income", period ) + # 685.209(f)(2): the lesser of the income-based amount and the + # standard payment on all of the couple's eligible loans, then the + # (g)(1)(i)-(ii) proration to the borrower's Direct Loans. + standard_payment = person.tax_unit( + "income_driven_repayment_standard_payment", period + ) + capped_payment = min_( + p.pay_as_you_earn.rate * discretionary_income, standard_payment + ) balance_share = person( "income_driven_repayment_balance_share", period.this_year ) - prorated_payment = p.pay_as_you_earn.rate * discretionary_income * balance_share - standard_payment = person("standard_repayment_payment", period) # Payments are set in whole cents. Rounding before the small payment # test keeps an exact $5.00 from falling below the threshold through # floating-point error. It also lifts an amount within half a cent # below $5 to $5.00, a modeling choice described in the documentation. - capped_payment = np.round(min_(prorated_payment, standard_payment), 2) + prorated_payment = np.round(capped_payment * balance_share, 2) small_payment = p.income_driven_repayment.small_payment adjusted_payment = where( - capped_payment < small_payment.threshold, + prorated_payment < small_payment.threshold, 0, - max_(capped_payment, small_payment.minimum), + max_(prorated_payment, small_payment.minimum), ) balance = person("federal_student_loan_balance", period.this_year) payment = min_(adjusted_payment, balance) From cb6363a7fae2a06839068108fff13fe82e8b576a Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Mon, 26 Oct 2026 12:55:00 -0400 Subject: [PATCH 6/9] Skip the held loan balances in the long-run uprater check test_float_dollar_inputs_have_long_run_upraters requires every float dollar input to carry an uprater. The loan balances that default_uprating.py now holds at their dataset value are the deliberate exception, so the check skips NO_DEFAULT_UPRATING_VARIABLES. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../policy/baseline/parameters/test_uprating_extensions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py index 01e350f6715..b34c768e596 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -9,6 +9,7 @@ from policyengine_core.parameters import Parameter, ParameterNode from policyengine_us.system import system +from policyengine_us.tools.default_uprating import NO_DEFAULT_UPRATING_VARIABLES from policyengine_us.tools.per_capita_uprating import per_capita_path from policyengine_us.parameters.uprating_extensions import ( DEPENDENT_STANDARD_DEDUCTION_STATUTORY_BASES, @@ -255,7 +256,9 @@ def test_retirement_distribution_inputs_use_pension_upraters(): def test_float_dollar_inputs_have_long_run_upraters(): - """Every float USD input should have a resolvable non-flat 2100 uprater.""" + """Every float USD input should have a resolvable non-flat 2100 uprater, + except the stocks default_uprating.py deliberately holds at their dataset + value.""" missing = [] unresolvable = [] flat = [] @@ -267,6 +270,8 @@ def test_float_dollar_inputs_have_long_run_upraters(): and variable.unit == "currency-USD" ): continue + if variable.name in NO_DEFAULT_UPRATING_VARIABLES: + continue if variable.uprating is None: missing.append(variable.name) From fa6ac3ad9981092a1e4e825c0bdc54019946beb2 Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Tue, 27 Oct 2026 10:00:22 -0400 Subject: [PATCH 7/9] Cap IBR and PAYE at each borrower's own standard payment; term consolidated parent PLUS loans by amount Address the review of #10415: - 20 U.S.C. 1098e(b)(6)(A) caps the payment "for all loans made to the borrower" at that borrower's 10-year standard payment, and 34 CFR 685.209(e)(2)(i) brings a spouse's loans into the (g) proration only. income_driven_repayment_standard_payment is now a Person amount on the person's own Direct and FFEL loans; IBR and PAYE prorate first and cap at the Direct Loan part of that amount. - income_driven_repayment_adjusted_gross_income cites 685.209(b)(10) for the IBR, SAVE, PAYE and ICR plans and keeps 1087e(q)(4)(A) for RAP. - A Direct Consolidation Loan that repaid parent PLUS loans and entered repayment on or after July 1, 2006 repays over 10 to 30 years (685.208(b)(2)(iii); 685.220(i)(2)(ii), (i)(3)(i)): new input federal_parent_plus_loan_is_consolidated and parameter standard_repayment.consolidation_repayment_period. - New FFEL, Parent PLUS, dependent, MFS, barred-spouse, final-payment and rounding edge cases; IBR cases renumbered 18-22. - Paragraph-level statute links, tighter cites, a guard test for the uprating exemption, programs.yaml notes, and an added changelog type. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LqCGPgxDukji8Bhs57c5rH --- .../student-loan-idr-restrictions.added.md | 1 + .../student-loan-idr-restrictions.fixed.md | 1 - .../consolidation_repayment_period.yaml | 43 +++++++ policyengine_us/programs.yaml | 6 +- .../income_based_repayment_payment.yaml | 120 ++++++++++++++---- ...riven_repayment_adjusted_gross_income.yaml | 6 +- ...income_driven_repayment_balance_share.yaml | 61 +++++++++ ...ome_driven_repayment_standard_payment.yaml | 61 +++++++-- .../pay_as_you_earn_payment.yaml | 68 ++++++---- ...epayment_assistance_plan_base_payment.yaml | 35 +++++ .../repayment_assistance_plan_eligible.yaml | 16 +++ .../repayment_assistance_plan_payment.yaml | 58 +++++++++ ...arent_plus_standard_repayment_payment.yaml | 89 ++++++++++++- .../parameters/test_uprating_extensions.py | 10 ++ policyengine_us/tools/default_uprating.py | 5 +- .../federal_ffel_loan_balance.py | 8 +- .../federal_parent_plus_loan_balance.py | 4 +- ...ederal_parent_plus_loan_is_consolidated.py | 24 ++++ .../federal_student_loan_interest_rate.py | 13 +- .../income_based_repayment_payment.py | 75 +++++++---- ..._driven_repayment_adjusted_gross_income.py | 34 +++-- .../income_driven_repayment_eligible.py | 4 + ...ncome_driven_repayment_standard_payment.py | 46 +++---- .../pay_as_you_earn_payment.py | 81 +++++++----- .../repayment_assistance_plan_payment.py | 7 +- .../parent_plus_standard_repayment_payment.py | 48 +++++-- 26 files changed, 734 insertions(+), 190 deletions(-) create mode 100644 changelog.d/student-loan-idr-restrictions.added.md delete mode 100644 changelog.d/student-loan-idr-restrictions.fixed.md create mode 100644 policyengine_us/parameters/gov/ed/student_loans/standard_repayment/consolidation_repayment_period.yaml create mode 100644 policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_is_consolidated.py diff --git a/changelog.d/student-loan-idr-restrictions.added.md b/changelog.d/student-loan-idr-restrictions.added.md new file mode 100644 index 00000000000..9e2d0177760 --- /dev/null +++ b/changelog.d/student-loan-idr-restrictions.added.md @@ -0,0 +1 @@ +Count FFEL debt in the student loan plans' spousal proration and in each borrower's own Income-Based Repayment and Pay As You Earn standard payment cap, and use the latest return's adjusted gross income. Add the standard payment on parent PLUS loans, with the 10- to 30-year term for consolidated parent PLUS loans, and hold federal student loan balances at their dataset value in later years instead of uprating them. diff --git a/changelog.d/student-loan-idr-restrictions.fixed.md b/changelog.d/student-loan-idr-restrictions.fixed.md deleted file mode 100644 index 130defa2d74..00000000000 --- a/changelog.d/student-loan-idr-restrictions.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Count FFEL debt in the student loan plans' spousal proration, use the latest return's adjusted gross income, and cap Income-Based Repayment and Pay As You Earn at the couple's combined standard payment. Add the 10-year standard payment on parent PLUS loans, and hold federal student loan balances at their dataset value in later years instead of uprating them. diff --git a/policyengine_us/parameters/gov/ed/student_loans/standard_repayment/consolidation_repayment_period.yaml b/policyengine_us/parameters/gov/ed/student_loans/standard_repayment/consolidation_repayment_period.yaml new file mode 100644 index 00000000000..a90382cd90b --- /dev/null +++ b/policyengine_us/parameters/gov/ed/student_loans/standard_repayment/consolidation_repayment_period.yaml @@ -0,0 +1,43 @@ +description: The Department of Education amortizes a Direct Consolidation Loan that entered repayment on or after July 1, 2006 over this number of years under the standard repayment plan, based on the total of the consolidation loan and the borrower's other education loans. +# A total "less than" a threshold falls in the tier below it; a total "equal +# to or greater than" a threshold falls in the tier that starts there, which +# matches the default right=False evaluation of the scale. +brackets: + - threshold: + 2006-07-01: 0 + amount: + 2006-07-01: 10 + - threshold: + 2006-07-01: 7_500 + amount: + 2006-07-01: 12 + - threshold: + 2006-07-01: 10_000 + amount: + 2006-07-01: 15 + - threshold: + 2006-07-01: 20_000 + amount: + 2006-07-01: 20 + - threshold: + 2006-07-01: 40_000 + amount: + 2006-07-01: 25 + - threshold: + 2006-07-01: 60_000 + amount: + 2006-07-01: 30 + +metadata: + type: single_amount + threshold_unit: currency-USD + amount_unit: year + period: year + label: Standard repayment plan repayment period for Direct Consolidation Loans + reference: + - title: 34 CFR 685.208(b)(2)(iii) + href: https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(2)(iii) + - title: 34 CFR 685.208(c), (j) (as of November 17, 2016) + href: https://www.ecfr.gov/on/2016-11-17/title-34/section-685.208#p-685.208(j) + - title: 34 CFR 685.220(i)(2)(ii), (i)(3)(i) + href: https://www.ecfr.gov/current/title-34/section-685.220#p-685.220(i)(2)(ii) diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 2034a784723..129b154ae96 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1629,7 +1629,7 @@ programs: variable: income_based_repayment_payment parameter_prefix: gov.ed.student_loans.income_based_repayment verified_start_year: 2026 - notes: Monthly payment (15% or 10% for new borrowers of income above 150% of the poverty guideline on the latest return's AGI, cap at the couple's 10-year standard payment on current Direct and FFEL balances, spousal and FFEL proration, small payment adjustment); zero for borrowers with a Direct Loan made on or after July 1, 2026; tax-dependent borrowers treated as ineligible; interest and forgiveness not modeled; not subtracted from household net income + notes: Monthly payment (15% or 10% for new borrowers of income above 150% of the poverty guideline on the latest return's AGI, spousal and FFEL proration, cap at the borrower's own 10-year standard payment on their current Direct and FFEL balances, small payment adjustment); zero for borrowers with a Direct Loan made on or after July 1, 2026; tax-dependent borrowers treated as ineligible; interest and forgiveness not modeled; not subtracted from household net income - id: save_plan name: SAVE plan (counterfactual) @@ -1641,7 +1641,7 @@ programs: variable: save_plan_payment parameter_prefix: gov.ed.student_loans.save_plan verified_start_year: 2026 - notes: Counterfactual monthly payment under 34 CFR 685.209(f)(1) (5% undergraduate and 10% graduate rates on income above 225% of the poverty guideline on the latest return's AGI, spousal and FFEL proration, small payment adjustment); computed for any period without implying the plan is available; interest and forgiveness not modeled; not subtracted from household net income + notes: Counterfactual monthly payment under 34 CFR 685.209(f)(1) (5% undergraduate and 10% graduate rates on income above 225% of the poverty guideline on the latest return's AGI, spousal and FFEL proration, small payment adjustment); computed for any period without implying the plan is available; tax-dependent borrowers treated as ineligible; interest and forgiveness not modeled; not subtracted from household net income - id: pay_as_you_earn name: Pay As You Earn @@ -1653,7 +1653,7 @@ programs: variable: pay_as_you_earn_payment parameter_prefix: gov.ed.student_loans.pay_as_you_earn verified_start_year: 2026 - notes: Monthly payment (10% of income above 150% of the poverty guideline on the latest return's AGI, cap at the couple's 10-year standard payment on current Direct and FFEL balances, spousal and FFEL proration, small payment adjustment); tax-dependent borrowers treated as ineligible; zero for borrowers with a Direct Loan made on or after July 1, 2026; enrollment limits (new borrower, repaying under PAYE on July 1, 2024) and the June 30, 2028 end not modeled; interest and forgiveness not modeled; not subtracted from household net income + notes: Monthly payment (10% of income above 150% of the poverty guideline on the latest return's AGI, spousal and FFEL proration, cap at the borrower's own 10-year standard payment on their current Direct and FFEL balances, small payment adjustment); tax-dependent borrowers treated as ineligible; zero for borrowers with a Direct Loan made on or after July 1, 2026; enrollment limits (new borrower, repaying under PAYE on July 1, 2024) and the June 30, 2028 end not modeled; interest and forgiveness not modeled; not subtracted from household net income - id: income_contingent_repayment name: Income-Contingent Repayment diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml index ca06edf37f1..93a9b823204 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.yaml @@ -1,9 +1,11 @@ # Monthly payment under 20 U.S.C. 1098e and 34 CFR 685.209(f)(2)-(3), (g)(1): # discretionary income = max(AGI - 1.5 x poverty guideline, 0) -# payment = lesser of 15% (new borrowers 10%) x discretionary income / 12 -# and the 10-year standard payment on the eligible Direct and FFEL loans of -# the borrower and, for joint filers, the spouse ((f)(2)-(3), (e)(2)(i)) -# x own Direct balance / couple's Direct and FFEL balance ((g)(1)(i)-(ii)) +# payment = lesser of +# 15% (new borrowers 10%) x discretionary income / 12 +# x own Direct balance / couple's Direct and FFEL balance ((g)(1)(i)-(ii)) +# and the borrower's own 10-year standard payment on their Direct and FFEL +# loans (1098e(b)(6)(A); (f)(2)(ii), (f)(3)(ii)) +# x own Direct balance / own Direct and FFEL balance ((g)(1)(ii)) # rounded to the cent; under 5 -> 0; 5 to under 10 -> 10; at most the # borrower's Direct balance. # 2026 guideline in the 48 states: 15,960 + 5,680 per additional person. @@ -420,7 +422,7 @@ # (685.209(g)(1)(iii)(A)). income_based_repayment_payment: 0 -- name: Case 17, spouses with different interest rates are capped together before proration. +- name: Case 18, spouses with different interest rates are each capped at their own standard payment. period: 2026-01 absolute_error_margin: 0.01 input: @@ -443,14 +445,13 @@ members: [person1, person2] state_code: TX output: - # Issue #10411 example. 1.5 x 21,640 = 32,460; 15% x 48,000 / 12 = 600. - # Standard payments: 30,000 / 120 = 250 and - # 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41, so the couple's cap is 680.41. - # min(600, 680.41) = 600, split 50/50: 300 each. Capping each spouse at - # their own standard payment would give 250 + 300 instead. - income_based_repayment_payment: [300, 300] + # Issue #10411 example. 1.5 x 21,640 = 32,460; 15% x 48,000 / 12 = 600, + # split 50/50: 300 each. Standard payments: 30,000 / 120 = 250 and + # 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41. min(300, 250) = 250 and + # min(300, 430.41) = 300 (20 U.S.C. 1098e(b)(6)(A)). + income_based_repayment_payment: [250, 300] -- name: Case 18, the couple's combined standard payment caps a high income. +- name: Case 19, each spouse's own standard payment caps a high income. period: 2026-01 absolute_error_margin: 0.01 input: @@ -473,12 +474,11 @@ members: [person1, person2] state_code: TX output: - # 15% x (96,460 - 32,460) / 12 = 800 > 680.4128, so the cap binds: - # 680.4128 x 50% = 340.2064 -> 340.21 each. Separate caps would give - # min(400, 250) + min(400, 430.41) = 250 + 400. - income_based_repayment_payment: [340.21, 340.21] + # 15% x (96,460 - 32,460) / 12 = 800, split 50/50: 400 each. + # min(400, 250) = 250 and min(400, 430.41) = 400. + income_based_repayment_payment: [250, 400] -- name: Case 19, spouses keep their own new-borrower and other-borrower rates. +- name: Case 20, spouses keep their own new-borrower and other-borrower rates. period: 2026-01 absolute_error_margin: 0.01 input: @@ -502,12 +502,12 @@ members: [person1, person2] state_code: TX output: - # Discretionary income (92,460 - 32,460) / 12 = 5,000. Couple's cap - # 2 x 333.0615 = 666.123. New borrower: min(10% x 5,000, 666.123) x 50% = - # 250. Other borrower: min(15% x 5,000, 666.123) x 50% = 333.06. + # Discretionary income (92,460 - 32,460) / 12 = 5,000. Each spouse's own + # standard payment is 333.06. New borrower: min(10% x 5,000 x 50%, + # 333.06) = 250. Other borrower: min(15% x 5,000 x 50%, 333.06) = 333.06. income_based_repayment_payment: [250, 333.06] -- name: Case 20, a spouse's FFEL debt counts in the joint proration and cap. +- name: Case 21, a spouse's FFEL debt counts in the joint proration. period: 2026-01 absolute_error_margin: 0.01 input: @@ -532,12 +532,12 @@ state_code: TX output: # IBR analogue of rulespec-us#1455 C16. 15% x (60,000 - 32,460) / 12 = - # 344.25, under the cap of 111.02 + 333.06 = 444.08. The borrower pays - # 344.25 x 10,000 / 40,000 = 86.06 (685.209(g)(1)(i)-(ii)); the spouse has - # no Direct Loans and owes nothing under the plan. + # 344.25. The borrower pays 344.25 x 10,000 / 40,000 = 86.06 + # (685.209(g)(1)(i)-(ii)), under their own standard payment of 111.02; the + # spouse has no Direct Loans and owes nothing under the plan. income_based_repayment_payment: [86.06, 0] -- name: Case 21, a borrower's own FFEL loans reduce the Direct Loan payment. +- name: Case 22, a borrower's own FFEL loans reduce the Direct Loan payment. period: 2026-01 absolute_error_margin: 0.01 input: @@ -561,6 +561,11 @@ members: [person2] filing_status: SINGLE adjusted_gross_income: 100_000 + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] households: household1: members: [person1] @@ -570,6 +575,67 @@ state_code: TX output: # 685.209(g)(1)(ii): x 10,000 / 40,000 = 25%. Cap on all 40,000 of - # eligible loans: 444.08. person1: min(200.75, 444.08) x 25% = 50.19. - # person2: min(950.75, 444.08) x 25% = 111.02. + # eligible loans: 444.08, of which the Direct Loans carry 25% = 111.02. + # person1: min(200.75 x 25%, 111.02) = 50.19. + # person2: min(950.75 x 25%, 111.02) = 111.02. income_based_repayment_payment: [50.19, 111.02] + +- name: Case 23, a Direct Loan balance under 10 dollars caps the 10 dollar minimum payment. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + federal_student_loan_balance: 8 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 100_000 + households: + household: + members: [person1] + state_code: TX + output: + # Standard payment min(max(0.09, 50), 8) = 8. Income-Based Repayment + # min(950.75, 8) and Pay As You Earn min(633.83, 8) give 8, which + # 685.209(g)(1)(iii)(B) raises to 10; the 8 dollar Direct Loan balance + # caps both at 8. + income_based_repayment_payment: 8 + pay_as_you_earn_payment: 8 + +- name: Case 24, a spouse barred by a loan made on or after July 1, 2026 still counts in the proration. + period: 2027-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + has_federal_student_loan_made_on_or_after_2026_07_01: true + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 60_000 + # Set so the case does not depend on the projected 2027 guideline. + income_driven_repayment_poverty_guideline: 20_000 + households: + household: + members: [person1, person2] + state_code: TX + output: + # The spouse's Direct Loans stay eligible loans (685.209(b)(5)); (d)(5) + # only stops the plan from repaying them. (60,000 - 1.5 x 20,000) / 12 = + # 2,500. Income-Based Repayment 15% = 375 and Pay As You Earn 10% = 250, + # each x 30,000 / 60,000 = 187.50 and 125, under the 333.06 standard + # payment; the spouse owes 0. + income_based_repayment_payment: [187.5, 0] + pay_as_you_earn_payment: [125, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.yaml index fca8432fd8d..3e8bd8e863a 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.yaml @@ -1,5 +1,7 @@ -# AGI on the most recent return (20 U.S.C. 1087e(q)(4)(A)). Without an input it -# defaults to the current year's AGI. +# AGI on the most recent return: 20 U.S.C. 1087e(q)(4)(A) for the Repayment +# Assistance Plan; 34 CFR 685.209(b)(10)(i) for the Income-Based Repayment, +# SAVE, Pay As You Earn and Income-Contingent Repayment plans. Without an input +# it defaults to the current year's AGI. - name: Case 1, defaults to the current year's adjusted gross income. period: 2027 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.yaml index e3c20acffab..0e4da5a62db 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.yaml @@ -111,3 +111,64 @@ # Couple 80,000. person1: 40,000 / 80,000 x 30,000 / 40,000 = 0.375. # person2: 40,000 / 80,000 x 20,000 / 40,000 = 0.25. income_driven_repayment_balance_share: [0.375, 0.25] + +- name: Case 7, married filing separately counts only the person's own Direct and FFEL loans. + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + person2: + age: 38 + federal_student_loan_balance: 20_000 + federal_ffel_loan_balance: 30_000 + tax_units: + tax_unit1: + members: [person1] + filing_status: SEPARATE + tax_unit2: + members: [person2] + filing_status: SEPARATE + marital_units: + marital_unit: + members: [person1, person2] + output: + # Separate returns: the spouse's income is not used, so the spouse's loans + # are not included either (685.209(e)(1)(i)(A), (e)(2)(i)) and there is no + # (g)(1)(i) proration. person1: 10,000 / 10,000 = 1. person2, (g)(1)(ii) + # alone: 20,000 / (20,000 + 30,000) = 0.4. + income_driven_repayment_balance_share: [1, 0.4] + +- name: Case 8, a spouse's Parent PLUS loans and a tax dependent's FFEL loans do not dilute the head's share. + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + person1: + age: 50 + is_tax_unit_head: true + is_tax_unit_spouse: false + federal_student_loan_balance: 20_000 + person2: + age: 48 + is_tax_unit_head: false + is_tax_unit_spouse: true + federal_parent_plus_loan_balance: 30_000 + person3: + # An adult relative claimed as a dependent, with FFEL loans made + # before the FFEL Program stopped new loans on June 30, 2010. + age: 40 + is_tax_unit_head: false + is_tax_unit_spouse: false + federal_ffel_loan_balance: 15_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + filing_status: JOINT + output: + # Parent PLUS loans are not eligible loans (685.209(b)(5)(i)-(ii)), and + # only a spouse's loans join the borrower's (685.209(e)(2)(i)), so the + # couple's eligible balance is person1's 20,000: 20,000 / 20,000 = 1. + income_driven_repayment_balance_share: [1, 0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml index 4ce2015e5d8..d86798b4eb3 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml @@ -1,8 +1,9 @@ -# Cap on Income-Based Repayment payments (34 CFR 685.209(f)(2)(ii), (f)(3)(ii)): -# the sum over the head and spouse of each one's 10-year standard payment on -# their eligible Direct and FFEL loans ((b)(5), (e)(2)(i)) at their own rate, -# balance x i / (1 - (1 + i) ^ -120) with i = rate / 12, at least 50 dollars -# and no more than the eligible balance (685.208(b)(1)). +# Cap on Income-Based Repayment and Pay As You Earn payments (20 U.S.C. +# 1098e(b)(6)(A); 34 CFR 685.209(f)(2)(ii), (f)(3)(ii)): each person's own +# 10-year standard payment on their eligible Direct and FFEL loans ((b)(5)) at +# their own rate, balance x i / (1 - (1 + i) ^ -120) with i = rate / 12, at +# least 50 dollars and no more than the eligible balance (685.208(b)(1)). A +# spouse's loans enter only the (g) proration ((e)(2)(i)), not this amount. - name: Case 1, a single borrower's cap is their standard payment. period: 2026-01 @@ -20,7 +21,7 @@ # 30,000 x 0.005 / (1 - 1.005 ^ -120) = 333.06. income_driven_repayment_standard_payment: 333.06 -- name: Case 2, joint filers add standard payments at each spouse's own rate. +- name: Case 2, each spouse has their own standard payment at their own rate. period: 2026-01 absolute_error_margin: 0.01 input: @@ -39,7 +40,7 @@ filing_status: JOINT output: # 30,000 / 120 = 250; 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41. - income_driven_repayment_standard_payment: 680.41 + income_driven_repayment_standard_payment: [250, 430.41] - name: Case 3, FFEL loans enter the cap at the person's interest rate. period: 2026-01 @@ -59,8 +60,9 @@ members: [person1, person2] filing_status: JOINT output: - # 111.02 on the borrower's Direct Loans + 333.06 on the spouse's FFEL loans. - income_driven_repayment_standard_payment: 444.08 + # 10,000 x 0.005 / (1 - 1.005 ^ -120) = 111.02 on the borrower's Direct + # Loans; 333.06 on the spouse's FFEL loans. + income_driven_repayment_standard_payment: [111.02, 333.06] - name: Case 4, each person's standard payment is at least the 50 dollar minimum. period: 2026-01 @@ -80,10 +82,10 @@ members: [person1, person2] filing_status: JOINT output: - # 333.06 + max(33.31, 50). - income_driven_repayment_standard_payment: 383.06 + # 333.06 and max(33.31, 50) = 50. + income_driven_repayment_standard_payment: [333.06, 50] -- name: Case 5, a tax dependent's loans do not enter the cap. +- name: Case 5, each person's standard payment covers only their own loans. period: 2026-01 absolute_error_margin: 0.01 input: @@ -105,8 +107,10 @@ members: [person1, person2] filing_status: HEAD_OF_HOUSEHOLD output: - # Only the head's 20,000: 222.04. - income_driven_repayment_standard_payment: 222.04 + # The head's 20,000: 222.04. The dependent's 15,000: 166.53, which does + # not enter the head's cap; the dependent's own Income-Based Repayment and + # Pay As You Earn payments are 0 through income_driven_repayment_eligible. + income_driven_repayment_standard_payment: [222.04, 166.53] - name: Case 6, an FFEL balance given for 2024 reads unchanged in 2026. period: 2026 @@ -128,3 +132,32 @@ 2026: 30_000 income_driven_repayment_standard_payment: 2026-01: 333.06 + +- name: Case 7, joint filers where only one spouse borrowed are capped at that spouse's standard payment. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 150_000 + households: + household: + members: [person1, person2] + state_code: TX + output: + # The spouse has no eligible loans, so their standard payment is + # min(max(0, 50), 0) = 0, not the 50 dollar minimum. Income-Based + # Repayment at 15% and Pay As You Earn at 10% of (150,000 - 32,460) / 12 + # are 1,469.25 and 979.50, above 333.06. + income_driven_repayment_standard_payment: [333.06, 0] + income_based_repayment_payment: [333.06, 0] + pay_as_you_earn_payment: [333.06, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml index 39ce80d0e6c..2b2773a3e4a 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.yaml @@ -1,9 +1,11 @@ # Monthly payment under 34 CFR 685.209(b)(4)(ii), (f)(2), (g)(1): # discretionary income = max(AGI - 1.5 x poverty guideline, 0) -# payment = lesser of 10% x discretionary income / 12 and the 10-year -# standard payment on the eligible Direct and FFEL loans of the borrower -# and, for joint filers, the spouse ((f)(2), (e)(2)(i)) -# x own Direct balance / couple's Direct and FFEL balance ((g)(1)(i)-(ii)) +# payment = lesser of +# 10% x discretionary income / 12 +# x own Direct balance / couple's Direct and FFEL balance ((g)(1)(i)-(ii)) +# and the borrower's own 10-year standard payment on their Direct and FFEL +# loans ((f)(2)(ii)) +# x own Direct balance / own Direct and FFEL balance ((g)(1)(ii)) # rounded to the cent; under 5 -> 0; 5 to under 10 -> 10; at most the # borrower's Direct balance. # 2026 guideline in the 48 states: 15,960 + 5,680 per additional person. @@ -82,7 +84,7 @@ # 222.04. pay_as_you_earn_payment: [264.11, 132.06] -- name: Case 4, the couple's combined standard payment caps joint filers who both borrowed. +- name: Case 4, each spouse's own standard payment caps joint filers who both borrowed. period: 2026-01 absolute_error_margin: 0.01 input: @@ -105,9 +107,8 @@ members: [person1, person2] state_code: TX output: - # 10% x (150,000 - 32,460) / 12 = 979.50 > the couple's cap of - # 444.08 + 111.02 = 555.10; shares 80% and 20% of 555.10: 444.08 and - # 111.02. + # 10% x (150,000 - 32,460) / 12 = 979.50; shares 80% and 20%: 783.60 and + # 195.90, above each spouse's own standard payment of 444.08 and 111.02. pay_as_you_earn_payment: [444.08, 111.02] - name: Case 5, a payment under 5 dollars becomes zero. @@ -231,7 +232,7 @@ # than 5, so it rises to 10 (685.209(g)(1)(iii)(B)). pay_as_you_earn_payment: 10 -- name: Case 10, spouses with different interest rates are capped together before proration. +- name: Case 10, spouses with different interest rates are each capped at their own standard payment. period: 2026-01 absolute_error_margin: 0.01 input: @@ -254,14 +255,13 @@ members: [person1, person2] state_code: TX output: - # Issue #10411 example. 1.5 x 21,640 = 32,460; 10% x 72,000 / 12 = 600. - # Standard payments: 30,000 / 120 = 250 and - # 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41; the couple's cap is 680.41. - # min(600, 680.41) = 600, split 50/50: 300 each. Capping each spouse at - # their own standard payment would give 250 + 300 instead. - pay_as_you_earn_payment: [300, 300] + # Issue #10411 example. 1.5 x 21,640 = 32,460; 10% x 72,000 / 12 = 600, + # split 50/50: 300 each. Standard payments: 30,000 / 120 = 250 and + # 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.41. min(300, 250) = 250 and + # min(300, 430.41) = 300. + pay_as_you_earn_payment: [250, 300] -- name: Case 11, the couple's combined standard payment caps a high income. +- name: Case 11, each spouse's own standard payment caps a high income. period: 2026-01 absolute_error_margin: 0.01 input: @@ -284,10 +284,9 @@ members: [person1, person2] state_code: TX output: - # 10% x (128,460 - 32,460) / 12 = 800 > 680.4128, so the cap binds: - # 680.4128 x 50% = 340.2064 -> 340.21 each. Separate caps would give - # min(400, 250) + min(400, 430.41) = 250 + 400. - pay_as_you_earn_payment: [340.21, 340.21] + # 10% x (128,460 - 32,460) / 12 = 800, split 50/50: 400 each. + # min(400, 250) = 250 and min(400, 430.41) = 400. + pay_as_you_earn_payment: [250, 400] - name: Case 12, a spouse's FFEL debt counts in the joint proration. period: 2026-01 @@ -313,7 +312,30 @@ members: [person1, person2] state_code: TX output: - # 10% x (61,000 - 32,460) / 12 = 237.83, under the cap of - # 111.02 + 333.06 = 444.08. The borrower pays 237.83 x 10,000 / 40,000 = - # 59.46 (685.209(g)(1)(i)-(ii)); the spouse owes nothing. + # 10% x (61,000 - 32,460) / 12 = 237.83. The borrower pays + # 237.83 x 10,000 / 40,000 = 59.46 (685.209(g)(1)(i)-(ii)), under their own + # standard payment of 111.02; the spouse owes nothing. pay_as_you_earn_payment: [59.46, 0] + +- name: Case 13, a payment within half a cent below 5 dollars rounds to 5 and rises to 10. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + federal_student_loan_balance: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 24_539.52 + households: + household: + members: [person1] + state_code: TX + output: + # 24,539.52 - 23,940 = 599.52; 10% x 599.52 / 12 = 4.996, which rounds to + # 5.00 and rises to 10 (the modeling choice in the documentation). + pay_as_you_earn_payment: 10 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.yaml index c6b4a270e92..0b80bd23aee 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.yaml @@ -211,6 +211,11 @@ tax_unit2: members: [person2] adjusted_gross_income: 30_001 + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] output: # 2% x 30,000 = 600; 3% x 30,001 = 900.03. repayment_assistance_plan_base_payment: [600, 900.03] @@ -238,6 +243,11 @@ tax_unit2: members: [person2] adjusted_gross_income: 40_001 + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] output: # 3% x 40,000 = 1,200; 4% x 40,001 = 1,600.04. repayment_assistance_plan_base_payment: [1_200, 1_600.04] @@ -265,6 +275,11 @@ tax_unit2: members: [person2] adjusted_gross_income: 50_001 + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] output: # 4% x 50,000 = 2,000; 5% x 50,001 = 2,500.05. repayment_assistance_plan_base_payment: [2_000, 2_500.05] @@ -292,6 +307,11 @@ tax_unit2: members: [person2] adjusted_gross_income: 60_001 + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] output: # 5% x 60,000 = 3,000; 6% x 60,001 = 3,600.06. repayment_assistance_plan_base_payment: [3_000, 3_600.06] @@ -319,6 +339,11 @@ tax_unit2: members: [person2] adjusted_gross_income: 70_001 + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] output: # 6% x 70,000 = 4,200; 7% x 70,001 = 4,900.07. repayment_assistance_plan_base_payment: [4_200, 4_900.07] @@ -346,6 +371,11 @@ tax_unit2: members: [person2] adjusted_gross_income: 80_001 + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] output: # 7% x 80,000 = 5,600; 8% x 80,001 = 6,400.08. repayment_assistance_plan_base_payment: [5_600, 6_400.08] @@ -373,6 +403,11 @@ tax_unit2: members: [person2] adjusted_gross_income: 90_001 + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] output: # 8% x 90,000 = 7,200; 9% x 90,001 = 8,100.09. repayment_assistance_plan_base_payment: [7_200, 8_100.09] diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.yaml index b2b498a7e83..4321ba83987 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_eligible.yaml @@ -86,3 +86,19 @@ members: [person1, person2] output: repayment_assistance_plan_eligible: [true, true] + +- name: Case 6, a borrower with only FFEL loans is not eligible. + period: 2027-01 + input: + people: + person1: + age: 40 + federal_ffel_loan_balance: 30_000 + tax_units: + tax_unit: + members: [person1] + output: + # The plan repays only Direct Loans (685.209(c)(6), (d)(4); 20 U.S.C. + # 1087e(d)(1)(F), (q)(1)). FFEL loans count only in the spousal proration + # (685.209(b)(5)(ii), (g)(3)(i)). + repayment_assistance_plan_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.yaml index 0103678c3ba..5219361939f 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.yaml @@ -504,3 +504,61 @@ # 16 2/3, not 5% x 60,000 / 12 = 250. repayment_assistance_plan_applicable_monthly_payment: 16.67 repayment_assistance_plan_payment: 16.67 + +- name: Case 24, a spouse's Parent PLUS loans and a tax dependent's FFEL loans do not dilute the borrower's share. + period: 2027-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + is_tax_unit_head: true + is_tax_unit_spouse: false + federal_student_loan_balance: 20_000 + person2: + age: 48 + is_tax_unit_head: false + is_tax_unit_spouse: true + federal_parent_plus_loan_balance: 30_000 + person3: + # An adult relative claimed as a dependent (FFEL loans before 2010). + age: 40 + is_tax_unit_head: false + is_tax_unit_spouse: false + federal_ffel_loan_balance: 15_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + filing_status: JOINT + adjusted_gross_income: 90_000 + output: + # 8% x 90,000 = 7,200 / 12 = 600; 600 - 1 x 50 = 550. Parent PLUS loans + # are not eligible loans (685.209(b)(5)(i)-(ii)), and only a spouse's loans + # join the borrower's (685.209(e)(2)(i), (g)(3)(i)), so person1's share is + # 20,000 / 20,000 = 1. + repayment_assistance_plan_eligible: [true, false, false] + repayment_assistance_plan_applicable_monthly_payment: 550 + repayment_assistance_plan_payment: [550, 0, 0] + +- name: Case 25, the final-payment cap uses the Direct Loan balance, not FFEL loans the plan cannot repay. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 100 + federal_ffel_loan_balance: 30_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 60_000 + output: + # 5% x 60,000 = 3,000 / 12 = 250. The plan repays only the 100 of Direct + # Loans (685.209(d)(4)), so January pays the 100 left to repay + # ((q)(4)(B)(iii)) and later months pay 0. + repayment_assistance_plan_payment: + 2027-01: 100 + 2027-02: 0 + 2027: 100 diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml index f90f6a2da2f..6d35dc5bd63 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml @@ -1,6 +1,10 @@ -# 10-year level payment on parent PLUS loans under 34 CFR 685.208(b)(1): -# balance x i / (1 - (1 + i) ^ -120), i = rate / 12, at least 50 dollars and no -# more than the balance. +# Level payment on parent PLUS loans under the standard repayment plan: +# balance x i / (1 - (1 + i) ^ -n), i = rate / 12, at least 50 dollars and no +# more than the balance. n = 120 under 34 CFR 685.208(b)(1). For a Direct +# Consolidation Loan that entered repayment on or after July 1, 2006, +# 685.208(b)(2)(iii) sets 10 to 30 years by the consolidation loan plus the +# borrower's other education loans, counted up to the consolidation loan's +# amount (685.220(i)(2)(ii), (i)(3)(i)). - name: Case 1, 30,000 dollars of parent PLUS loans at 6 percent. period: 2026-01 @@ -127,3 +131,82 @@ # (1 - 1.005 ^ -180) = 253.16. parent_plus_standard_repayment_payment: 0 tiered_standard_repayment_payment: 253.16 + +- name: Case 8, a consolidated parent PLUS loan repays over 15 to 30 years by amount. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_parent_plus_loan_balance: 30_000 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_interest_rate: 0.06 + person2: + age: 50 + federal_parent_plus_loan_balance: 19_999.99 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_interest_rate: 0.06 + person3: + age: 50 + federal_parent_plus_loan_balance: 20_000 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_interest_rate: 0.06 + person4: + age: 50 + federal_parent_plus_loan_balance: 59_999.99 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_interest_rate: 0.06 + person5: + age: 50 + federal_parent_plus_loan_balance: 60_000 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + tax_unit3: + members: [person3] + tax_unit4: + members: [person4] + tax_unit5: + members: [person5] + output: + # No other education loans, so the term follows the consolidation loan: + # 30,000 -> 20 years: 30,000 x 0.005 / (1 - 1.005 ^ -240) = 214.93, not the + # 10-year 333.06. 19,999.99 -> 15 years (n = 180): 168.77. 20,000 -> 20 + # years: 143.29. 59,999.99 -> 25 years (n = 300): 386.58. 60,000 -> 30 + # years (n = 360): 359.73. + parent_plus_standard_repayment_payment: [214.93, 168.77, 143.29, 386.58, 359.73] + +- name: Case 9, other education loans set a consolidated loan's term up to its own amount. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_parent_plus_loan_balance: 15_000 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_balance: 50_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 50 + federal_parent_plus_loan_balance: 15_000 + federal_parent_plus_loan_is_consolidated: true + federal_ffel_loan_balance: 5_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + output: + # person1: the 50,000 of Direct Loans counts only up to the 15,000 + # consolidation loan (685.220(i)(3)(i)): 15,000 + 15,000 = 30,000 -> 20 + # years, 15,000 x 0.005 / (1 - 1.005 ^ -240) = 107.46 (30 years and 89.93 + # if the cap were ignored). person2: 15,000 + 5,000 of FFEL = 20,000 -> 20 + # years: 107.46 (15 years and 126.58 without the FFEL loans). + parent_plus_standard_repayment_payment: [107.46, 107.46] diff --git a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py index d5c4efe47a2..5fcdfaf1d7b 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -8,6 +8,7 @@ import yaml from policyengine_core.parameters import Parameter, ParameterNode +from policyengine_core.variables.variable import QuantityType from policyengine_us.system import system from policyengine_us.tools.default_uprating import NO_DEFAULT_UPRATING_VARIABLES @@ -297,6 +298,15 @@ def test_float_dollar_inputs_have_long_run_upraters(): assert flat == [] +def test_held_balances_are_unuprated_stocks(): + """Each variable default_uprating.py holds at its dataset value exists, + declares no uprater and is a stock, so the exemption cannot go stale.""" + for name in NO_DEFAULT_UPRATING_VARIABLES: + variable = system.variables[name] + assert variable.uprating is None, name + assert variable.quantity_type == QuantityType.STOCK, name + + def test_ssa_nawi_and_payroll_cap_extend_to_2100(): """Test that the SSA NAWI and payroll cap do not flatten after 2036.""" parameters = PARAMETERS diff --git a/policyengine_us/tools/default_uprating.py b/policyengine_us/tools/default_uprating.py index 150a6c44557..854117721e1 100644 --- a/policyengine_us/tools/default_uprating.py +++ b/policyengine_us/tools/default_uprating.py @@ -103,8 +103,9 @@ ] -# Loan balances are stocks held at their dataset value: no price or income -# index applies, and repayment is not rolled forward. +# Federal student loan balances are stocks held at their dataset value: no +# price or income index applies, and repayment is not rolled forward. Other +# debts, such as auto loan balances, keep their uprating. NO_DEFAULT_UPRATING_VARIABLES = { "federal_student_loan_balance", "federal_parent_plus_loan_balance", diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py index 0b1073ccfb3..e29754c1d8f 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py @@ -12,8 +12,9 @@ class federal_ffel_loan_balance(Variable): "to parents and excepted consolidation loans. The Direct Loan " "income-driven repayment plans do not repay these loans, but the " "balance enters the spousal and FFEL adjustments of 685.209(g)(1) and " - "(g)(3) and the 10-year standard payment cap of the Income-Based " - "Repayment and Pay As You Earn plans. FFEL loans consolidated into a " + "(g)(3), the borrower's own 10-year standard payment cap under the " + "Income-Based Repayment and Pay As You Earn plans, and the term of a " + "consolidated parent PLUS loan. FFEL loans consolidated into a " "Direct Consolidation " "Loan belong in federal_student_loan_balance instead. This is the " "opening balance for the year. Later years hold it at its dataset " @@ -27,4 +28,7 @@ class federal_ffel_loan_balance(Variable): "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(5)(ii)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(6)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(1)(ii)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(3)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(2)(ii)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(3)(ii)", ) diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py index 98a65f9bbfe..25cdb486118 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_balance.py @@ -11,7 +11,9 @@ class federal_parent_plus_loan_balance(Variable): "Direct Consolidation Loans that repaid such loans. These excepted " "loans cannot be repaid under the Repayment Assistance Plan; " "parent_plus_standard_repayment_payment repays them under the " - "standard plan. This is the opening balance for the year. Later years " + "standard plan, over 10 years, or over 10 to 30 years for a " + "consolidation loan flagged by federal_parent_plus_loan_is_consolidated. " + "This is the opening balance for the year. Later years " "hold it at its dataset (input) value, with no price or income " "uprating and no repayment roll-forward." ) diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_is_consolidated.py b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_is_consolidated.py new file mode 100644 index 00000000000..8b162a4c3d6 --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/federal_parent_plus_loan_is_consolidated.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class federal_parent_plus_loan_is_consolidated(Variable): + value_type = bool + entity = Person + label = "Parent PLUS loan balance is a Direct Consolidation Loan" + documentation = ( + "The person's federal_parent_plus_loan_balance is a Direct " + "Consolidation Loan that repaid parent PLUS loans and entered " + "repayment from July 1, 2006 through June 30, 2026. Its standard " + "repayment plan runs 10 to 30 years depending on the total of the " + "consolidation loan and the borrower's other education loans, " + "instead of 10 years. Leave it false for unconsolidated parent PLUS " + "loans and for consolidation loans that entered repayment before July " + "1, 2006, which repay over 10 years." + ) + definition_period = YEAR + default_value = False + reference = ( + "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(1)", + "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(2)", + "https://www.ecfr.gov/current/title-34/section-685.220#p-685.220(i)(2)(ii)", + ) diff --git a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py index 12fc9bb2e87..2ae2f8b8533 100644 --- a/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py +++ b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_interest_rate.py @@ -8,15 +8,18 @@ class federal_student_loan_interest_rate(Variable): documentation = ( "Annual interest rate on the loans in federal_student_loan_balance, " "weighted by balance. Used to amortize the balance under the " - "standard repayment plan. The Income-Based Repayment and Pay As You " - "Earn plans' standard payment cap also applies it to the person's " - "federal_ffel_loan_balance, " - "and parent_plus_standard_repayment_payment to their " - "federal_parent_plus_loan_balance." + "standard repayment plan. As a simplification, one rate stands in for " + "all of the person's federal loans: the Income-Based Repayment and " + "Pay As You Earn plans' standard payment cap also applies it to the " + "person's federal_ffel_loan_balance, and " + "parent_plus_standard_repayment_payment to their " + "federal_parent_plus_loan_balance, although FFEL and parent PLUS " + "loans carry their own rates (34 CFR 682.202(a), 685.202(a))." ) unit = "/1" definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-34/section-685.202#p-685.202(a)", "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(a)", + "https://www.ecfr.gov/current/title-34/section-682.202#p-682.202(a)", ) diff --git a/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py index 46f856bb048..e8ab75d21b1 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py @@ -8,25 +8,31 @@ class income_based_repayment_payment(Variable): documentation = ( "Monthly payment owed by a borrower under the Income-Based Repayment " "plan, assuming the borrower is enrolled in the plan and in active " - "repayment, not in deferment, forbearance or default. The payment is " - "the lesser of the borrower's plan rate times discretionary income " - "and the 10-year standard payment on the eligible loans " - "(income_driven_repayment_standard_payment, which for joint filers " - "covers both spouses' Direct and FFEL loans). That amount is prorated " - "to the borrower's Direct Loans (income_driven_repayment_balance_share), " - "rounded to the cent, given the small payment adjustment and capped at " - "the borrower's Direct Loan balance. Spouses keep their own rates, so a " - "new borrower pays 10 percent and the other spouse 15 percent of the " - "same discretionary income before the cap and proration. The cap uses " - "the current balance, not the balance when the borrower entered the " - "plan. A borrower with a Direct Loan made on or after July 1, 2026 " - "cannot use the plan and owes nothing under it." + "repayment, not in deferment, forbearance or default. The borrower's " + "plan rate times discretionary income is prorated to their Direct " + "Loans (income_driven_repayment_balance_share, which for joint filers " + "divides by both spouses' Direct and FFEL loans) and capped at the " + "Direct Loan part of the borrower's own 10-year standard payment " + "(income_driven_repayment_standard_payment on their Direct and FFEL " + "loans, times their Direct share of those loans). The result is " + "rounded to the cent, given the small payment adjustment and capped " + "at the borrower's Direct Loan balance. Spouses keep their own rates, " + "so a new borrower pays 10 percent and the other spouse 15 percent of " + "the same discretionary income before proration, and each spouse's " + "cap covers only their own loans. The cap uses the current balance, " + "not the balance when the borrower entered the plan. The prorated " + "payment is rounded to the cent before the small payment adjustment, " + "a modeling choice: a calculated payment just under $5, such as " + "$4.996, rounds to $5.00 and becomes $10 rather than $0 under 34 CFR " + "685.209(g)(1)(iii), which sets a payment of less than $5 to $0. A " + "borrower with a Direct Loan made on or after July 1, 2026 cannot use " + "the plan and owes nothing under it." ) unit = USD definition_period = MONTH defined_for = "income_driven_repayment_eligible" reference = ( - "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1098e&num=0&edition=prelim", + "https://www.law.cornell.edu/uscode/text/20/1098e#b_6_A", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)(i)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(2)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(3)", @@ -47,30 +53,47 @@ def formula(person, period, parameters): discretionary_income = person.tax_unit( "income_based_repayment_discretionary_income", period ) - # 685.209(f)(2)-(3): the lesser of the income-based amount and the - # standard payment on all of the couple's eligible loans, then the - # (g)(1)(i)-(ii) proration to the borrower's Direct Loans. - standard_payment = person.tax_unit( - "income_driven_repayment_standard_payment", period - ) - capped_payment = min_(rate * discretionary_income, standard_payment) + # 685.209(g)(1)(i)-(ii) prorate the income-based amount to the + # borrower's Direct Loans. 20 U.S.C. 1098e(b)(6)(A) and 685.209(f)(2)(ii), + # (f)(3)(ii) cap the payment on all of the borrower's eligible loans at + # their own 10-year standard payment; the Direct Loans carry the + # (g)(1)(ii) part of that cap. balance_share = person( "income_driven_repayment_balance_share", period.this_year ) + balance = person("federal_student_loan_balance", period.this_year) + eligible_balance = balance + person( + "federal_ffel_loan_balance", period.this_year + ) + direct_fraction = np.divide( + balance, + eligible_balance, + out=np.zeros_like(balance), + where=eligible_balance > 0, + ) + standard_payment = person("income_driven_repayment_standard_payment", period) # Payments are set in whole cents. Rounding before the small payment # test keeps an exact $5.00 from falling below the threshold through - # floating-point error (e.g. 15% x $400 / 12). - prorated_payment = np.round(capped_payment * balance_share, 2) + # floating-point error (e.g. 15% x $400 / 12). It also lifts an amount + # within half a cent below $5 to $5.00, a modeling choice described in + # the documentation. + prorated_payment = np.round( + min_( + rate * discretionary_income * balance_share, + standard_payment * direct_fraction, + ), + 2, + ) small_payment = p.income_driven_repayment.small_payment adjusted_payment = where( prorated_payment < small_payment.threshold, 0, max_(prorated_payment, small_payment.minimum), ) - balance = person("federal_student_loan_balance", period.this_year) payment = min_(adjusted_payment, balance) - # 20 U.S.C. 1087e(d)(6)-(7) and 34 CFR 685.209(d)(5) limit the plan to - # borrowers with no Direct Loan made on or after July 1, 2026. + # 20 U.S.C. 1087e(d)(1) and (d)(7)(A) and 34 CFR 685.209(d)(5) limit + # the plan to borrowers with no Direct Loan made on or after July 1, + # 2026. has_new_loan = person( "has_federal_student_loan_made_on_or_after_2026_07_01", period.this_year ) diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py index fd7893ad52c..0cebbdf59e4 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py @@ -8,22 +8,32 @@ class income_driven_repayment_adjusted_gross_income(Variable): unit = USD documentation = ( "Adjusted gross income on the most recent federal income tax return " - "available when the Department of Education sets the monthly payment, " - "which the Repayment Assistance Plan and the Income-Based Repayment, " - "SAVE, Pay As You Earn and Income-Contingent Repayment plans use as " - "income: 20 U.S.C. 1087e(q)(4)(A) takes adjusted gross " - "income 'for the most recent taxable year'. That is usually the prior " - "year's return, or documentation of current income the borrower " - "supplies instead. Household and microsimulation inputs usually cover " - "a single year, so reading the prior year would return zero income; " - "when not provided, this defaults to the current year's adjusted " - "gross income. An explicit input overrides it." + "available when the Department of Education sets the monthly payment. " + "For the Repayment Assistance Plan, 20 U.S.C. 1087e(q)(4)(A) defines it " + "as adjusted gross income 'for the most recent taxable year'. For the " + "Income-Based Repayment, SAVE, Pay As You Earn and Income-Contingent " + "Repayment plans, 34 CFR 685.209(b)(10)(i) defines income as adjusted " + "gross income as reported to the IRS, which the Department takes from " + "the latest return it obtains (685.209(l)(1)-(3); 20 U.S.C. " + "1098e(c)(2)(A)(i)). Whose income counts follows 685.209(e)(1)(i), and " + "(e)(1)(ii) for the Income-Contingent Repayment plan. A borrower whose " + "income fell can document current income instead (685.209(l)(6)). " + "When not provided, this defaults to the current year's adjusted gross " + "income, because household inputs usually cover only the simulated " + "year. The default overstates payments for borrowers whose income rose " + "since their last return. An explicit input overrides it. Family size " + "and the Repayment Assistance Plan's dependent count still come from " + "the current year's inputs, so an override from an earlier return " + "mixes years." ) definition_period = YEAR reference = ( - "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1087e&num=0&edition=prelim", + "https://www.law.cornell.edu/uscode/text/20/1087e#q_4_A", + "https://www.law.cornell.edu/uscode/text/20/1098e#c_2_A_i", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(10)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(1)(i)", - "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(l)(4)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(1)(ii)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(l)(1)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(l)(6)", ) adds = ["adjusted_gross_income"] diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_eligible.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_eligible.py index 6ae66c949b8..8fd59c80de4 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_eligible.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_eligible.py @@ -17,8 +17,12 @@ class income_driven_repayment_eligible(Variable): ) definition_period = MONTH reference = ( + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(c)(3)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(c)(4)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(c)(5)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(d)(1)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(d)(2)", + "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(d)(3)", ) def formula(person, period, parameters): diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py index 1612fe29d21..83d2358d965 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py @@ -6,40 +6,40 @@ class income_driven_repayment_standard_payment(Variable): value_type = float - entity = TaxUnit + entity = Person label = "Income-driven repayment 10-year standard payment cap" documentation = ( "What the borrower would pay on the 10-year standard repayment plan " - "based on the eligible loan balances and interest rates, the cap on " - "Income-Based Repayment and Pay As You Earn plan payments. Eligible " - "loans are Direct and " - "FFEL loans other than parent PLUS loans and excepted consolidation " - "loans. A joint filer's spouse's eligible loans count too, so this is " - "the sum over the head and spouse of the tax unit of each one's " - "10-year standard payment on their federal_student_loan_balance plus " - "federal_ffel_loan_balance, amortized at their own " - "federal_student_loan_interest_rate. Each person's standard payment " - "is at least the standard plan minimum and no more than their eligible " - "balance. Married people filing separately are in separate tax units, " - "so only the borrower's loans count. The cap uses the current balance, " - "not the balance when the borrower began paying under the plan." + "on their own eligible loans, the most the borrower pays under the " + "Income-Based Repayment and Pay As You Earn plans (20 U.S.C. " + "1098e(b)(6)(A): 'for all loans made to the borrower under part B or " + "D'). Eligible loans are Direct and FFEL loans other than parent PLUS " + "loans and excepted consolidation loans, so this is the 10-year " + "standard payment on the person's federal_student_loan_balance plus " + "federal_ffel_loan_balance at their federal_student_loan_interest_rate. " + "One standard plan minimum covers the Direct and FFEL loans together, " + "and the payment is no more than the eligible balance. A joint filer's " + "spouse's loans do not enter it: 34 CFR 685.209(e)(2)(i) counts them " + "only in the (g) proration. The cap uses the current balance, not the " + "balance when the borrower began paying under the plan." ) unit = USD definition_period = MONTH reference = ( + "https://www.law.cornell.edu/uscode/text/20/1098e#b_6_A", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(5)", - "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)(i)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(2)(ii)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(f)(3)(ii)", "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(1)", ) - def formula(tax_unit, period, parameters): + def formula(person, period, parameters): p = parameters(period).gov.ed.student_loans.standard_repayment - person = tax_unit.members - direct_balance = person("federal_student_loan_balance", period.this_year) - ffel_balance = person("federal_ffel_loan_balance", period.this_year) - eligible_balance = direct_balance + ffel_balance + eligible_balance = add( + person, + period.this_year, + ["federal_student_loan_balance", "federal_ffel_loan_balance"], + ) annual_rate = person("federal_student_loan_interest_rate", period.this_year) number_of_payments = p.repayment_period * MONTHS_IN_YEAR amortized_payment = amortized_monthly_payment( @@ -47,8 +47,4 @@ def formula(tax_unit, period, parameters): ) # As in standard_repayment_payment, the minimum does not apply to a # final payment below it. - standard_payment = min_( - max_(amortized_payment, p.minimum_payment), eligible_balance - ) - is_head_or_spouse = person("is_tax_unit_head_or_spouse", period.this_year) - return tax_unit.sum(where(is_head_or_spouse, standard_payment, 0)) + return min_(max_(amortized_payment, p.minimum_payment), eligible_balance) diff --git a/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py b/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py index f03de8cd401..902f2100ca6 100644 --- a/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py @@ -8,26 +8,27 @@ class pay_as_you_earn_payment(Variable): documentation = ( "Monthly payment owed by a borrower under the Pay As You Earn plan, " "assuming the borrower is enrolled in the plan and in active " - "repayment, not in deferment, forbearance or default. The payment is " - "the lesser of the plan rate times discretionary income and the " - "10-year standard payment on the eligible loans " - "(income_driven_repayment_standard_payment, which for joint filers " - "covers both spouses' Direct and FFEL loans, each at their own " - "interest rate). That amount is prorated to the borrower's Direct " - "Loans (income_driven_repayment_balance_share), rounded to the cent, " - "given the small payment adjustment and capped at the borrower's " - "Direct Loan balance. As with the Income-Based Repayment plan, the " - "cap uses the current balance, not the balance when the borrower " - "entered the plan. A borrower with a " - "Direct Loan made on or after July 1, 2026 cannot use the plan and " - "owes nothing under it. The other limits on who can repay under the " - "plan are not modeled: being a new borrower under 34 CFR " - "685.209(b)(13)(i), repaying under the plan on July 1, 2024, and the " - "plan's end on June 30, 2028 (685.209(c)(4)). The prorated payment is " - "rounded to the cent before the small payment adjustment, a modeling " - "choice: a calculated payment just under $5, such as $4.996, rounds " - "to $5.00 and becomes $10 rather than $0 under 685.209(g)(1)(iii), " - "which sets a payment of less than $5 to $0." + "repayment, not in deferment, forbearance or default. The plan rate " + "times discretionary income is prorated to the borrower's Direct " + "Loans (income_driven_repayment_balance_share, which for joint filers " + "divides by both spouses' Direct and FFEL loans) and capped at the " + "Direct Loan part of the borrower's own 10-year standard payment " + "(income_driven_repayment_standard_payment on their Direct and FFEL " + "loans at their own interest rate, times their Direct share of those " + "loans). The result is rounded to the cent, given the small payment " + "adjustment and capped at the borrower's Direct Loan balance. Each " + "spouse's cap covers only their own loans. As with the Income-Based " + "Repayment plan, the cap uses the current balance, not the balance " + "when the borrower entered the plan. A borrower with a Direct Loan " + "made on or after July 1, 2026 cannot use the plan and owes nothing " + "under it. The other limits on who can repay under the plan are not " + "modeled: being a new borrower under 34 CFR 685.209(b)(13)(i), " + "repaying under the plan on July 1, 2024, and the plan's end on June " + "30, 2028 (685.209(c)(4)). The prorated payment is rounded to the " + "cent before the small payment adjustment, a modeling choice: a " + "calculated payment just under $5, such as $4.996, rounds to $5.00 " + "and becomes $10 rather than $0 under 685.209(g)(1)(iii), which sets " + "a payment of less than $5 to $0." ) unit = USD definition_period = MONTH @@ -47,34 +48,46 @@ def formula(person, period, parameters): discretionary_income = person.tax_unit( "pay_as_you_earn_discretionary_income", period ) - # 685.209(f)(2): the lesser of the income-based amount and the - # standard payment on all of the couple's eligible loans, then the - # (g)(1)(i)-(ii) proration to the borrower's Direct Loans. - standard_payment = person.tax_unit( - "income_driven_repayment_standard_payment", period - ) - capped_payment = min_( - p.pay_as_you_earn.rate * discretionary_income, standard_payment - ) + # 685.209(g)(1)(i)-(ii) prorate the income-based amount to the + # borrower's Direct Loans. 685.209(f)(2)(ii), as in 20 U.S.C. + # 1098e(b)(6)(A) for the Income-Based Repayment plan, caps the payment + # on all of the borrower's eligible loans at their own 10-year standard + # payment; the Direct Loans carry the (g)(1)(ii) part of that cap. balance_share = person( "income_driven_repayment_balance_share", period.this_year ) + balance = person("federal_student_loan_balance", period.this_year) + eligible_balance = balance + person( + "federal_ffel_loan_balance", period.this_year + ) + direct_fraction = np.divide( + balance, + eligible_balance, + out=np.zeros_like(balance), + where=eligible_balance > 0, + ) + standard_payment = person("income_driven_repayment_standard_payment", period) # Payments are set in whole cents. Rounding before the small payment # test keeps an exact $5.00 from falling below the threshold through # floating-point error. It also lifts an amount within half a cent # below $5 to $5.00, a modeling choice described in the documentation. - prorated_payment = np.round(capped_payment * balance_share, 2) + prorated_payment = np.round( + min_( + p.pay_as_you_earn.rate * discretionary_income * balance_share, + standard_payment * direct_fraction, + ), + 2, + ) small_payment = p.income_driven_repayment.small_payment adjusted_payment = where( prorated_payment < small_payment.threshold, 0, max_(prorated_payment, small_payment.minimum), ) - balance = person("federal_student_loan_balance", period.this_year) payment = min_(adjusted_payment, balance) - # 20 U.S.C. 1087e(d)(6)-(7) and 34 CFR 685.209(c)(4)(v), (d)(5) limit - # the plan to borrowers with no Direct Loan made on or after July 1, - # 2026. + # 20 U.S.C. 1087e(d)(1) and (d)(7)(A) and 34 CFR 685.209(c)(4)(v), + # (d)(5) limit the plan to borrowers with no Direct Loan made on or + # after July 1, 2026. has_new_loan = person( "has_federal_student_loan_made_on_or_after_2026_07_01", period.this_year ) diff --git a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.py b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.py index c6a69a5fed1..8b2672ea81a 100644 --- a/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_payment.py @@ -16,7 +16,11 @@ class repayment_assistance_plan_payment(Variable): "and at least the minimum payment. The plan cannot repay FFEL loans " "(685.209(d)(4)), and (g)(3) has no FFEL adjustment like (g)(1)(ii), " "so a borrower's own FFEL loans raise their share of the payment on " - "their Direct Loans. Payments in a year stop once they add up to the " + "their Direct Loans. The proposed rule (91 FR 4284-85) and the final " + "rule's preamble (91 FR 23838) copied the Income-Based Repayment " + "plan's (g)(1)(i) proration 'to provide consistency with IBR' without " + "discussing FFEL loans, so the code follows the text of (g)(3)(i). " + "Payments in a year stop once they add up to the " "borrower's opening Direct Loan balance; interest accrual is not " "modeled." ) @@ -29,6 +33,7 @@ class repayment_assistance_plan_payment(Variable): "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(5)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(2)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(3)", + "https://www.govinfo.gov/content/pkg/FR-2026-05-01/pdf/2026-08556.pdf#page=71", ) def formula(person, period, parameters): diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py index 1c09b78e401..3db5e618ef9 100644 --- a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py @@ -10,9 +10,17 @@ class parent_plus_standard_repayment_payment(Variable): label = "Parent PLUS standard repayment plan monthly payment" documentation = ( "Fixed monthly payment that repays the person's parent PLUS loans " - "(federal_parent_plus_loan_balance) over the 10-year standard " - "repayment plan, which covers Direct PLUS Loan borrowers, parents " - "included. The balance is amortized at the person's " + "(federal_parent_plus_loan_balance) under the standard repayment " + "plan. Unconsolidated parent PLUS loans repay over 10 years. A Direct " + "Consolidation Loan that repaid them and entered repayment on or after " + "July 1, 2006 (federal_parent_plus_loan_is_consolidated) repays over " + "10 to 30 years, set by the total of the consolidation loan and the " + "borrower's other education loans, which count up to the amount of " + "the consolidation loan. The current balances stand in for the " + "balances when the consolidation loan entered repayment, and the " + "other education loans are the person's federal_student_loan_balance " + "and federal_ffel_loan_balance; private education loans are not " + "modeled. The balance is amortized at the person's " "federal_student_loan_interest_rate, a single rate for all of their " "federal loans, and the payment is at least the plan minimum and no " "more than the balance. It assumes the borrower is in active " @@ -21,29 +29,51 @@ class parent_plus_standard_repayment_payment(Variable): "repay. A borrower with a Direct Loan made on or after July 1, 2026 " "cannot use this plan: the tiered standard plan " "(tiered_standard_repayment_payment) repays their parent PLUS loans, " - "so this is zero for them. Repayment of consolidated parent PLUS " - "loans under the Income-Contingent Repayment plan is not modeled." + "so this is zero for them. The flag is yearly, so a loan made in the " + "second half of 2026 moves all of 2026 to the tiered plan. Repayment " + "of consolidated parent PLUS loans under the Income-Contingent " + "Repayment plan is not modeled." ) unit = USD definition_period = MONTH reference = ( - "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title20-section1087e&num=0&edition=prelim", + "https://www.law.cornell.edu/uscode/text/20/1087e#d_1_A", "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(1)", + "https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(2)", + "https://www.ecfr.gov/current/title-34/section-685.220#p-685.220(i)(2)(ii)", ) def formula(person, period, parameters): p = parameters(period).gov.ed.student_loans.standard_repayment balance = person("federal_parent_plus_loan_balance", period.this_year) annual_rate = person("federal_student_loan_interest_rate", period.this_year) - number_of_payments = p.repayment_period * MONTHS_IN_YEAR + # 685.208(b)(2)(iii) and 685.220(i)(2)(ii), (i)(3)(i): a consolidation + # loan's term follows its amount plus the borrower's other education + # loans, counted up to the consolidation loan's amount. + other_loans = add( + person, + period.this_year, + ["federal_student_loan_balance", "federal_ffel_loan_balance"], + ) + consolidation_total = balance + min_(other_loans, balance) + is_consolidated = person( + "federal_parent_plus_loan_is_consolidated", period.this_year + ) + repayment_period = where( + is_consolidated, + p.consolidation_repayment_period.calc(consolidation_total), + p.repayment_period, + ) + number_of_payments = repayment_period * MONTHS_IN_YEAR amortized_payment = amortized_monthly_payment( balance, annual_rate, number_of_payments ) # As in standard_repayment_payment, the minimum does not apply to a # final payment below it. payment = min_(max_(amortized_payment, p.minimum_payment), balance) - # 20 U.S.C. 1087e(d)(6)-(7) and 34 CFR 685.208(b)(1) limit this plan to - # borrowers with no Direct Loan made on or after July 1, 2026. + # 20 U.S.C. 1087e(d)(1) and (d)(7)(A) and 34 CFR 685.208(b)(1)-(2) + # limit this plan to borrowers with no Direct Loan made on or after + # July 1, 2026. has_new_loan = person( "has_federal_student_loan_made_on_or_after_2026_07_01", period.this_year ) From c6ee53c601fcc706abec7b46a259b119021b5434 Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Tue, 27 Oct 2026 11:12:07 -0400 Subject: [PATCH 8/9] Check Montana's pension exemption in the Montana AGI pooling invariant #10392 subtracts the 2021-2023 partial pension, annuity, and IRA income exemption in mt_agi_indiv and mt_agi_joint rather than through mt_subtractions, so a Hypothesis-drawn pensioner with no other Montana adjustment reported Montana AGI below federal AGI and failed the pooling property. Require both exemption amounts to be zero for that property and for the seeded grid's matching guard. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../fix-mt-agi-invariant-pension-exemption.fixed.md | 1 + .../tests/core/test_mt_state_agi_invariants.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fix-mt-agi-invariant-pension-exemption.fixed.md diff --git a/changelog.d/fix-mt-agi-invariant-pension-exemption.fixed.md b/changelog.d/fix-mt-agi-invariant-pension-exemption.fixed.md new file mode 100644 index 00000000000..ea4ffb12b37 --- /dev/null +++ b/changelog.d/fix-mt-agi-invariant-pension-exemption.fixed.md @@ -0,0 +1 @@ +Exclude filers with Montana's 2021–2023 partial pension, annuity, and IRA income exemption from the Montana AGI pooling test, which assumed only `mt_subtractions` reduce Montana AGI. diff --git a/policyengine_us/tests/core/test_mt_state_agi_invariants.py b/policyengine_us/tests/core/test_mt_state_agi_invariants.py index 330b4e02af5..707ff16a247 100644 --- a/policyengine_us/tests/core/test_mt_state_agi_invariants.py +++ b/policyengine_us/tests/core/test_mt_state_agi_invariants.py @@ -24,8 +24,11 @@ 2. Reporting: `taxsim_state_agi`, and its alias `state_agi`, equal `mt_agi` through 2023 and federal adjusted gross income from 2024. 3. Pooling: through 2023, a unit on the joint path with non-negative federal - AGI, no Montana additions or subtractions and no Social Security reports - federal AGI, so one spouse's loss offsets the other spouse's income. + AGI, no Montana additions or subtractions, no partial pension, annuity, and + IRA income exemption and no Social Security reports federal AGI, so one + spouse's loss offsets the other spouse's income. The exemption is outside + `mt_subtractions` (`mt_agi_indiv` and `mt_agi_joint` subtract it), so it is + checked separately. 4. Bounds: `mt_agi` is never negative, and a unit outside Montana has no `mt_agi`. @@ -72,11 +75,13 @@ "taxsim_state_agi", "state_agi", "adjusted_gross_income", + "mt_pension_exemption_joint", ) PERSON_OUTPUTS = ( "mt_agi_indiv", "mt_additions", "mt_subtractions", + "mt_pension_exemption_indiv", "social_security", ) @@ -196,6 +201,8 @@ def _check(units, year): unadjusted = ( (run["mt_additions"] == 0) & (run["mt_subtractions"] == 0) + & (run["mt_pension_exemption_indiv"] == 0) + & (run["mt_pension_exemption_joint"] == 0) & (run["social_security"] == 0) ) pooled = in_mt & ~separate & unadjusted & (run["adjusted_gross_income"] >= 0) @@ -231,6 +238,8 @@ def test_seeded_grid(year): & (run["adjusted_gross_income"] >= 0) & (run["mt_additions"] == 0) & (run["mt_subtractions"] == 0) + & (run["mt_pension_exemption_indiv"] == 0) + & (run["mt_pension_exemption_joint"] == 0) & (run["social_security"] == 0) ) assert (floored_sum[joint_path] > run["mt_agi"][joint_path] + 1).any() From 2d25e8117e47de2c5d768f9850addbbcfd75f904 Mon Sep 17 00:00:00 2001 From: David Trimmer Date: Thu, 29 Oct 2026 10:43:06 -0400 Subject: [PATCH 9/9] Fix the 2016 eCFR link, cite 1087e(e)(8)(A)(i), test the 10- and 12-year consolidation tiers - consolidation_repayment_period: link 685.208(c), (j) as of 2016-12-19, the first date eCFR serves (the 2016-11-17 page returns 404). - income_driven_repayment_adjusted_gross_income: cite 20 U.S.C. 1087e(e)(8)(A)(i) for SAVE, PAYE and ICR beside 1098e(c)(2)(A)(i) for IBR. - parent_plus_standard_repayment_payment Case 10: 7,499.99, 7,500, 9,999.99 and 10,000 consolidated at 6% give 83.27, 73.19, 97.58, 84.39. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LqCGPgxDukji8Bhs57c5rH --- .../consolidation_repayment_period.yaml | 4 +- ...arent_plus_standard_repayment_payment.yaml | 40 +++++++++++++++++++ ..._driven_repayment_adjusted_gross_income.py | 5 ++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/policyengine_us/parameters/gov/ed/student_loans/standard_repayment/consolidation_repayment_period.yaml b/policyengine_us/parameters/gov/ed/student_loans/standard_repayment/consolidation_repayment_period.yaml index a90382cd90b..ad7a2da858e 100644 --- a/policyengine_us/parameters/gov/ed/student_loans/standard_repayment/consolidation_repayment_period.yaml +++ b/policyengine_us/parameters/gov/ed/student_loans/standard_repayment/consolidation_repayment_period.yaml @@ -37,7 +37,7 @@ metadata: reference: - title: 34 CFR 685.208(b)(2)(iii) href: https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(2)(iii) - - title: 34 CFR 685.208(c), (j) (as of November 17, 2016) - href: https://www.ecfr.gov/on/2016-11-17/title-34/section-685.208#p-685.208(j) + - title: 34 CFR 685.208(c), (j) (as of December 19, 2016) + href: https://www.ecfr.gov/on/2016-12-19/title-34/section-685.208#p-685.208(j) - title: 34 CFR 685.220(i)(2)(ii), (i)(3)(i) href: https://www.ecfr.gov/current/title-34/section-685.220#p-685.220(i)(2)(ii) diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml index 6d35dc5bd63..2dcad81329f 100644 --- a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml @@ -210,3 +210,43 @@ # if the cap were ignored). person2: 15,000 + 5,000 of FFEL = 20,000 -> 20 # years: 107.46 (15 years and 126.58 without the FFEL loans). parent_plus_standard_repayment_payment: [107.46, 107.46] + +- name: Case 10, small consolidated parent PLUS loans repay over 10 or 12 years. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_parent_plus_loan_balance: 7_499.99 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_interest_rate: 0.06 + person2: + age: 50 + federal_parent_plus_loan_balance: 7_500 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_interest_rate: 0.06 + person3: + age: 50 + federal_parent_plus_loan_balance: 9_999.99 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_interest_rate: 0.06 + person4: + age: 50 + federal_parent_plus_loan_balance: 10_000 + federal_parent_plus_loan_is_consolidated: true + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + tax_unit3: + members: [person3] + tax_unit4: + members: [person4] + output: + # No other education loans. 7,499.99 -> 10 years (n = 120): 7,499.99 x + # 0.005 / (1 - 1.005 ^ -120) = 83.27. 7,500 -> 12 years (n = 144): 73.19. + # 9,999.99 -> 12 years: 97.58. 10,000 -> 15 years (n = 180): 84.39. + parent_plus_standard_repayment_payment: [83.27, 73.19, 97.58, 84.39] diff --git a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py index 0cebbdf59e4..5adf1f43c78 100644 --- a/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py @@ -15,7 +15,9 @@ class income_driven_repayment_adjusted_gross_income(Variable): "Repayment plans, 34 CFR 685.209(b)(10)(i) defines income as adjusted " "gross income as reported to the IRS, which the Department takes from " "the latest return it obtains (685.209(l)(1)-(3); 20 U.S.C. " - "1098e(c)(2)(A)(i)). Whose income counts follows 685.209(e)(1)(i), and " + "1098e(c)(2)(A)(i) for Income-Based Repayment, 1087e(e)(8)(A)(i) for " + "the income-contingent SAVE, Pay As You Earn and Income-Contingent " + "Repayment plans). Whose income counts follows 685.209(e)(1)(i), and " "(e)(1)(ii) for the Income-Contingent Repayment plan. A borrower whose " "income fell can document current income instead (685.209(l)(6)). " "When not provided, this defaults to the current year's adjusted gross " @@ -30,6 +32,7 @@ class income_driven_repayment_adjusted_gross_income(Variable): reference = ( "https://www.law.cornell.edu/uscode/text/20/1087e#q_4_A", "https://www.law.cornell.edu/uscode/text/20/1098e#c_2_A_i", + "https://www.law.cornell.edu/uscode/text/20/1087e#e_8_A", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(b)(10)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(1)(i)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(e)(1)(ii)",