diff --git a/changelog.d/nj-njhps-exact-fpl-ratio.fixed.md b/changelog.d/nj-njhps-exact-fpl-ratio.fixed.md new file mode 100644 index 00000000000..21f10cbee01 --- /dev/null +++ b/changelog.d/nj-njhps-exact-fpl-ratio.fixed.md @@ -0,0 +1 @@ +Decide New Jersey Health Plan Savings' 600% income ceiling on the exact ratio of ACA MAGI to the prior-year poverty guideline instead of the truncated `aca_magi_fraction`, so incomes from 600.003% to 600.994% of the poverty line are no longer eligible, and put a household exactly at 150%, 200%, 250% or 400% of the poverty line (or up to one point above it) in the higher per-member band, following DOBI's table of plan year 2026 amounts ("At least 150% FPL but less than 200% FPL", ..., "At least 550% FPL but less than or equal to 600% FPL", "Above 600% FPL"). diff --git a/policyengine_us/parameters/gov/states/nj/dobi/njhps/pmpm.yaml b/policyengine_us/parameters/gov/states/nj/dobi/njhps/pmpm.yaml index b8a97a386f6..ce267dacf4f 100644 --- a/policyengine_us/parameters/gov/states/nj/dobi/njhps/pmpm.yaml +++ b/policyengine_us/parameters/gov/states/nj/dobi/njhps/pmpm.yaml @@ -14,6 +14,8 @@ metadata: href: https://pub.njleg.gov/bills/2020/AL20/61_.HTM - title: Get Covered NJ - New Jersey Health Plan Savings href: https://www.nj.gov/getcoverednj/financialhelp/premiums/ + - title: NJ DOBI 2027 Rate Filing Requirements, section 7 (plan year 2026 subsidy amounts by FPL range) + href: https://www.nj.gov/dobi/division_insurance/ihcseh/ratefilings/2027RateFilingRequirements.pdf#page=4 brackets: # Household income measured as ACA MAGI as a share of the federal poverty line. @@ -33,17 +35,23 @@ brackets: # FY2026, continuing "up to 600% FPL"). Neither source alone both states the # values and confirms PY2026 currency, so both are carried. The Get Covered NJ # URL is a secondary (anchorless) corroborating reference, not a value source. - # Bands are lower-exclusive / upper-inclusive, so thresholds use the "x.0001" - # convention to match the published "from X% through Y%" banding. The 138% floor - # is enforced by the eligibility gate; this base band gives $0 below 138% FPL so - # the single_amount lookup covers the full domain. + # Bands are lower-inclusive / upper-exclusive, as DOBI's 2027 Rate Filing + # Requirements (section 7) tabulate "the subsidy amounts from plan year 2026": + # "Less than 150% FPL" $20, "At least 150% FPL but less than 200% FPL" $40, ..., + # "At least 550% FPL but less than or equal to 600% FPL" $50, "Above 600% FPL" + # $0. Each threshold is therefore the band's lower edge, and a household exactly + # at an edge takes the higher band. The scale reads the truncated + # aca_magi_fraction, which is exact for lower-inclusive whole-percentage edges. + # The 138% floor and the 600% ceiling are enforced by the eligibility gate; this + # base band gives $0 below 138% FPL so the single_amount lookup covers the full + # domain. # Below 138% FPL (routed to NJ FamilyCare/Medicaid) -> $0 per member per month. - threshold: 2026-01-01: 0 amount: 0000-01-01: 0 2026-01-01: 0 - # 138% through 150% FPL -> $20 per member per month. The schedule is new to the + # 138% to less than 150% FPL -> $20 per member per month. The schedule is new to the # model for PY2026, so every band is $0 before 2026-01-01. The threshold is set # to 1.3799 (just below the 138% floor) because aca_magi_fraction is a float32 # variable and float32(1.38) rounds to 1.37999999..., which is just BELOW a @@ -54,32 +62,32 @@ brackets: amount: 0000-01-01: 0 2026-01-01: 20 - # 150% through 200% FPL -> $40. + # At least 150% but less than 200% FPL -> $40. - threshold: - 2026-01-01: 1.5001 + 2026-01-01: 1.5 amount: 0000-01-01: 0 2026-01-01: 40 - # 200% through 250% FPL -> $50. + # At least 200% but less than 250% FPL -> $50. - threshold: - 2026-01-01: 2.0001 + 2026-01-01: 2 amount: 0000-01-01: 0 2026-01-01: 50 - # 250% through 400% FPL -> $100. + # At least 250% but less than 400% FPL -> $100. - threshold: - 2026-01-01: 2.5001 + 2026-01-01: 2.5 amount: 0000-01-01: 0 2026-01-01: 100 - # 400% through 600% FPL -> $50. In this band the federal APTC is $0 (the 400% FPL + # At least 400% and up to 600% FPL -> $50. In this band the federal APTC is $0 (the 400% FPL # cliff reverts for PY2026), so NJHPS is the sole premium subsidy. - threshold: - 2026-01-01: 4.0001 + 2026-01-01: 4 amount: 0000-01-01: 0 2026-01-01: 50 - # Above 600% FPL (ineligible) -> $0. + # Above 600% FPL (ineligible; the eligibility gate excludes it) -> $0. - threshold: 2026-01-01: 6.0001 amount: diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/integration.yaml index 372c73ac5dd..a33ade0b86f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/integration.yaml @@ -1,6 +1,7 @@ # Integration tests for New Jersey Health Plan Savings (NJHPS). # -# These exercise the full chain: aca_magi_fraction (income band) -> +# These exercise the full chain: the exact ratio of ACA MAGI to the prior-year +# guideline (eligibility band) and aca_magi_fraction (PMPM band) -> # nj_njhps_member_eligible (per-person non-income enrollee gate) -> # nj_njhps_eligible (in_effect + band + at least one member) -> # nj_njhps (per-member build-up capped by SLCSP - APTC). @@ -12,19 +13,19 @@ # # Amount formula recap: # n = count of nj_njhps_member_eligible members -# pmpm(frac) = 20 [1.38-1.5], 40 (1.5-2.0], 50 (2.0-2.5], 100 (2.5-4.0], -# 50 (4.0-6.0], 0 elsewhere [per member per month] +# pmpm(frac) = 20 [1.38-1.5), 40 [1.5-2.0), 50 [2.0-2.5), 100 [2.5-4.0), +# 50 [4.0-6.0], 0 elsewhere [per member per month] # base_annual = 12 * pmpm(frac) * n # residual = max(0, slcsp_annual - aca_ptc) # annual = eligible * min(base_annual, residual) -- name: Scenario 1, single adult with income deriving 200% FPL, $40 band. +- name: Scenario 1, single adult with income deriving 200% FPL, $50 band. # Income path: employment_income 31_300 -> aca_magi 31_300 (single W-2 filer, # AGI = gross). aca_magi_fraction uses the PRIOR-year (2025) single FPG 15_650: # floor(100 * 31_300 / 15_650) / 100 = floor(200.0) / 100 = 2.0. - # frac 2.0 is upper-inclusive in the (1.5, 2.0] band -> pmpm 40. n=1 -> - # base = 12*40 = 480. residual = max(0, 8_000 - 5_000) = 3_000. - # annual = min(480, 3_000) = 480. + # DOBI's plan year 2026 bands are lower-inclusive: "At least 200% FPL but less + # than 250% FPL" -> pmpm 50. n=1 -> base = 12*50 = 600. + # residual = max(0, 8_000 - 5_000) = 3_000. annual = min(600, 3_000) = 600. period: 2026 absolute_error_margin: 0.01 input: @@ -49,7 +50,7 @@ nj_njhps_eligible: true slcsp: 8_000 # annual sum of the MONTH variable aca_ptc: 5_000 - nj_njhps: 480 + nj_njhps: 600 - name: Scenario 2, family of four at 300% FPL, four members, digest anchor. # frac 3.0 -> pmpm 100. All four pay an ACA premium -> n=4. @@ -66,7 +67,9 @@ tax_units: tax_unit: members: [person1, person2, person3, person4] - aca_magi_fraction: 3.0 + aca_magi: 30_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 20_000 aca_ptc: 8_000 households: @@ -96,7 +99,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 5.0 + aca_magi: 50_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 7_000 aca_ptc: 0 households: @@ -125,7 +130,9 @@ tax_units: tax_unit: members: [person1, person2] - aca_magi_fraction: 2.2 + aca_magi: 22_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 6_000 aca_ptc: 5_200 households: @@ -153,7 +160,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.2 + aca_magi: 12_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 6_000 aca_ptc: 4_000 households: @@ -181,7 +190,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.5 + aca_magi: 25_000 + tax_unit_fpg: + 2025: 10_000 filing_status: SEPARATE slcsp: 9_000 aca_ptc: 4_000 @@ -211,7 +222,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 6.0 + aca_magi: 60_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 10_000 aca_ptc: 0 households: @@ -231,10 +244,10 @@ # =========================================================================== - name: Scenario 8, exactly 400% FPL, APTC-positive residual caps below the base. - # notes: 400.0 leg of the residual-flip pair. frac 4.0 (NOT > 4.0001) -> pmpm 100, - # n=1 -> base = 12*100 = 1_200. At the cliff the tax unit is still APTC-eligible, + # notes: 400.0 leg of the residual-flip pair. "At least 400% FPL" -> pmpm 50, + # n=1 -> base = 12*50 = 600. At the cliff the tax unit is still APTC-eligible, # so aca_ptc 9_500 -> residual = max(0, 10_000 - 9_500) = 500. annual = - # min(1_200, 500) = 500 (the post-APTC residual binds). Paired with Scenario 9. + # min(600, 500) = 500 (the post-APTC residual binds). Paired with Scenario 9. period: 2026 absolute_error_margin: 0.01 input: @@ -245,7 +258,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 4.0 + aca_magi: 40_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 10_000 aca_ptc: 9_500 households: @@ -262,7 +277,7 @@ - name: Scenario 9, just above 400% FPL, APTC 0 so residual is the full SLCSP. # notes: 400.01 leg of the residual-flip pair (same slcsp 10_000 as Scenario 8). - # frac 4.01 > 4.0001 -> pmpm 50, n=1 -> base = 12*50 = 600. Above the 400% cliff + # frac 4.01 >= 4.0 -> pmpm 50, n=1 -> base = 12*50 = 600. Above the 400% cliff # aca_ptc 0 -> residual = max(0, 10_000 - 0) = 10_000. annual = min(600, 10_000) = # 600. The residual source flips from slcsp - aptc (500) to full slcsp (10_000), # so the base binds -> 600. NJHPS is the sole subsidy in this band. @@ -276,7 +291,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 4.01 + aca_magi: 40_100 + tax_unit_fpg: + 2025: 10_000 slcsp: 10_000 aca_ptc: 0 households: @@ -307,7 +324,9 @@ tax_units: tax_unit: members: [person1, person2, person3, person4] - aca_magi_fraction: 3.0 + aca_magi: 30_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 20_000 aca_ptc: 8_000 households: diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps.yaml index 5ce199518f4..a864cfb31d7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps.yaml @@ -1,21 +1,23 @@ # Unit tests for the New Jersey Health Plan Savings (NJHPS) amount. # -# All inputs (pays_aca_premium per person, aca_magi_fraction, slcsp as an annual -# value, aca_ptc) are supplied directly so every expected value is exactly -# hand-verifiable and independent of the rating-area premium lookup. slcsp is a +# All inputs (pays_aca_premium per person, aca_magi against a prior-year +# tax_unit_fpg pinned to $10,000, slcsp as an annual value, aca_ptc) are supplied +# directly so every expected value is exactly hand-verifiable and independent of the rating-area premium lookup. slcsp is a # MONTH-period variable; the amount formula annualizes it via # add(tax_unit, period, ["slcsp"]). Providing slcsp at the YEAR period sets the # annual total, so the values below are annual SLCSP. # # Formula (REQ-5): # n = count of nj_njhps_member_eligible members -# pmpm(frac) = 20 [1.38-1.5], 40 (1.5-2.0], 50 (2.0-2.5], 100 (2.5-4.0], -# 50 (4.0-6.0], 0 elsewhere [per member per month] +# pmpm(frac) = 20 [1.38-1.5), 40 [1.5-2.0), 50 [2.0-2.5), 100 [2.5-4.0), +# 50 [4.0-6.0], 0 elsewhere [per member per month] # base_annual = 12 * pmpm(frac) * n # residual = max(0, slcsp_annual - aca_ptc) # annual = eligible * min(base_annual, residual) -# PMPM bracket thresholds are 1.5001 / 2.0001 / 2.5001 / 4.0001 / 6.0001, so a -# fraction EXACTLY at a band boundary (e.g. 1.5 or 2.0) stays in the LOWER band. +# PMPM bracket thresholds are 1.5 / 2 / 2.5 / 4 (DOBI's plan year 2026 bands, +# "At least 150% FPL but less than 200% FPL" and so on), so a fraction EXACTLY at +# a band boundary (e.g. 1.5 or 2.0) takes the HIGHER band. The 600% ceiling is +# inclusive ("less than or equal to 600% FPL"). # --- Each PMPM band with a positive residual (base binds) --- @@ -32,7 +34,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.4 + aca_magi: 14_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 6_000 aca_ptc: 5_000 households: @@ -55,7 +59,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.8 + aca_magi: 18_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 8_000 aca_ptc: 5_000 households: @@ -78,7 +84,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.2 + aca_magi: 22_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 9_000 aca_ptc: 4_000 households: @@ -101,7 +109,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 3.0 + aca_magi: 30_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 10_000 aca_ptc: 2_000 households: @@ -126,7 +136,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 5.0 + aca_magi: 50_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 7_000 aca_ptc: 0 households: @@ -136,11 +148,12 @@ output: nj_njhps: 600 -# --- Band step boundaries (upper-inclusive; lower band wins at the exact edge) --- +# --- Band step boundaries (lower-inclusive; higher band wins at the exact edge) --- -- name: Case 6, exactly 150% FPL stays in the $20 band. - # frac 1.5 is NOT > 1.5001, so pmpm 20 (not 40). base = 12*20 = 240. - # residual = max(0, 6_000 - 5_000) = 1_000. annual = min(240, 1_000) = 240. +- name: Case 6, exactly 150% FPL is in the $40 band. + # DOBI's plan year 2026 bands: "At least 150% FPL but less than 200% FPL" -> + # pmpm 40. base = 12*40 = 480. + # residual = max(0, 6_000 - 5_000) = 1_000. annual = min(480, 1_000) = 480. period: 2026 absolute_error_margin: 0.01 input: @@ -151,7 +164,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.5 + aca_magi: 15_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 6_000 aca_ptc: 5_000 households: @@ -159,10 +174,10 @@ members: [person1] state_code: NJ output: - nj_njhps: 240 + nj_njhps: 480 - name: Case 7, just above 150% FPL moves to the $40 band. - # frac 1.51 > 1.5001 -> pmpm 40. base = 12*40 = 480. + # frac 1.51 >= 1.5 -> pmpm 40. base = 12*40 = 480. # residual = max(0, 6_000 - 5_000) = 1_000 -> annual = min(480, 1_000) = 480. period: 2026 absolute_error_margin: 0.01 @@ -174,7 +189,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.51 + aca_magi: 15_100 + tax_unit_fpg: + 2025: 10_000 slcsp: 6_000 aca_ptc: 5_000 households: @@ -184,9 +201,9 @@ output: nj_njhps: 480 -- name: Case 8, exactly 200% FPL stays in the $40 band. - # frac 2.0 is NOT > 2.0001, so pmpm 40. base = 12*40 = 480. - # residual = max(0, 8_000 - 5_000) = 3_000 -> annual = min(480, 3_000) = 480. +- name: Case 8, exactly 200% FPL is in the $50 band. + # "At least 200% FPL but less than 250% FPL" -> pmpm 50. base = 12*50 = 600. + # residual = max(0, 8_000 - 5_000) = 3_000 -> annual = min(600, 3_000) = 600. period: 2026 absolute_error_margin: 0.01 input: @@ -197,7 +214,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 8_000 aca_ptc: 5_000 households: @@ -205,10 +224,10 @@ members: [person1] state_code: NJ output: - nj_njhps: 480 + nj_njhps: 600 - name: Case 9, just above 200% FPL moves to the $50 band. - # frac 2.01 > 2.0001 -> pmpm 50. base = 12*50 = 600. + # frac 2.01 >= 2.0 -> pmpm 50. base = 12*50 = 600. # residual = max(0, 8_000 - 5_000) = 3_000 -> annual = min(600, 3_000) = 600. period: 2026 absolute_error_margin: 0.01 @@ -220,7 +239,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.01 + aca_magi: 20_100 + tax_unit_fpg: + 2025: 10_000 slcsp: 8_000 aca_ptc: 5_000 households: @@ -230,9 +251,9 @@ output: nj_njhps: 600 -- name: Case 10, exactly 250% FPL stays in the $50 band. - # frac 2.5 is NOT > 2.5001, so pmpm 50. base = 12*50 = 600. - # residual = max(0, 9_000 - 4_000) = 5_000 -> annual = min(600, 5_000) = 600. +- name: Case 10, exactly 250% FPL is in the $100 band. + # "At least 250% FPL but less than 400% FPL" -> pmpm 100. base = 12*100 = 1_200. + # residual = max(0, 9_000 - 4_000) = 5_000 -> annual = min(1_200, 5_000) = 1_200. period: 2026 absolute_error_margin: 0.01 input: @@ -243,7 +264,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.5 + aca_magi: 25_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 9_000 aca_ptc: 4_000 households: @@ -251,10 +274,10 @@ members: [person1] state_code: NJ output: - nj_njhps: 600 + nj_njhps: 1_200 - name: Case 11, just above 250% FPL moves to the $100 band. - # frac 2.51 > 2.5001 -> pmpm 100. base = 12*100 = 1_200. + # frac 2.51 >= 2.5 -> pmpm 100. base = 12*100 = 1_200. # residual = max(0, 9_000 - 4_000) = 5_000 -> annual = min(1_200, 5_000) = 1_200. period: 2026 absolute_error_margin: 0.01 @@ -266,7 +289,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.51 + aca_magi: 25_100 + tax_unit_fpg: + 2025: 10_000 slcsp: 9_000 aca_ptc: 4_000 households: @@ -276,9 +301,9 @@ output: nj_njhps: 1_200 -- name: Case 12, exactly 400% FPL stays in the $100 band. - # frac 4.0 is NOT > 4.0001, so pmpm 100. base = 12*100 = 1_200. - # residual = max(0, 10_000 - 1_000) = 9_000 -> annual = min(1_200, 9_000) = 1_200. +- name: Case 12, exactly 400% FPL is in the $50 band. + # "At least 400% FPL but less than 450% FPL" -> pmpm 50. base = 12*50 = 600. + # residual = max(0, 10_000 - 1_000) = 9_000 -> annual = min(600, 9_000) = 600. period: 2026 absolute_error_margin: 0.01 input: @@ -289,7 +314,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 4.0 + aca_magi: 40_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 10_000 aca_ptc: 1_000 households: @@ -297,10 +324,10 @@ members: [person1] state_code: NJ output: - nj_njhps: 1_200 + nj_njhps: 600 - name: Case 13, just above 400% FPL moves to the $50 band. - # frac 4.01 > 4.0001 -> pmpm 50. Above the APTC cliff aca_ptc is 0. + # frac 4.01 >= 4.0 -> pmpm 50. Above the APTC cliff aca_ptc is 0. # base = 12*50 = 600. residual = max(0, 7_000 - 0) = 7_000 -> annual = 600. period: 2026 absolute_error_margin: 0.01 @@ -312,7 +339,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 4.01 + aca_magi: 40_100 + tax_unit_fpg: + 2025: 10_000 slcsp: 7_000 aca_ptc: 0 households: @@ -323,7 +352,7 @@ nj_njhps: 600 - name: Case 14, exactly 600% FPL stays in the $50 band. - # frac 6.0 is NOT > 6.0001, so pmpm 50. base = 12*50 = 600. + # frac 6.0 is NOT > 6.0001 (the ceiling is inclusive), so pmpm 50. base = 12*50 = 600. # residual = max(0, 8_000 - 0) = 8_000 -> annual = min(600, 8_000) = 600. period: 2026 absolute_error_margin: 0.01 @@ -335,7 +364,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 6.0 + aca_magi: 60_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 8_000 aca_ptc: 0 households: @@ -357,7 +388,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 6.01 + aca_magi: 60_100 + tax_unit_fpg: + 2025: 10_000 slcsp: 8_000 aca_ptc: 0 households: @@ -384,7 +417,9 @@ tax_units: tax_unit: members: [person1, person2, person3, person4] - aca_magi_fraction: 3.0 + aca_magi: 30_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 20_000 aca_ptc: 8_000 households: @@ -412,7 +447,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.45 + aca_magi: 14_500 + tax_unit_fpg: + 2025: 10_000 slcsp: 5_100 aca_ptc: 5_000 households: @@ -435,7 +472,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.45 + aca_magi: 14_500 + tax_unit_fpg: + 2025: 10_000 slcsp: 6_000 aca_ptc: 5_000 households: @@ -461,7 +500,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.2 + aca_magi: 22_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 5_000 aca_ptc: 5_000 households: @@ -485,7 +526,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.3 + aca_magi: 13_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 6_000 aca_ptc: 3_000 households: @@ -509,7 +552,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 8_000 aca_ptc: 0 households: @@ -533,7 +578,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 8_000 aca_ptc: 5_000 households: @@ -557,7 +604,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2024: 10_000 slcsp: 8_000 aca_ptc: 5_000 households: @@ -588,7 +637,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.2 + aca_magi: 22_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 0 aca_ptc: 0 households: @@ -614,7 +665,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.2 + aca_magi: 22_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 5_600 aca_ptc: 5_000 households: @@ -631,10 +684,10 @@ # residual jumps to the full slcsp. The pmpm band ALSO steps $100 -> $50 here, so # the pair shows the band step and the residual-source flip together. -- name: Case 26, exactly 400% FPL, $100 band, APTC-positive residual caps the base. - # notes: 400.0 leg of the cliff pair. frac 4.0 (NOT > 4.0001) -> pmpm 100, - # base = 12*100 = 1_200. aca_ptc 9_500 (still APTC-eligible at the cliff) -> - # residual = max(0, 10_000 - 9_500) = 500 -> annual = min(1_200, 500) = 500. +- name: Case 26, exactly 400% FPL, $50 band, APTC-positive residual caps the base. + # notes: 400.0 leg of the cliff pair. "At least 400% FPL" -> pmpm 50, + # base = 12*50 = 600. aca_ptc 9_500 (still APTC-eligible at the cliff) -> + # residual = max(0, 10_000 - 9_500) = 500 -> annual = min(600, 500) = 500. # The post-APTC residual binds (distinct from Case 12, where the base binds). period: 2026 absolute_error_margin: 0.01 @@ -646,7 +699,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 4.0 + aca_magi: 40_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 10_000 aca_ptc: 9_500 households: @@ -657,8 +712,8 @@ nj_njhps: 500 - name: Case 27, just above 400% FPL, $50 band, APTC 0 so residual is full SLCSP. - # notes: 400.01 leg of the cliff pair (same slcsp 10_000 as Case 26). frac 4.01 > - # 4.0001 -> pmpm 50, base = 12*50 = 600. Above the 400% cliff aca_ptc 0 -> + # notes: 400.01 leg of the cliff pair (same slcsp 10_000 as Case 26). frac 4.01 >= + # 4.0 -> pmpm 50, base = 12*50 = 600. Above the 400% cliff aca_ptc 0 -> # residual = max(0, 10_000 - 0) = 10_000 -> annual = min(600, 10_000) = 600. # The residual source flips from slcsp - aptc (500 available) to full slcsp # (10_000 available), so the base now binds -> 600. @@ -672,7 +727,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 4.01 + aca_magi: 40_100 + tax_unit_fpg: + 2025: 10_000 slcsp: 10_000 aca_ptc: 0 households: @@ -700,7 +757,9 @@ tax_units: tax_unit: members: [person1, person2, person3, person4] - aca_magi_fraction: 3.0 + aca_magi: 30_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 20_000 aca_ptc: 8_000 households: @@ -728,7 +787,9 @@ tax_units: tax_unit: members: [person1, person2, person3, person4, person5] - aca_magi_fraction: 2.2 + aca_magi: 22_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 30_000 aca_ptc: 5_000 households: @@ -755,7 +816,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.38 + aca_magi: 13_800 + tax_unit_fpg: + 2025: 10_000 slcsp: 6_000 aca_ptc: 3_000 households: @@ -783,7 +846,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.2 + aca_magi: 22_000 + tax_unit_fpg: + 2025: 10_000 slcsp: 5_000 aca_ptc: 6_000 households: @@ -792,3 +857,27 @@ state_code: NJ output: nj_njhps: 0 + +- name: Case 32, $23,630.56 (150.994% of the 2025 guideline) is in the $40 band. + # The truncated fraction is 1.50, which is "at least 150%": pmpm 40, so + # base = 12*40 = 480. residual = max(0, 6_000 - 5_000) = 1_000 -> annual = 480. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + pays_aca_premium: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 23_630.56 + slcsp: 6_000 + aca_ptc: 5_000 + households: + household: + members: [person1] + state_code: NJ + output: + aca_magi_fraction: 1.50 + nj_njhps: 480 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps_eligible.yaml index ca24a904635..6f404f33457 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps_eligible.yaml @@ -2,7 +2,10 @@ # # nj_njhps_eligible (TaxUnit, bool, YEAR) returns: # in_effect -# & (fpl_floor <= aca_magi_fraction <= fpl_limit) [1.38 <= frac <= 6.0] +# & (fpl_floor <= ratio <= fpl_limit) [1.38 <= ratio <= 6.0] +# where ratio = max(aca_magi, 0) / tax_unit_fpg(prior year), the exact ratio, not +# the truncated aca_magi_fraction. Supplied-income cases pin the prior-year +# guideline to $10,000. # & (count of nj_njhps_member_eligible > 0) # # in_effect is false before 2026-01-01 and true (open-ended) from 2026-01-01. @@ -20,7 +23,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -41,7 +46,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.37 + aca_magi: 13_700 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -60,7 +67,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.38 + aca_magi: 13_800 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -81,7 +90,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 6.0 + aca_magi: 60_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -100,7 +111,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 6.01 + aca_magi: 60_100 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -122,7 +135,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -144,7 +159,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 5.0 + aca_magi: 50_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -165,7 +182,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -187,7 +206,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2024: 10_000 households: household: members: [person1] @@ -206,10 +227,88 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2026: 10_000 households: household: members: [person1] state_code: NJ output: nj_njhps_eligible: true + +- name: Case 11, $93,900 (exactly 600% of the 2025 guideline) is eligible. + period: 2026 + input: + people: + person1: + age: 40 + nj_njhps_member_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 93_900 + households: + household: + members: [person1] + state_code: NJ + output: + aca_magi_fraction: 6.00 + nj_njhps_eligible: true + +- name: Case 12, $93,900.47 (600.003%) is ineligible though truncated to 6.00. + period: 2026 + input: + people: + person1: + age: 40 + nj_njhps_member_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 93_900.47 + households: + household: + members: [person1] + state_code: NJ + output: + aca_magi_fraction: 6.00 + nj_njhps_eligible: false + +- name: Case 13, $94,055.56 (600.994%) is ineligible though truncated to 6.00. + period: 2026 + input: + people: + person1: + age: 40 + nj_njhps_member_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 94_055.56 + households: + household: + members: [person1] + state_code: NJ + output: + aca_magi_fraction: 6.00 + nj_njhps_eligible: false + +- name: Case 14, $21,597 (exactly 138%) is eligible. + period: 2026 + input: + people: + person1: + age: 40 + nj_njhps_member_eligible: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 21_597 + households: + household: + members: [person1] + state_code: NJ + output: + aca_magi_fraction: 1.38 + nj_njhps_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps_member_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps_member_eligible.yaml index 0aadc7215a3..03cbbb9652a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps_member_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/dobi/njhps/nj_njhps_member_eligible.yaml @@ -5,11 +5,14 @@ # PY2026 (the 400% APTC cliff reverts). It returns: # pays_aca_premium # & filing_status != SEPARATE -# & (fpl_floor <= aca_magi_fraction <= fpl_limit) [1.38 <= frac <= 6.0] +# & (fpl_floor <= ratio <= fpl_limit) [1.38 <= ratio <= 6.0] +# where ratio = max(aca_magi, 0) / tax_unit_fpg(prior year), the exact ratio, not +# the truncated aca_magi_fraction. # # pays_aca_premium is a Person YEAR bool supplied directly so each case is -# independent of the immigration / MEC / TIN / coverage-gap internals. aca_magi_fraction and -# filing_status are TaxUnit inputs. The band boundaries are inclusive on both ends: +# independent of the immigration / MEC / TIN / coverage-gap internals. aca_magi (against a +# prior-year tax_unit_fpg pinned to $10,000) and filing_status are TaxUnit inputs; +# income-derived cases use the actual 2025 one-person guideline, $15,650. The band boundaries are inclusive on both ends: # 1.38 (138% floor) and 6.0 (600% ceiling). # --- pays_aca_premium gate --- @@ -24,7 +27,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -42,7 +47,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -64,7 +71,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 filing_status: SEPARATE households: household: @@ -86,7 +95,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.37 + aca_magi: 13_700 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -105,7 +116,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 1.38 + aca_magi: 13_800 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -124,7 +137,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 6.0 + aca_magi: 60_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -143,7 +158,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 6.01 + aca_magi: 60_100 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -166,7 +183,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 5.0 + aca_magi: 50_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -187,7 +206,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] @@ -210,7 +231,9 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: 2.0 + aca_magi: 20_000 + tax_unit_fpg: + 2024: 10_000 households: household: members: [person1] @@ -235,7 +258,9 @@ tax_units: tax_unit: members: [person1, person2, person3, person4] - aca_magi_fraction: 3.0 + aca_magi: 30_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1, person2, person3, person4] @@ -260,10 +285,88 @@ tax_units: tax_unit: members: [person1] - aca_magi_fraction: -0.5 + aca_magi: -5_000 + tax_unit_fpg: + 2025: 10_000 households: household: members: [person1] state_code: NJ output: nj_njhps_member_eligible: false + +- name: Case 13, $93,900 (exactly 600% of the 2025 guideline) is eligible. + period: 2026 + input: + people: + person1: + age: 40 + pays_aca_premium: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 93_900 + households: + household: + members: [person1] + state_code: NJ + output: + aca_magi_fraction: 6.00 + nj_njhps_member_eligible: [true] + +- name: Case 14, $93,900.47 (600.003%) is ineligible though truncated to 6.00. + period: 2026 + input: + people: + person1: + age: 40 + pays_aca_premium: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 93_900.47 + households: + household: + members: [person1] + state_code: NJ + output: + aca_magi_fraction: 6.00 + nj_njhps_member_eligible: [false] + +- name: Case 15, $94,055.56 (600.994%) is ineligible though truncated to 6.00. + period: 2026 + input: + people: + person1: + age: 40 + pays_aca_premium: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 94_055.56 + households: + household: + members: [person1] + state_code: NJ + output: + aca_magi_fraction: 6.00 + nj_njhps_member_eligible: [false] + +- name: Case 16, $21,597 (exactly 138%) is eligible. + period: 2026 + input: + people: + person1: + age: 40 + pays_aca_premium: true + tax_units: + tax_unit: + members: [person1] + aca_magi: 21_597 + households: + household: + members: [person1] + state_code: NJ + output: + aca_magi_fraction: 1.38 + nj_njhps_member_eligible: [true] diff --git a/policyengine_us/variables/gov/states/nj/dobi/njhps/nj_njhps_eligible.py b/policyengine_us/variables/gov/states/nj/dobi/njhps/nj_njhps_eligible.py index 558647c50a9..77bc4e6b89d 100644 --- a/policyengine_us/variables/gov/states/nj/dobi/njhps/nj_njhps_eligible.py +++ b/policyengine_us/variables/gov/states/nj/dobi/njhps/nj_njhps_eligible.py @@ -24,7 +24,10 @@ class nj_njhps_eligible(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nj.dobi.njhps in_effect = p.in_effect - magi_fraction = tax_unit("aca_magi_fraction", period) + # The exact ratio, not the truncated aca_magi_fraction, which maps + # 600.00%-600.99% to 6.00 (see nj_njhps_member_eligible). + magi = max_(tax_unit("aca_magi", period), 0) + magi_fraction = magi / tax_unit("tax_unit_fpg", period.last_year) income_eligible = (magi_fraction >= p.fpl_floor) & ( magi_fraction <= p.fpl_limit ) diff --git a/policyengine_us/variables/gov/states/nj/dobi/njhps/nj_njhps_member_eligible.py b/policyengine_us/variables/gov/states/nj/dobi/njhps/nj_njhps_member_eligible.py index 6be048c756f..3777f6cff92 100644 --- a/policyengine_us/variables/gov/states/nj/dobi/njhps/nj_njhps_member_eligible.py +++ b/policyengine_us/variables/gov/states/nj/dobi/njhps/nj_njhps_member_eligible.py @@ -35,8 +35,13 @@ class nj_njhps_member_eligible(Variable): def formula(person, period, parameters): p = parameters(period).gov.states.nj.dobi.njhps - # aca_magi_fraction is a TaxUnit variable read at the person level. - magi_fraction = person.tax_unit("aca_magi_fraction", period) + # DOBI's plan year 2026 schedule runs to "less than or equal to 600% + # FPL" ($0 "Above 600% FPL"). Test the exact ratio, not the truncated + # aca_magi_fraction: truncation maps 600.00%-600.99% to 6.00, which + # would admit incomes above 600%. The 138% floor (">=") is unaffected + # by truncation. Both are TaxUnit quantities read at the person level. + magi = max_(person.tax_unit("aca_magi", period), 0) + magi_fraction = magi / person.tax_unit("tax_unit_fpg", period.last_year) # pays_aca_premium is the Person-level Marketplace enrollment test: # TIN, immigration/lawful-presence status, no ineligible minimum # essential coverage, the age-based premium requirement and, since