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/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/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..ad7a2da858e --- /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 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/programs.yaml b/policyengine_us/programs.yaml index 807b5760412..3242ebe3f88 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1681,7 +1681,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 @@ -1693,7 +1693,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); zero for borrowers with a Direct Loan 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 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) @@ -1705,7 +1705,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 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 @@ -1717,7 +1717,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 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, 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 @@ -1729,7 +1729,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_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 fbc8fc13a71..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,8 +1,13 @@ # 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 +# 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. # Standard payment on 30,000 at 6 percent: 333.06. @@ -416,3 +421,221 @@ # 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 18, spouses with different interest rates are each capped at their own 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 + 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, + # 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 19, each spouse's own 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, split 50/50: 400 each. + # min(400, 250) = 250 and min(400, 430.41) = 400. + income_based_repayment_payment: [250, 400] + +- name: Case 20, 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. 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 21, 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: 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. 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 22, 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 + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] + 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, 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_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/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..3e8bd8e863a --- /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,31 @@ +# 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 + 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..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 @@ -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,119 @@ 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] + +- 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 new file mode 100644 index 00000000000..d86798b4eb3 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.yaml @@ -0,0 +1,163 @@ +# 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 + 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, each spouse has their own standard payment at their 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: [250, 430.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: + # 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 + 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 and max(33.31, 50) = 50. + income_driven_repayment_standard_payment: [333.06, 50] + +- name: Case 5, each person's standard payment covers only their own loans. + 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: + # 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 + 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 + +- 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_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..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,8 +1,13 @@ # 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 +# 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. # Standard payment at 6 percent: 30,000 -> 333.06; 40,000 -> 444.08; # 20,000 -> 222.04; 10,000 -> 111.02. @@ -79,7 +84,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, each spouse's own standard payment caps joint filers who both borrowed. period: 2026-01 absolute_error_margin: 0.01 input: @@ -103,7 +108,7 @@ 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. + # 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. @@ -226,3 +231,111 @@ # 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 each capped at their own 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 + 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, + # 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, each spouse's own 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, 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 + 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. 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/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..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 @@ -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,272 @@ 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 + 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] + # 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 + 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] + # 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 + 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] + # 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 + 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] + # 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 + 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] + # 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 + 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] + # 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 + 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] + # 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..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 @@ -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: @@ -85,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 3050c7e5c2f..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 @@ -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,152 @@ 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 + +- 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/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/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..2dcad81329f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.yaml @@ -0,0 +1,252 @@ +# 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 + 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 + +- 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 + +- name: Case 7, a borrower with a loan made on or after July 1, 2026 repays parent PLUS loans under the tiered plan. + period: 2027-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 + has_federal_student_loan_made_on_or_after_2026_07_01: true + tax_units: + tax_unit: + members: [person1] + output: + # The 10-year plan is closed to this borrower (685.208(b)(1)); the tiered + # plan repays the 30,000 over 15 years: 30,000 x 0.005 / + # (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] + +- 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/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 b5a19ccbdcc..4fc09a669a2 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 @@ -95,3 +95,27 @@ # payment: 30,000 x 0.005 / (1 - 1.005 ^ -180) = 253.16. standard_repayment_payment: 0 tiered_standard_repayment_payment: 253.16 + +- name: Case 7, 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/tests/policy/baseline/parameters/test_uprating_extensions.py b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py index 22ccfc9dafa..1b1dd84fc72 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -8,8 +8,10 @@ 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 from policyengine_us.tools.per_capita_uprating import per_capita_path from policyengine_us.parameters.uprating_extensions import ( DEPENDENT_STANDARD_DEDUCTION_STATUTORY_BASES, @@ -258,7 +260,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 = [] @@ -270,6 +274,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) @@ -293,6 +299,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 d26a56d6d5a..854117721e1 100644 --- a/policyengine_us/tools/default_uprating.py +++ b/policyengine_us/tools/default_uprating.py @@ -103,6 +103,16 @@ ] +# 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", + "federal_ffel_loan_balance", +} + + def _is_float_dollar_input(variable) -> bool: return ( variable.is_input_variable() @@ -113,8 +123,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 new file mode 100644 index 00000000000..e29754c1d8f --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/federal_ffel_loan_balance.py @@ -0,0 +1,34 @@ +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), 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 " + "(input) value, with no price or income uprating and no repayment " + "roll-forward." + ) + unit = USD + quantity_type = STOCK + definition_period = YEAR + 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)", + "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 fc2759fc929..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 @@ -9,12 +9,17 @@ 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; " + "parent_plus_standard_repayment_payment repays them under the " + "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." ) 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_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_balance.py b/policyengine_us/variables/gov/ed/student_loans/federal_student_loan_balance.py index 27cb0c65c89..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 @@ -13,12 +13,16 @@ 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. 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)", 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..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,11 +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." + "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_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 181b5c50a27..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 @@ -7,13 +7,24 @@ 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. A " + "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." ) @@ -21,7 +32,8 @@ class income_based_repayment_payment(Variable): 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)", "https://www.ecfr.gov/current/title-34/section-685.209#p-685.209(g)(1)", @@ -41,25 +53,47 @@ 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. 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) + 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). - capped_payment = np.round(min_(prorated_payment, standard_payment), 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( - 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) - # 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_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 new file mode 100644 index 00000000000..5adf1f43c78 --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_adjusted_gross_income.py @@ -0,0 +1,42 @@ +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. " + "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) 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 " + "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://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)", + "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_balance_share.py b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_balance_share.py index d130506dee3..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,27 +6,44 @@ 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, 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 " + "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 d07a02526e7..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 @@ -8,19 +8,27 @@ class income_driven_repayment_eligible(Variable): documentation = ( "The person has loans that the Income-Based Repayment, SAVE, Pay As " "You Earn and Income-Contingent Repayment plans can repay and is the " - "head or spouse of their tax unit." + "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)-(5)); their payment " + "would come from their own income." ) 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): 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..83d2358d965 --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/income_driven_repayment/income_driven_repayment_standard_payment.py @@ -0,0 +1,50 @@ +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 = Person + 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. " + "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(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(person, period, parameters): + p = parameters(period).gov.ed.student_loans.standard_repayment + 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( + eligible_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), eligible_balance) 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..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 @@ -6,21 +6,29 @@ 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 " - "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 " - "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." + "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 " + "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 @@ -29,6 +37,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,27 +48,46 @@ 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. 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) + 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. - capped_payment = np.round(min_(prorated_payment, standard_payment), 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( - 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) - # 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_base_payment.py b/policyengine_us/variables/gov/ed/student_loans/repayment_assistance_plan/repayment_assistance_plan_base_payment.py index 8f0f1098dcd..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 @@ -5,6 +5,13 @@ 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 rate scale's first " + "finite threshold." + ) unit = USD definition_period = YEAR reference = ( @@ -17,7 +24,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..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 @@ -7,9 +7,22 @@ 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. 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." ) unit = USD definition_period = MONTH @@ -17,17 +30,20 @@ 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)", + "https://www.govinfo.gov/content/pkg/FR-2026-05-01/pdf/2026-08556.pdf#page=71", ) 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 c735a5b3d73..a79f4a517bd 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/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..3db5e618ef9 --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py @@ -0,0 +1,80 @@ +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) 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 " + "repayment. This is separate from standard_repayment_payment, so the " + "income-driven repayment caps stay on the loans those plans can " + "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. 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://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) + # 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)(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 + ) + return where(has_new_loan, 0, payment)