diff --git a/changelog.d/idr-cap-before-proration.fixed.md b/changelog.d/idr-cap-before-proration.fixed.md new file mode 100644 index 00000000000..be92e4d1d2e --- /dev/null +++ b/changelog.d/idr-cap-before-proration.fixed.md @@ -0,0 +1 @@ +Apply the Income-Based Repayment and Pay As You Earn 10-year standard payment cap before the spousal balance proration, as 34 CFR 685.209(f)(2)-(3) and (g)(1) order them. diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index c1bc9f0b631..4cb94f1a03b 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1790,7 +1790,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, 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 + 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 borrower's own 10-year standard payment on their current Direct and FFEL balances, then 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) @@ -1814,7 +1814,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, 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 + notes: Monthly payment (10% of income above 150% of the poverty guideline on the latest return's AGI, cap at the borrower's own 10-year standard payment on their current Direct and FFEL balances, then 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 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 93a9b823204..21803726095 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 @@ -2,10 +2,10 @@ # discretionary income = max(AGI - 1.5 x poverty guideline, 0) # 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)) +# loans (1098e(b)(6)(A); (f)(2)(ii), (f)(3)(ii)), +# times own Direct balance / couple's Direct and FFEL balance +# ((g)(1)(i)-(ii), applied to the lesser-of amount), # 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. @@ -193,10 +193,10 @@ members: [person1, person2] state_code: TX output: - # 1.5 x 21,640 = 32,460; 15% x 47,540 / 12 = 594.25. - # Shares 75% and 25%: 445.69 and 148.56, below the caps of 666.12 and - # 222.04. - income_based_repayment_payment: [445.69, 148.56] + # 1.5 x 21,640 = 32,460; 15% x 47,540 / 12 = 594.25. Own standard + # payments 666.12 and 222.04. min(594.25, 666.12) x 75% = 445.69 and + # min(594.25, 222.04) x 25% = 55.51 (685.209(f)(2)-(3), then (g)(1)). + income_based_repayment_payment: [445.69, 55.51] - name: Case 9, married filing separately excludes the spouse's income. period: 2026-01 @@ -446,10 +446,12 @@ state_code: TX output: # 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] + # with balance shares of 50% each. Standard payments: 30,000 / 120 = 250 + # and 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.4128. min(600, 250) x 50% = + # 125 and min(600, 430.4128) x 50% = 215.2064 -> 215.21 (20 U.S.C. + # 1098e(b)(6)(A); 685.209(f)(2)-(3), then (g)(1)). Capping after + # proration would give min(300, 250) = 250 and min(300, 430.41) = 300. + income_based_repayment_payment: [125, 215.21] - name: Case 19, each spouse's own standard payment caps a high income. period: 2026-01 @@ -474,9 +476,10 @@ members: [person1, person2] state_code: TX output: - # 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] + # 15% x (96,460 - 32,460) / 12 = 800, above both standard payments. + # min(800, 250) x 50% = 125 and min(800, 430.4128) x 50% = 215.21. + # Capping after proration would give 250 and 400. + income_based_repayment_payment: [125, 215.21] - name: Case 20, spouses keep their own new-borrower and other-borrower rates. period: 2026-01 @@ -503,9 +506,11 @@ state_code: TX output: # 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] + # standard payment is 333.0615. New borrower: min(10% x 5,000 = 500, + # 333.0615) x 50% = 166.53. Other borrower: min(15% x 5,000 = 750, + # 333.0615) x 50% = 166.53. Capping after proration would give 250 and + # 333.06. + income_based_repayment_payment: [166.53, 166.53] - name: Case 21, a spouse's FFEL debt counts in the joint proration. period: 2026-01 @@ -532,10 +537,12 @@ state_code: TX output: # IBR analogue of rulespec-us#1455 C16. 15% x (60,000 - 32,460) / 12 = - # 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] + # 344.25, above the borrower's own standard payment of 111.0205. The + # borrower pays min(344.25, 111.0205) x 10,000 / 40,000 = 27.7551 -> + # 27.76 (685.209(f)(2)-(3), then (g)(1)(i)-(ii)); the spouse has no Direct + # Loans and owes nothing under the plan. Capping after proration would + # give min(86.06, 111.02) = 86.06. + income_based_repayment_payment: [27.76, 0] - name: Case 22, a borrower's own FFEL loans reduce the Direct Loan payment. period: 2026-01 @@ -574,10 +581,11 @@ 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, 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. + # 685.209(g)(1)(ii): x 10,000 / 40,000 = 25%. Standard payment on all + # 40,000 of eligible loans: 444.08. No spouse debt, so the order of cap + # and proration does not matter. + # 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] - name: Case 23, a Direct Loan balance under 10 dollars caps the 10 dollar minimum payment. @@ -634,8 +642,129 @@ 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] + # 2,500. Income-Based Repayment: min(15% x 2,500 = 375, 333.0615) x + # 30,000 / 60,000 = 166.53. Pay As You Earn: min(10% x 2,500 = 250, + # 333.0615) x 50% = 125. The spouse owes 0. Capping after proration + # would give IBR min(187.50, 333.06) = 187.50. + income_based_repayment_payment: [166.53, 0] pay_as_you_earn_payment: [125, 0] + +- name: Case 25, joint filers at 6 percent pay each standard payment times their balance share. + 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: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 80_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 15% x (80,460 - 32,460) / 12 = 600. Each spouse's own standard payment + # is 30,000 x 0.005 / (1 - 1.005 ^ -120) = 333.0615. min(600, 333.0615) x + # 50% = 166.53 each. Capping after proration would give min(300, 333.06) + # = 300 each. + income_based_repayment_payment: [166.53, 166.53] + +- name: Case 26, a low income-based amount is prorated the same way under either order. + 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: 30_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 40_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 15% x (40,460 - 32,460) / 12 = 100, below each 333.0615 standard + # payment. min(100, 333.0615) x 50% = 50 each. + income_based_repayment_payment: [50, 50] + +- name: Case 27, with 10 and 15 percent rates the cap binds on one spouse only. + 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: 68_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # Discretionary income (68,460 - 32,460) / 12 = 3,000. New borrower: + # min(10% x 3,000 = 300, 333.0615) x 50% = 150, under the cap. Other + # borrower: min(15% x 3,000 = 450, 333.0615) x 50% = 166.53, at the cap. + # Capping after proration would give 150 and min(225, 333.06) = 225. + income_based_repayment_payment: [150, 166.53] + +- name: Case 28, both spouses' FFEL loans enter the proration of each spouse's capped amount. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + federal_ffel_loan_balance: 10_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + federal_student_loan_balance: 20_000 + federal_ffel_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 80_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 15% x (80,460 - 32,460) / 12 = 600. Own standard payments on Direct and + # FFEL loans: 20,000 -> 222.0410 and 40,000 -> 444.0820. Balance shares, + # (g)(1)(i) x (g)(1)(ii): 10,000 / 60,000 = 1/6 and 20,000 / 60,000 = 1/3. + # person1: min(600, 222.0410) / 6 = 37.0068 -> 37.01. + # person2: min(600, 444.0820) / 3 = 148.0273 -> 148.03. + # Capping after proration (cap x own Direct fraction of 50%) would give + # min(100, 111.02) = 100 and min(200, 222.04) = 200. + income_based_repayment_payment: [37.01, 148.03] 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 2b2773a3e4a..f1f096904f5 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 @@ -2,10 +2,10 @@ # discretionary income = max(AGI - 1.5 x poverty guideline, 0) # 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)) +# loans ((f)(2)(ii)), +# times own Direct balance / couple's Direct and FFEL balance +# ((g)(1)(i)-(ii), applied to the lesser-of amount), # 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. @@ -79,10 +79,11 @@ members: [person1, person2] state_code: TX output: - # 1.5 x 21,640 = 32,460; 10% x 47,540 / 12 = 396.17. - # Shares 2/3 and 1/3: 264.11 and 132.06, below the caps of 444.08 and - # 222.04. - pay_as_you_earn_payment: [264.11, 132.06] + # 1.5 x 21,640 = 32,460; 10% x 47,540 / 12 = 396.17. Own standard + # payments 444.0820 and 222.0410. min(396.17, 444.08) x 2/3 = 264.11 and + # min(396.17, 222.0410) x 1/3 = 74.0137 -> 74.01. Capping after + # proration would give min(132.06, 222.04) = 132.06 for the spouse. + pay_as_you_earn_payment: [264.11, 74.01] - name: Case 4, each spouse's own standard payment caps joint filers who both borrowed. period: 2026-01 @@ -107,9 +108,12 @@ 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 each spouse's own standard payment of 444.08 and 111.02. - pay_as_you_earn_payment: [444.08, 111.02] + # 10% x (150,000 - 32,460) / 12 = 979.50, above each spouse's own + # standard payment of 444.0820 and 111.0205. Shares 80% and 20%: + # 444.0820 x 80% = 355.2656 -> 355.27 and 111.0205 x 20% = 22.2041 -> + # 22.20. Capping after proration would give min(783.60, 444.08) = 444.08 + # and min(195.90, 111.02) = 111.02. + pay_as_you_earn_payment: [355.27, 22.20] - name: Case 5, a payment under 5 dollars becomes zero. period: 2026-01 @@ -256,10 +260,11 @@ state_code: TX output: # 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] + # with balance shares of 50% each. Standard payments: 30,000 / 120 = 250 + # and 30,000 x 0.01 / (1 - 1.01 ^ -120) = 430.4128. min(600, 250) x 50% = + # 125 and min(600, 430.4128) x 50% = 215.2064 -> 215.21. Capping after + # proration would give 250 and 300. + pay_as_you_earn_payment: [125, 215.21] - name: Case 11, each spouse's own standard payment caps a high income. period: 2026-01 @@ -284,9 +289,10 @@ members: [person1, person2] state_code: TX output: - # 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] + # 10% x (128,460 - 32,460) / 12 = 800, above both standard payments. + # min(800, 250) x 50% = 125 and min(800, 430.4128) x 50% = 215.21. + # Capping after proration would give 250 and 400. + pay_as_you_earn_payment: [125, 215.21] - name: Case 12, a spouse's FFEL debt counts in the joint proration. period: 2026-01 @@ -312,10 +318,12 @@ members: [person1, person2] state_code: TX output: - # 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] + # 10% x (61,000 - 32,460) / 12 = 237.83, above the borrower's own + # standard payment of 111.0205. The borrower pays min(237.83, 111.0205) x + # 10,000 / 40,000 = 27.7551 -> 27.76 (685.209(f)(2), then + # (g)(1)(i)-(ii)); the spouse owes nothing. Capping after proration would + # give min(59.46, 111.02) = 59.46. + pay_as_you_earn_payment: [27.76, 0] - name: Case 13, a payment within half a cent below 5 dollars rounds to 5 and rises to 10. period: 2026-01 @@ -339,3 +347,33 @@ # 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 + +- name: Case 14, both spouses' FFEL loans enter the proration of each spouse's capped amount. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + federal_student_loan_balance: 10_000 + federal_ffel_loan_balance: 10_000 + federal_student_loan_interest_rate: 0.06 + person2: + age: 38 + federal_student_loan_balance: 20_000 + federal_ffel_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 104_460 + households: + household: + members: [person1, person2] + state_code: TX + output: + # 10% x (104,460 - 32,460) / 12 = 600. Own standard payments 222.0410 and + # 444.0820; balance shares 1/6 and 1/3 ((g)(1)(i) x (g)(1)(ii)). + # min(600, 222.0410) / 6 = 37.01 and min(600, 444.0820) / 3 = 148.03. + pay_as_you_earn_payment: [37.01, 148.03] diff --git a/policyengine_us/tests/test_student_loan_idr_cap_order_property.py b/policyengine_us/tests/test_student_loan_idr_cap_order_property.py new file mode 100644 index 00000000000..649af88f3a2 --- /dev/null +++ b/policyengine_us/tests/test_student_loan_idr_cap_order_property.py @@ -0,0 +1,285 @@ +"""Hypothesis checks: the IBR and PAYE 10-year cap applies before proration. + +34 CFR 685.209(f)(2) and (f)(3) make an Income-Based Repayment or Pay As You +Earn payment the lesser of the income-based amount A = rate x discretionary +income and the borrower's own 10-year standard payment S on their Direct and +FFEL loans. 685.209(g)(1) then multiplies that lesser amount by the balance +share: own Direct / couple's (Direct + FFEL). So, with own Direct d, own FFEL +f and the couple's Direct and FFEL total T: + + P = min(A, S) x d / T + +Properties, for generated balances, interest rates, new-borrower status and +discretionary income: + +1. Differential: with no spouse debt, P equals min(A x d / (d + f), + S x d / (d + f)), written here independently of the model. +2. Identity: P equals min(A, S) x d / T, and a borrower whose spouse has no + eligible debt pays what a single filer pays at the same income. +3. Bound: P <= S x d / (d + f) and P <= A x d / T. +4. Monotonicity: P never falls as income rises, and never rises as the + spouse's balance rises. + +The model's payment also rounds to the cent, applies the small-payment rule +of 685.209(g)(1)(iii) and caps the payment at the Direct Loan balance. That +post-processing is a nondecreasing function of P, so each property is +checked on the model's payment against references passed through the same +post-processing: equalities stay equalities, and bounds and monotonicity +carry through. + +Every generated couple is simulated in five variants (as drawn, with more +income, with a larger spouse balance, with no spouse debt, and as a single +filer) in one vectorized simulation per example. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +MONTH = "2026-01" +YEAR = 2026 +# Written from the law, not read from the parameter tree. +IBR_RATE = 0.15 # 20 U.S.C. 1098e(b)(1); 685.209(f)(3)(i) +IBR_NEW_BORROWER_RATE = 0.10 # 20 U.S.C. 1098e(e); 685.209(f)(2)(i) +PAYE_RATE = 0.10 # 685.209(f)(2)(i) +STANDARD_MONTHS = 120 # 685.208(b)(1)(i) +STANDARD_MINIMUM = 50 # 685.208(b)(1)(ii) +SMALL_THRESHOLD = 5 # 685.209(g)(1)(iii)(A) +SMALL_MINIMUM = 10 # 685.209(g)(1)(iii)(B) +TOLERANCE = 0.011 +VARIANTS = ("base", "more_income", "larger_spouse_balance", "no_spouse_debt") + + +def standard_payment(balance, annual_rate): + """10-year level payment on the balance, at least 50, at most the balance.""" + monthly_rate = annual_rate / 12 + if monthly_rate == 0: + amortized = balance / STANDARD_MONTHS + else: + amortized = ( + balance * monthly_rate / (1 - (1 + monthly_rate) ** -STANDARD_MONTHS) + ) + return min(max(amortized, STANDARD_MINIMUM), balance) + + +def post_process(amount, direct_balance): + """Round to the cent, apply the small-payment rule, cap at the balance.""" + rounded = round(amount, 2) + if rounded < SMALL_THRESHOLD: + adjusted = 0 + else: + adjusted = max(rounded, SMALL_MINIMUM) + return min(adjusted, direct_balance) + + +def near_threshold(amount): + # A reference within a cent of the 5 dollar threshold can round to the + # other side of it than the model's floating-point result. + return abs(amount - SMALL_THRESHOLD) < 0.01 + + +borrower = st.fixed_dictionaries( + { + "direct": st.integers(1, 150_000), + "ffel": st.one_of(st.just(0), st.integers(1, 100_000)), + "rate": st.sampled_from([0, 0.03, 0.055, 0.068, 0.12]), + "new_borrower": st.booleans(), + } +) +spouse = st.fixed_dictionaries( + { + "direct": st.one_of(st.just(0), st.integers(1, 150_000)), + "ffel": st.one_of(st.just(0), st.integers(1, 100_000)), + "rate": st.sampled_from([0, 0.03, 0.055, 0.068, 0.12]), + "new_borrower": st.booleans(), + } +) +couple = st.fixed_dictionaries( + { + "borrower": borrower, + "spouse": spouse, + # Monthly discretionary income, often enough to reach the cap. + "income": st.one_of(st.integers(0, 2_000), st.integers(0, 15_000)), + "income_rise": st.integers(1, 5_000), + "spouse_direct_rise": st.integers(0, 50_000), + "spouse_ffel_rise": st.integers(0, 50_000), + } +) + + +def person_input(name, loans): + return name, { + "age": {YEAR: 40}, + "federal_student_loan_balance": {YEAR: loans["direct"]}, + "federal_ffel_loan_balance": {YEAR: loans["ffel"]}, + "federal_student_loan_interest_rate": {YEAR: loans["rate"]}, + "is_income_based_repayment_new_borrower": {YEAR: loans["new_borrower"]}, + } + + +def build(couples): + """One situation holding every variant of every couple, in a fixed order.""" + people, tax_units, households, marital_units = {}, {}, {}, {} + order = [] + + def add_unit(key, members, income, filing_status): + names = [] + for i, loans in enumerate(members): + name, values = person_input(f"{key}_p{i}", loans) + people[name] = values + names.append(name) + order.append((key, i)) + tax_units[key] = { + "members": names, + "filing_status": {YEAR: filing_status}, + "income_based_repayment_discretionary_income": {MONTH: income}, + "pay_as_you_earn_discretionary_income": {MONTH: income}, + } + households[key] = {"members": names, "state_code": {YEAR: "TX"}} + marital_units[key] = {"members": names} + + for n, c in enumerate(couples): + b, s = c["borrower"], c["spouse"] + add_unit(f"c{n}_base", [b, s], c["income"], "JOINT") + add_unit(f"c{n}_more_income", [b, s], c["income"] + c["income_rise"], "JOINT") + larger = dict( + s, + direct=s["direct"] + c["spouse_direct_rise"], + ffel=s["ffel"] + c["spouse_ffel_rise"], + ) + add_unit(f"c{n}_larger_spouse_balance", [b, larger], c["income"], "JOINT") + add_unit( + f"c{n}_no_spouse_debt", + [b, dict(s, direct=0, ffel=0)], + c["income"], + "JOINT", + ) + add_unit(f"c{n}_single", [b], c["income"], "SINGLE") + situation = { + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": marital_units, + "spm_units": {k: {"members": v["members"]} for k, v in households.items()}, + "families": {k: {"members": v["members"]} for k, v in households.items()}, + } + simulation = Simulation(situation=situation) + results = {} + for variable in ("income_based_repayment_payment", "pay_as_you_earn_payment"): + values = simulation.calculate(variable, MONTH) + results[variable] = {key: float(v) for key, v in zip(order, values)} + return results + + +def plan_rate(variable, loans): + if variable == "pay_as_you_earn_payment": + return PAYE_RATE + return IBR_NEW_BORROWER_RATE if loans["new_borrower"] else IBR_RATE + + +def check(couples): + results = build(couples) + problems = [] + for n, c in enumerate(couples): + b, s = c["borrower"], c["spouse"] + for variable, paid in results.items(): + + def p(variant, i=0): + return paid[(f"c{n}_{variant}", i)] + + for i, (own, other) in enumerate(((b, s), (s, b))): + if own["direct"] == 0: + if p("base", i) != 0: + problems.append(f"{variable} c{n}: no Direct Loans but pays") + continue + d, f = own["direct"], own["ffel"] + couple_total = d + f + other["direct"] + other["ffel"] + a = plan_rate(variable, own) * c["income"] + cap = standard_payment(d + f, own["rate"]) + share = d / couple_total + # 2. Identity. + reference = min(a, cap) * share + if not near_threshold(reference) and ( + abs(p("base", i) - post_process(reference, d)) > TOLERANCE + ): + problems.append( + f"{variable} c{n} spouse {i}: identity {p('base', i)} vs " + f"{post_process(reference, d)}" + ) + # 3. Bound. + own_fraction = d / (d + f) + for bound, label in ( + (cap * own_fraction, "standard payment x own Direct fraction"), + (a * share, "income-based amount x balance share"), + ): + if p("base", i) > post_process(bound, d) + TOLERANCE: + problems.append( + f"{variable} c{n} spouse {i}: {p('base', i)} above {label}" + ) + # 4. Monotonicity in income. + if p("more_income", i) < p("base", i) - TOLERANCE: + problems.append(f"{variable} c{n} spouse {i}: falls with income") + # The borrower (spouse 0) always has Direct Loans. + d, f = b["direct"], b["ffel"] + a = plan_rate(variable, b) * c["income"] + cap = standard_payment(d + f, b["rate"]) + fraction = d / (d + f) + # 1. Differential: main's order, which agrees with no spouse debt. + differential = min(a * fraction, cap * fraction) + if not near_threshold(differential): + for variant in ("no_spouse_debt", "single"): + if abs(p(variant) - post_process(differential, d)) > TOLERANCE: + problems.append( + f"{variable} c{n} {variant}: {p(variant)} vs " + f"{post_process(differential, d)}" + ) + # 2. Identity: no spouse debt pays what a single filer pays. + if abs(p("no_spouse_debt") - p("single")) > TOLERANCE: + problems.append(f"{variable} c{n}: joint with no spouse debt != single") + # 4. Monotonicity in the spouse's balance. + if p("larger_spouse_balance") > p("base") + TOLERANCE: + problems.append(f"{variable} c{n}: rises with the spouse's balance") + assert not problems, "\n".join(problems) + + +@hypothesis.settings( + max_examples=10, + deadline=None, + # Each example is a simulation of up to 40 people. Shrinking a failure + # would run hundreds more, so a failing example is reported as drawn. + phases=[ + hypothesis.Phase.explicit, + hypothesis.Phase.reuse, + hypothesis.Phase.generate, + ], + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) +@hypothesis.given(st.lists(couple, min_size=1, max_size=8)) +def test_cap_before_proration(couples): + check(couples) + + +def test_cap_before_proration_hand_case(): + # $30,000 each at 0% and 12%, $600 income-based amount: standard payments + # 250 and 430.41, so each spouse pays half: 125 and 215.21. + loans = {"ffel": 0, "new_borrower": False} + c = { + "borrower": dict(loans, direct=30_000, rate=0), + "spouse": dict(loans, direct=30_000, rate=0.12), + "income": 4_000, + "income_rise": 1, + "spouse_direct_rise": 0, + "spouse_ffel_rise": 0, + } + results = build([c]) + paid = results["income_based_repayment_payment"] + assert paid[("c0_base", 0)] == pytest.approx(125, abs=0.01) + assert paid[("c0_base", 1)] == pytest.approx(215.21, abs=0.01) + check([c]) 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 e8ab75d21b1..e17ddae6cd0 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,29 @@ 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 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." + "repayment, not in deferment, forbearance or default. The payment is " + "the lesser of the borrower's plan rate times discretionary income " + "and the borrower's own 10-year standard payment " + "(income_driven_repayment_standard_payment, on their own Direct and " + "FFEL loans), under 34 CFR 685.209(f)(2) and (f)(3). That lesser " + "amount is then prorated to the borrower's Direct Loans under " + "685.209(g)(1) (income_driven_repayment_balance_share, which for " + "joint filers divides by both spouses' Direct and FFEL 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, and each spouse's " + "standard payment covers only their own loans. Because the proration " + "applies after the cap, a joint filer whose income-based amount " + "exceeds their standard payment pays that standard payment times " + "their balance share. The cap uses the current balance, not the " + "balance when the borrower entered the plan. The 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 @@ -53,24 +57,16 @@ def formula(person, period, parameters): discretionary_income = person.tax_unit( "income_based_repayment_discretionary_income", period ) - # 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. + # 20 U.S.C. 1098e(b)(6)(A) and 685.209(f)(2)(ii), (f)(3)(ii) make the + # payment the lesser of the income-based amount and the borrower's own + # 10-year standard payment on their eligible loans. 685.209(g)(1) + # adjusts the payment amounts calculated under (f)(1) through (3): + # (g)(1)(i)(B) and (g)(1)(ii) multiply that lesser amount by the + # balance shares, so the proration follows the 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 @@ -78,10 +74,7 @@ def formula(person, period, parameters): # 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, - ), + min_(rate * discretionary_income, standard_payment) * balance_share, 2, ) small_payment = p.income_driven_repayment.small_payment 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 c199aa4deaf..05ddc5cc6c8 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 @@ -17,7 +17,10 @@ class income_driven_repayment_balance_share(Variable): "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." + "so only their own loans count. Under the Income-Based Repayment and " + "Pay As You Earn plans the share multiplies the lesser of the " + "income-based amount and the borrower's own 10-year standard payment, " + "since (g)(1) adjusts the amount calculated under (f)(2) and (f)(3)." ) unit = "/1" definition_period = YEAR 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 83d2358d965..984cf202c34 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 @@ -10,8 +10,8 @@ class income_driven_repayment_standard_payment(Variable): label = "Income-driven repayment 10-year standard payment cap" documentation = ( "What the borrower would pay on the 10-year standard repayment 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. " + "on their own eligible loans, the cap on the income-based amount 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 " @@ -20,8 +20,14 @@ class income_driven_repayment_standard_payment(Variable): "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." + "only in the (g) proration. The plans take the lesser of the " + "income-based amount and this payment (685.209(f)(2), (f)(3)) and " + "then prorate that lesser amount by " + "income_driven_repayment_balance_share (685.209(g)(1)), so a joint " + "filer whose income-based amount exceeds this payment pays this " + "payment times their balance share. The cap uses the current " + "balance, not the balance when the borrower began paying under the " + "plan." ) unit = USD definition_period = MONTH 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 902f2100ca6..7142081e6a4 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,27 +8,31 @@ 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 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 " + "repayment, not in deferment, forbearance or default. The payment is " + "the lesser of the plan rate times discretionary income and the " + "borrower's own 10-year standard payment " + "(income_driven_repayment_standard_payment, on their own Direct and " + "FFEL loans at their own interest rate), under 34 CFR 685.209(f)(2). " + "That lesser amount is then prorated to the borrower's Direct Loans " + "under 685.209(g)(1) (income_driven_repayment_balance_share, which " + "for joint filers divides by both spouses' Direct and FFEL 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." + "spouse's standard payment covers only their own loans. Because the " + "proration applies after the cap, a joint filer whose income-based " + "amount exceeds their standard payment pays that standard payment " + "times their balance share. 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 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 @@ -48,24 +52,17 @@ def formula(person, period, parameters): discretionary_income = person.tax_unit( "pay_as_you_earn_discretionary_income", period ) - # 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. + # 685.209(f)(2)(ii), as 20 U.S.C. 1098e(b)(6)(A) does for the + # Income-Based Repayment plan, makes the payment the lesser of the + # income-based amount and the borrower's own 10-year standard payment + # on their eligible loans. 685.209(g)(1) adjusts the payment amounts + # calculated under (f)(1) through (3): (g)(1)(i)(B) and (g)(1)(ii) + # multiply that lesser amount by the balance shares, so the proration + # follows the 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 @@ -73,9 +70,10 @@ def formula(person, period, parameters): # below $5 to $5.00, a modeling choice described in the documentation. prorated_payment = np.round( min_( - p.pay_as_you_earn.rate * discretionary_income * balance_share, - standard_payment * direct_fraction, - ), + p.pay_as_you_earn.rate * discretionary_income, + standard_payment, + ) + * balance_share, 2, ) small_payment = p.income_driven_repayment.small_payment diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 0aa317084eb..2ad017d3121 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -10542,6 +10542,44 @@ candidates: limit/self_only.yaml, limit/family.yaml). The statute-first rule requires changing conflicting projections; verify the statutory window substitution first, and preserve the distinction from published limits fixed through 2027.' +- id: us:idr-10-year-cap-before-spousal-proration + program: student-loans + years: + from: 2026 + type: calendar + question: For a married borrower who files jointly and whose spouse has eligible loans, is the Income-Based Repayment + or Pay As You Earn payment capped at the borrower's 10-year standard payment before or after it is prorated by + the borrower's share of the couple's loans? + sources: + - kind: statute + citation: 20 U.S.C. 1098e(b)(6)(A) + url: https://www.law.cornell.edu/uscode/text/20/1098e#b_6_A + - kind: regulation + citation: 34 CFR 685.209(e)(2)(i), (f)(2), (f)(3) and (g)(1) + url: https://www.ecfr.gov/current/title-34/section-685.209 + readings: + statute: Section 1098e(b)(6)(A) sets a maximum, the 10-year standard payment on all the borrower's loans, and says + nothing of proration between spouses. + other: 'Regulation order: (f)(2) and (f)(3) make the payment the lesser of the income-based amount and the 10-year + standard payment, and (g)(1) multiplies "the borrower''s payment amount as calculated in accordance with paragraphs + (f)(1) through (3)" by the balance share, so the share applies to the capped amount. The other reading prorates + the income-based amount first and caps each spouse''s share at their own standard payment.' + example: Spouses with $30,000 each at 0% and 12% and a $600 income-based amount have standard payments of $250.00 + and $430.41. In the regulation's order each pays half of min($600, own standard payment), $125.00 and $215.21; + capping after proration gives min($300, $250) = $250 and min($300, $430.41) = $300. + pe_us: + follows: gap-fill + code: + - policyengine_us/variables/gov/ed/student_loans/income_based_repayment/income_based_repayment_payment.py + - policyengine_us/variables/gov/ed/student_loans/pay_as_you_earn/pay_as_you_earn_payment.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10570 + state: ruled + note: The model follows the regulation's order for both plans. For Income-Based Repayment it stays under the + statute's maximum; Pay As You Earn has no statutory cap. Each borrower's standard + payment covers only their own Direct and FFEL loans; (e)(2)(i) brings the spouse's loans in only for (g). A couple + whose income-based amount exceeds both standard payments pays each standard payment times the balance share, + so at equal balances and rates half their combined standard amount. - id: us:mortgage-interest-limit-ratio-rounding program: income-tax years: