diff --git a/changelog.d/standard-minimum-per-borrower.added.md b/changelog.d/standard-minimum-per-borrower.added.md new file mode 100644 index 00000000000..5353ce01c1c --- /dev/null +++ b/changelog.d/standard-minimum-per-borrower.added.md @@ -0,0 +1 @@ +Apply the standard repayment plan's $50 minimum once across a borrower's Direct and parent PLUS loans in a new combined_standard_repayment_payment, as 34 CFR 685.208(b)(1)(ii) sets it, while a consolidated parent PLUS loan under 685.208(b)(2) keeps its own minimum. diff --git a/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.yaml b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.yaml new file mode 100644 index 00000000000..838b3c8032f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.yaml @@ -0,0 +1,210 @@ +# One $50 minimum on all of a borrower's payments under the standard repayment +# plan (34 CFR 685.208(b)(1)(ii)), with Direct and parent PLUS loans amortized +# separately and added: min(max(direct x f + parent PLUS x f, 50), direct + +# parent PLUS). f is the 120-month factor at 6 percent: +# .005 / (1 - 1.005 ^ -120) = .0111021. A parent PLUS Direct Consolidation Loan +# that entered repayment on or after July 1, 2006 repays under 685.208(b)(2), a +# separate plan with its own (b)(2)(ii) minimum, so its payment is added to the +# Direct Loan payment as is. + +- name: Case 1, a small Direct balance and a large parent PLUS balance share one 50 dollar minimum. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 1_000 + federal_parent_plus_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 1,000 x .0111021 = 11.10 and 20,000 x .0111021 = 222.04. Separately: + # max(11.10, 50) = 50 and 222.04. Together: max(11.10 + 222.04 = 233.14, + # 50) = 233.14, under the 21,000 balance. + standard_repayment_payment: 50 + parent_plus_standard_repayment_payment: 222.04 + combined_standard_repayment_payment: 233.14 + +- name: Case 2, two small balances owe one 50 dollar minimum. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 1_000 + federal_parent_plus_loan_balance: 1_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 11.10 + 11.10 = 22.20, below 50, so one minimum of 50, under the 2,000 balance; + # each separate variable is 50. + standard_repayment_payment: 50 + parent_plus_standard_repayment_payment: 50 + combined_standard_repayment_payment: 50 + +- name: Case 3, payments above the minimum add up as before. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 5_000 + federal_parent_plus_loan_balance: 5_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 5,000 x .0111021 = 55.51 each, both above 50: 55.51 + 55.51 = 111.02. + standard_repayment_payment: 55.51 + parent_plus_standard_repayment_payment: 55.51 + combined_standard_repayment_payment: 111.02 + +- name: Case 4, a consolidated parent PLUS loan keeps its own minimum under 685.208(b)(2). + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 1_000 + federal_parent_plus_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + federal_parent_plus_loan_is_consolidated: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # Consolidation total 20,000 + min(1,000, 20,000) = 21,000, in the 20,000 to + # 40,000 tier: 20 years. 20,000 x .005 / (1 - 1.005 ^ -240) = 143.29. Direct + # Loans under (b)(1): max(11.10, 50) = 50. Two plans, two minimums: + # 50 + 143.29 = 193.29. + standard_repayment_payment: 50 + parent_plus_standard_repayment_payment: 143.29 + combined_standard_repayment_payment: 193.29 + +- name: Case 5, balances below the minimum cap the payment at the total balance. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 30 + federal_parent_plus_loan_balance: 10 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 30 x .0111021 + 10 x .0111021 = 0.44, raised to 50, then capped at the + # 40 total balance. Separately 30 and 10. + standard_repayment_payment: 30 + parent_plus_standard_repayment_payment: 10 + combined_standard_repayment_payment: 40 + +- name: Case 6, parent PLUS loans alone match the parent PLUS variable. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 0 + federal_parent_plus_loan_balance: 20_000 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 20,000 x .0111021 = 222.04; no Direct Loans. + standard_repayment_payment: 0 + parent_plus_standard_repayment_payment: 222.04 + combined_standard_repayment_payment: 222.04 + +- name: Case 7, Direct Loans alone match the Direct Loan variable. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 20_000 + federal_parent_plus_loan_balance: 0 + federal_student_loan_interest_rate: 0.06 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 20,000 x .0111021 = 222.04; no parent PLUS loans. + standard_repayment_payment: 222.04 + parent_plus_standard_repayment_payment: 0 + combined_standard_repayment_payment: 222.04 + +- name: Case 8, a Direct Loan made on or after July 1, 2026 moves the borrower to the tiered plan. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + federal_student_loan_balance: 1_000 + federal_parent_plus_loan_balance: 20_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] + filing_status: SINGLE + households: + household: + members: [person1] + state_code: OH + output: + # 20 U.S.C. 1087e(d)(1), (d)(7)(A): the borrower repays under the tiered + # standard plan, so each standard plan payment is 0. + standard_repayment_payment: 0 + parent_plus_standard_repayment_payment: 0 + combined_standard_repayment_payment: 0 diff --git a/policyengine_us/tests/test_combined_standard_repayment_property.py b/policyengine_us/tests/test_combined_standard_repayment_property.py new file mode 100644 index 00000000000..3aede6cb72c --- /dev/null +++ b/policyengine_us/tests/test_combined_standard_repayment_property.py @@ -0,0 +1,236 @@ +"""Hypothesis checks: one standard plan minimum per borrower. + +34 CFR 685.208(b)(1) puts Direct Subsidized, Unsubsidized and PLUS loans on +one standard plan, and (b)(1)(ii) sets its $50 minimum on the borrower's +payments. combined_standard_repayment_payment adds the Direct and parent PLUS +level payments before one minimum applies, unless the parent PLUS balance is +a Direct Consolidation Loan under 685.208(b)(2), a separate plan with its own +minimum. With D the Direct variable (standard_repayment_payment), P the parent +PLUS variable (parent_plus_standard_repayment_payment) and C the combined +payment, for generated balances, rates and consolidation flags: + +1. Bound: max(D, P) <= C <= D + P, and C <= the total balance. +2. Agreement: C = D + P where both level payments reach the minimum, and + where the parent PLUS loan is consolidated. +3. One loan type: with one balance zero, C equals the other variable. +4. Monotonicity: C never falls as either balance rises, while the + consolidation loan's repayment period holds still. +5. Differential: where the parent PLUS loan is not consolidated, C equals + min(max(Direct level payment + parent PLUS level payment, 50), total + balance), written here independently of the model. The first four hold + for D + P too, so this one is what catches a minimum on each loan type. + +Each generated borrower is simulated in five variants (as drawn, with a larger +Direct balance, with a larger parent PLUS balance, with no Direct Loans and +with no parent PLUS loans) in one vectorized simulation per example. +""" + +import pytest + +from policyengine_us import Simulation + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +MONTH = "2026-01" +YEAR = 2026 +# Written from the law, not read from the parameter tree. +STANDARD_MONTHS = 120 # 685.208(b)(1)(i) +STANDARD_MINIMUM = 50 # 685.208(b)(1)(ii) +# 685.208(b)(2)(iii): years by the consolidation loan plus other education +# loans, counted up to the consolidation loan's amount. +CONSOLIDATION_TIERS = ((60_000, 30), (40_000, 25), (20_000, 20), (10_000, 15)) +CONSOLIDATION_TIERS += ((7_500, 12), (0, 10)) +TOLERANCE = 0.011 + + +def amortized(balance, annual_rate, months): + monthly_rate = annual_rate / 12 + if monthly_rate == 0: + return balance / months + return balance * monthly_rate / (1 - (1 + monthly_rate) ** -months) + + +def consolidation_years(loans): + if not loans["consolidated"]: + return 10 + total = loans["parent_plus"] + min( + loans["direct"] + loans["ffel"], loans["parent_plus"] + ) + return next(years for floor, years in CONSOLIDATION_TIERS if total >= floor) + + +borrower = st.fixed_dictionaries( + { + "direct": st.one_of(st.just(0), st.integers(1, 6_000), st.integers(1, 80_000)), + "parent_plus": st.one_of( + st.just(0), st.integers(1, 6_000), st.integers(1, 80_000) + ), + "ffel": st.one_of(st.just(0), st.integers(1, 30_000)), + "rate": st.sampled_from([0, 0.03, 0.06, 0.08]), + "consolidated": st.booleans(), + "new_loan": st.sampled_from([False, False, False, True]), + "direct_rise": st.integers(1, 20_000), + "parent_plus_rise": st.integers(1, 20_000), + } +) + + +def variants(loans): + return { + "base": loans, + "direct_rise": dict(loans, direct=loans["direct"] + loans["direct_rise"]), + "parent_plus_rise": dict( + loans, parent_plus=loans["parent_plus"] + loans["parent_plus_rise"] + ), + "no_direct": dict(loans, direct=0), + "no_parent_plus": dict(loans, parent_plus=0), + } + + +def build(borrowers): + """One situation holding every variant of every borrower.""" + people, tax_units, households = {}, {}, {} + order = [] + for n, loans in enumerate(borrowers): + for variant, values in variants(loans).items(): + name = f"b{n}_{variant}" + people[name] = { + "age": {YEAR: 50}, + "federal_student_loan_balance": {YEAR: values["direct"]}, + "federal_parent_plus_loan_balance": {YEAR: values["parent_plus"]}, + "federal_ffel_loan_balance": {YEAR: values["ffel"]}, + "federal_student_loan_interest_rate": {YEAR: values["rate"]}, + "federal_parent_plus_loan_is_consolidated": { + YEAR: values["consolidated"] + }, + "has_federal_student_loan_made_on_or_after_2026_07_01": { + YEAR: values["new_loan"] + }, + } + tax_units[name] = { + "members": [name], + "filing_status": {YEAR: "SINGLE"}, + } + households[name] = {"members": [name], "state_code": {YEAR: "OH"}} + order.append((n, variant)) + situation = { + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": {k: {"members": v["members"]} for k, v in households.items()}, + "spm_units": {k: {"members": v["members"]} for k, v in households.items()}, + "families": {k: {"members": v["members"]} for k, v in households.items()}, + } + simulation = Simulation(situation=situation) + results = {} + for variable in ( + "standard_repayment_payment", + "parent_plus_standard_repayment_payment", + "combined_standard_repayment_payment", + ): + values = simulation.calculate(variable, MONTH) + results[variable] = {key: float(v) for key, v in zip(order, values)} + return results + + +def check(borrowers): + results = build(borrowers) + problems = [] + for n, loans in enumerate(borrowers): + cases = variants(loans) + + def get(variable, variant): + return results[variable][(n, variant)] + + for variant, values in cases.items(): + d = get("standard_repayment_payment", variant) + p = get("parent_plus_standard_repayment_payment", variant) + c = get("combined_standard_repayment_payment", variant) + label = f"b{n} {variant} {values}: D={d} P={p} C={c}" + # 1. Bound. + if c > d + p + TOLERANCE: + problems.append(f"{label}: above D + P") + if c < max(d, p) - TOLERANCE: + problems.append(f"{label}: below max(D, P)") + if c > values["direct"] + values["parent_plus"] + TOLERANCE: + problems.append(f"{label}: above the total balance") + # 2. Agreement. + direct_level = amortized(values["direct"], values["rate"], STANDARD_MONTHS) + parent_plus_level = amortized( + values["parent_plus"], + values["rate"], + consolidation_years(values) * 12, + ) + both_reach_minimum = ( + direct_level >= STANDARD_MINIMUM + and parent_plus_level >= STANDARD_MINIMUM + ) + if (both_reach_minimum or values["consolidated"]) and abs( + c - (d + p) + ) > TOLERANCE: + problems.append(f"{label}: differs from D + P") + # 5. Differential: one minimum, written here from the law. + if not values["consolidated"]: + total = values["direct"] + values["parent_plus"] + reference = min( + max(direct_level + parent_plus_level, STANDARD_MINIMUM), total + ) + if values["new_loan"]: + reference = 0 + if abs(c - reference) > TOLERANCE: + problems.append(f"{label}: differs from one minimum {reference}") + # 3. One loan type. + if values["direct"] == 0 and abs(c - p) > TOLERANCE: + problems.append(f"{label}: no Direct Loans but C != P") + if values["parent_plus"] == 0 and abs(c - d) > TOLERANCE: + problems.append(f"{label}: no parent PLUS loans but C != D") + # 4. Monotonicity, where the consolidation period holds still. + base = get("combined_standard_repayment_payment", "base") + for variant in ("direct_rise", "parent_plus_rise"): + if consolidation_years(cases[variant]) != consolidation_years( + cases["base"] + ): + continue + if get("combined_standard_repayment_payment", variant) < base - TOLERANCE: + problems.append(f"b{n} {variant}: falls as the balance rises") + assert not problems, "\n".join(problems) + + +@hypothesis.settings( + max_examples=10, + deadline=None, + # Each example is a simulation of up to 40 people. Shrinking a failure + # would run hundreds more, so a failing example is reported as drawn. + phases=[ + hypothesis.Phase.explicit, + hypothesis.Phase.reuse, + hypothesis.Phase.generate, + ], + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) +@hypothesis.given(st.lists(borrower, min_size=1, max_size=8)) +def test_one_standard_plan_minimum(borrowers): + check(borrowers) + + +def test_one_standard_plan_minimum_hand_case(): + # 1,000 Direct and 20,000 parent PLUS at 6%: 11.10 + 222.04 = 233.14, + # where the two variables each apply a minimum: 50 + 222.04. + loans = { + "direct": 1_000, + "parent_plus": 20_000, + "ffel": 0, + "rate": 0.06, + "consolidated": False, + "new_loan": False, + "direct_rise": 1, + "parent_plus_rise": 1, + } + results = build([loans]) + combined = results["combined_standard_repayment_payment"][(0, "base")] + assert combined == pytest.approx(233.14, abs=0.01) + check([loans]) diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py new file mode 100644 index 00000000000..1d195b93b99 --- /dev/null +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py @@ -0,0 +1,72 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ed.student_loans.student_loan_amortization_helpers import ( + direct_standard_amortized_payment, + parent_plus_standard_amortized_payment, +) + + +class combined_standard_repayment_payment(Variable): + value_type = float + entity = Person + label = "Combined Direct and parent PLUS standard repayment plan monthly payment" + documentation = ( + "Monthly payment for a borrower with all of their Direct Loans " + "(federal_student_loan_balance) and parent PLUS loans " + "(federal_parent_plus_loan_balance) on the standard repayment plan. " + "34 CFR 685.208(b)(1) puts Direct Subsidized, Unsubsidized and PLUS " + "loans on one 10-year plan, and (b)(1)(ii) sets its $50 minimum on " + "the borrower's payments, so the two level payments are added before " + "one minimum applies, and the total is no more than the two balances. " + "A Direct Consolidation Loan that repaid parent PLUS loans and " + "entered repayment on or after July 1, 2006 " + "(federal_parent_plus_loan_is_consolidated) repays under 685.208(b)(2), " + "a separate plan with its own minimum in (b)(2)(ii), so for that " + "borrower this is the sum of standard_repayment_payment and " + "parent_plus_standard_repayment_payment. FFEL loans are not " + "included. A borrower who holds Direct Loans on one plan and parent " + "PLUS loans on another, such as an income-driven plan for the Direct " + "Loans, is not modeled here; the two separate variables stay the " + "right read for them. A borrower with a Direct Loan made on or after " + "July 1, 2026 cannot use these plans and repays under the tiered " + "standard plan (tiered_standard_repayment_payment), so this is zero " + "for them." + ) + unit = USD + definition_period = MONTH + 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.law.cornell.edu/uscode/text/20/1087e#d_1_A", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.ed.student_loans.standard_repayment + direct_balance = person("federal_student_loan_balance", period.this_year) + parent_plus_balance = person( + "federal_parent_plus_loan_balance", period.this_year + ) + amortized_payment = direct_standard_amortized_payment( + person, period, parameters + ) + parent_plus_standard_amortized_payment(person, period, parameters) + # 685.208(b)(1)(ii): one minimum on all of the borrower's payments + # under the plan, except a final payment below it. + one_plan_payment = min_( + max_(amortized_payment, p.minimum_payment), + direct_balance + parent_plus_balance, + ) + # 685.208(b)(2): a consolidation loan repays under its own plan, with + # its own (b)(2)(ii) minimum. + is_consolidated = person( + "federal_parent_plus_loan_is_consolidated", period.this_year + ) + two_plan_payment = person("standard_repayment_payment", period) + person( + "parent_plus_standard_repayment_payment", period + ) + payment = where(is_consolidated, two_plan_payment, one_plan_payment) + # 20 U.S.C. 1087e(d)(1) and (d)(7)(A) and 34 CFR 685.208(b)(1)-(2) + # limit these plans 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) diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py index 3db5e618ef9..14f5ecf596f 100644 --- a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/parent_plus_standard_repayment_payment.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ed.student_loans.student_loan_amortization_helpers import ( - amortized_monthly_payment, + parent_plus_standard_amortized_payment, ) @@ -32,7 +32,10 @@ class parent_plus_standard_repayment_payment(Variable): "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." + "Repayment plan is not modeled. For a borrower with both Direct and " + "parent PLUS loans on the standard plan, " + "combined_standard_repayment_payment gives the total, with one " + "minimum where both are under 34 CFR 685.208(b)(1)." ) unit = USD definition_period = MONTH @@ -46,27 +49,8 @@ class parent_plus_standard_repayment_payment(Variable): 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 + amortized_payment = parent_plus_standard_amortized_payment( + person, period, parameters ) # As in standard_repayment_payment, the minimum does not apply to a # final payment below it. diff --git a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py index 69c914e461d..9b906043ef9 100644 --- a/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py +++ b/policyengine_us/variables/gov/ed/student_loans/standard_repayment/standard_repayment_payment.py @@ -1,6 +1,6 @@ from policyengine_us.model_api import * from policyengine_us.variables.gov.ed.student_loans.student_loan_amortization_helpers import ( - amortized_monthly_payment, + direct_standard_amortized_payment, ) @@ -15,7 +15,10 @@ class standard_repayment_payment(Variable): "balance. The payment amortizes the current balance, not the balance " "when the loans entered repayment. A borrower with a Direct Loan made " "on or after July 1, 2026 cannot use this plan and repays under the " - "tiered standard plan instead (tiered_standard_repayment_payment)." + "tiered standard plan instead (tiered_standard_repayment_payment). " + "For a borrower with both Direct and parent PLUS loans on the " + "standard plan, combined_standard_repayment_payment gives the total, " + "with one minimum where both are under 34 CFR 685.208(b)(1)." ) unit = USD definition_period = MONTH @@ -28,10 +31,8 @@ class standard_repayment_payment(Variable): def formula(person, period, parameters): p = parameters(period).gov.ed.student_loans.standard_repayment balance = person("federal_student_loan_balance", period.this_year) - annual_rate = person("federal_student_loan_interest_rate", period.this_year) - number_of_payments = p.repayment_period * MONTHS_IN_YEAR - amortized_payment = amortized_monthly_payment( - balance, annual_rate, number_of_payments + amortized_payment = direct_standard_amortized_payment( + person, period, parameters ) # The minimum does not apply to a final payment below it. payment = min_(max_(amortized_payment, p.minimum_payment), balance) diff --git a/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py b/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py index bae45d6fcc8..afdbc551027 100644 --- a/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py +++ b/policyengine_us/variables/gov/ed/student_loans/student_loan_amortization_helpers.py @@ -18,3 +18,44 @@ def amortized_monthly_payment(balance, annual_rate, number_of_payments): where=monthly_rate > 0, ) return balance * payment_per_dollar + + +def direct_standard_amortized_payment(person, period, parameters): + """Level monthly payment on the person's Direct Loans over the 10-year + standard repayment plan (34 CFR 685.208(b)(1)), before the plan minimum + and the balance bound.""" + p = parameters(period).gov.ed.student_loans.standard_repayment + balance = person("federal_student_loan_balance", period.this_year) + annual_rate = person("federal_student_loan_interest_rate", period.this_year) + number_of_payments = p.repayment_period * MONTHS_IN_YEAR + return amortized_monthly_payment(balance, annual_rate, number_of_payments) + + +def parent_plus_standard_amortized_payment(person, period, parameters): + """Level monthly payment on the person's parent PLUS loans under the + standard repayment plan, before the plan minimum and the balance bound: + 10 years (34 CFR 685.208(b)(1)), or 10 to 30 years for a Direct + Consolidation Loan that entered repayment on or after July 1, 2006 + (685.208(b)(2)).""" + 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 + return amortized_monthly_payment(balance, annual_rate, number_of_payments) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 02d04ed6e37..0aa317084eb 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -10867,6 +10867,73 @@ candidates: state: unreviewed note: 'The open PR follows POMS rather than the broader regulatory reading; no statutory comparison or specific ruling is supplied. Code not yet on main: ssi_spousal_deeming_child_allocations, ssi_ineligible_child_allocation.' +- id: us:standard-plan-minimum-consolidated + program: student-loans + years: + from: 2006 + type: calendar + question: Does a borrower with Direct Loans on the 10-year standard plan and a Direct Consolidation Loan that repaid + parent PLUS loans owe one $50 minimum across both, or a minimum on each? + sources: + - kind: statute + citation: 20 U.S.C. 1087e(d)(1)(A) + url: https://www.law.cornell.edu/uscode/text/20/1087e#d_1_A + - kind: regulation + citation: 34 CFR 685.208(b)(1)(ii) and (b)(2)(ii) + url: https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(2) + readings: + statute: The statute provides a standard repayment plan and sets no minimum payment. + other: 'Per plan: a consolidation loan that entered repayment on or after July 1, 2006 repays under (b)(2), a separate + plan with its own (b)(2)(ii) minimum, so the borrower owes a minimum on each plan. Per borrower: one minimum + across all of the borrower''s standard plan payments.' + example: A borrower at 6% with $1,000 of Direct Loans and a $20,000 consolidated parent PLUS loan repaid over 20 + years pays $50.00 + $143.29 = $193.29 with a minimum on each plan, against max($11.10 + $143.29, $50) = $154.39 + with one minimum. + pe_us: + follows: gap-fill + code: + - policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10599 + state: ruled + note: combined_standard_repayment_payment adds standard_repayment_payment and parent_plus_standard_repayment_payment + where federal_parent_plus_loan_is_consolidated is true. This narrows the per-borrower reading in + us:standard-plan-minimum-per-borrower to borrowers whose loans are all under (b)(1). The 2017 and 2025 CFR editions + hold these two plans at 685.208(b) and (c). +- id: us:standard-plan-minimum-per-borrower + program: student-loans + years: + from: 2016 + type: calendar + question: Does a borrower with both Direct Loans and unconsolidated parent PLUS loans on the 10-year standard plan + owe one $50 minimum on all of those payments, or a minimum on each loan type? + sources: + - kind: statute + citation: 20 U.S.C. 1087e(d)(1)(A) + url: https://www.law.cornell.edu/uscode/text/20/1087e#d_1_A + - kind: regulation + citation: 34 CFR 685.208(b)(1) and (b)(1)(ii) + url: https://www.ecfr.gov/current/title-34/section-685.208#p-685.208(b)(1) + readings: + statute: The statute provides a standard repayment plan and sets no minimum payment. + other: '(b)(1) gives Direct Subsidized, Unsubsidized and PLUS borrowers one plan, and (b)(1)(ii) says "A borrower''s + payments under this repayment plan are at least $50 per month", so one minimum covers all of them. The other + reading applies the minimum to each loan type''s payment.' + example: A borrower at 6% with $1,000 of Direct Loans and $20,000 of parent PLUS loans pays max($11.10 + $222.04, + $50) = $233.14 with one minimum, against $50.00 + $222.04 = $272.04 with a minimum on each. + pe_us: + follows: gap-fill + code: + - policyengine_us/variables/gov/ed/student_loans/standard_repayment/combined_standard_repayment_payment.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10599 + state: ruled + note: standard_repayment_payment and parent_plus_standard_repayment_payment keep their own minimums, since a borrower + whose Direct Loans are on an income-driven plan repays parent PLUS loans alone on the standard plan; + combined_standard_repayment_payment applies one minimum for a borrower with both on the standard plan. The 2017 and + 2025 CFR editions carry the same sentence at 685.208(b)(2), so the question runs from the model's first + minimum_payment value in 2016. Issue + 10567 reports the double minimum. - id: us:student-loan-interest-joint-claimed-spouse program: income-tax years: