diff --git a/changelog.d/hb-lha-cap-before-taper.fixed.md b/changelog.d/hb-lha-cap-before-taper.fixed.md new file mode 100644 index 0000000000..85a7208322 --- /dev/null +++ b/changelog.d/hb-lha-cap-before-taper.fixed.md @@ -0,0 +1 @@ +Apply the Local Housing Allowance cap to the eligible rent before the Housing Benefit taper and non-dependant deductions, as SI 2006/213 regs 12D(2)(a), 70 and 71 (SI 2006/214 regs 12D, 50 and 51 at pension age) require, rather than capping the tapered rent. diff --git a/docs/book/programs/gov/dwp/housing-benefit.ipynb b/docs/book/programs/gov/dwp/housing-benefit.ipynb index 4f928b173d..3fa1f88ec0 100644 --- a/docs/book/programs/gov/dwp/housing-benefit.ipynb +++ b/docs/book/programs/gov/dwp/housing-benefit.ipynb @@ -38,9 +38,9 @@ "\n", "The award is computed in four stages:\n", "\n", - "1. **Eligible rent** (`benunit_rent`): the contracted rent for the property, capped at the Local Housing Allowance rate where the LHA applies.\n", + "1. **Eligible rent**: the rent the benunit is liable for (`benunit_rent`). Where the Local Housing Allowance applies (`LHA_eligible`: renting, and no member in social housing), the eligible rent is the maximum rent (LHA), which is the LHA rate or, where the rent is lower, the rent (`LHA_cap`; SI 2006/213 regs [12D(2)(a)](https://www.legislation.gov.uk/uksi/2006/213/regulation/12D) and [13D(5)](https://www.legislation.gov.uk/uksi/2006/213/regulation/13D)).\n", "2. **Applicable amount** (`housing_benefit_applicable_amount`): a personal allowance derived from the benunit's composition (single, couple, lone parent) and age, plus any disability/carer premiums. This is the \"minimum income\" the household needs before HB tapers begin.\n", - "3. **Means test** (`housing_benefit_entitlement`): the benunit's `housing_benefit_applicable_income` in excess of the applicable amount is tapered against the eligible rent at the published `withdrawal_rate` (currently 0.65). The result is then reduced by `housing_benefit_non_dep_deductions` for any non-dependant adults living in the household.\n", + "3. **Means test** (`housing_benefit_entitlement`): the appropriate maximum Housing Benefit is the eligible rent less `housing_benefit_non_dep_deductions` for any non-dependant adults living in the household ([reg. 70](https://www.legislation.gov.uk/uksi/2006/213/regulation/70)). The published `withdrawal_rate` (currently 0.65) of the benunit's `housing_benefit_applicable_income` in excess of the applicable amount is deducted from that maximum ([SSCBA 1992 s. 130(3)(b)](https://www.legislation.gov.uk/ukpga/1992/4/section/130), [reg. 71](https://www.legislation.gov.uk/uksi/2006/213/regulation/71)). Because the LHA caps the eligible rent, the taper comes off the LHA: a private tenant with rent above the LHA and income above the applicable amount receives the LHA less the taper, not the lower of the LHA and the rent less the taper. The pension-age regulations (SI 2006/214) have the same rules at regs 12D, 13D, 50 and 51, and the Northern Ireland regulations at SR 2006/405 regs 13C, 14D, 68 and 69 and SR 2006/406 regs 13C, 14D, 48 and 49.\n", "4. **Benefit cap** (`housing_benefit`): the post-means-test award is finally reduced by `benefit_cap_reduction` if the household's total benefit income exceeds the cap and no cap exemption applies.\n", "\n", "The take-up gate (`would_claim_housing_benefit`) is applied inside `housing_benefit_pre_benefit_cap`." diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_entitlement.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_entitlement.yaml index 36ff0f8875..bfb2fb548c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_entitlement.yaml @@ -20,7 +20,11 @@ LHA_cap: 1_000 housing_benefit_non_dep_deductions: 0 output: - housing_benefit_entitlement: 1_000 + # The eligible rent is the LHA cap of 1,000 (SI 2006/213 reg 12D(2)(a)), + # and the taper of 0.65 * (11,000 - 1,000) = 6,500 is deducted from it + # (reg 71): max(0, 1,000 - 6,500) = 0. Capping after the taper gave + # min(8,000 - 6,500, 1,000) = 1,000. + housing_benefit_entitlement: 0 - name: Capped at 0 period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml new file mode 100644 index 0000000000..4714f22593 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml @@ -0,0 +1,186 @@ +# The Local Housing Allowance caps the eligible rent before the taper. +# Where a maximum rent (LHA) applies, it is the eligible rent (SI 2006/213 and +# SI 2006/214 reg 12D(2)(a)), and it is the rent where the rent is below the +# LHA (reg 13D(5)). The appropriate maximum Housing Benefit is the eligible rent +# less non-dependant deductions (reg 70; SPC reg 50), and 65% of the excess of +# income over the applicable amount is deducted from it (SSCBA 1992 +# s.130(3)(b); reg 71; SPC reg 51). So: +# HB = max(0, min(rent, LHA) - non-dependant deductions - taper) +# The code before this fix capped after the taper: +# max(0, min(max(0, rent - taper), LHA) - non-dependant deductions) +# +# Applicable income and amount are inputs, so the taper is exact and does not +# depend on the earnings disregard or the Guarantee Credit passport. +# LHA_cap is min(rent, LHA rate), as LHA_cap.py computes it. + +- name: Rent above the LHA is tapered from the LHA, not from the rent + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 30_000 + LHA_eligible: true + LHA_cap: 9_467.64 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 14_948.80 + housing_benefit_non_dep_deductions: 0 + output: + # taper = 0.65 * (14,948.80 - 13,312) = 0.65 * 1,636.80 = 1,063.92 + # HB = 9,467.64 - 1,063.92 = 8,403.72 + # (the old order gave min(30,000 - 1,063.92, 9,467.64) = 9,467.64) + housing_benefit_entitlement: 8_403.72 + +- name: Single pensioner private renter with rent above the LHA + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + state_pension_reported: 16_000 + benunits: + benunit: + members: [person] + # The model's 2026 rate for this household (default BRMA, Maidstone, + # one-bedroom category) is also 9,467.64; it is an input here so the + # test pins the order of the cap and the taper, not the LHA table. + BRMA_LHA_rate: 9_467.64 + # Inputs, so the earnings disregard and the pension-age allowance + # cannot move the taper. The applicable income input overrides the + # income computed from state_pension_reported, which only keeps + # guarantee_credit at 0 below. + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 14_948.80 + households: + household: + members: [person] + rent: 30_000 + tenure_type: RENT_PRIVATELY + output: + # State Pension of 16,000 is above the Guarantee Credit minimum guarantee, + # so no Guarantee Credit passport applies. + guarantee_credit: 0 + LHA_eligible: true + # LHA_cap = min(30,000, 9,467.64) = 9,467.64 + LHA_cap: 9_467.64 + housing_benefit_non_dep_deductions: 0 + # 9,467.64 - 0.65 * (14,948.80 - 13,312) = 9,467.64 - 1,063.92 = 8,403.72 + housing_benefit_eligible: true + housing_benefit: 8_403.72 + +- name: Rent below the LHA is the eligible rent + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 8_000 + LHA_eligible: true + # min(8,000, 9,467.64) = 8,000 + LHA_cap: 8_000 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 14_948.80 + housing_benefit_non_dep_deductions: 0 + output: + # 8,000 - 1,063.92 = 6,936.08, as under the old order + housing_benefit_entitlement: 6_936.08 + +- name: Taper larger than the excess of the rent over the LHA + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 12_000 + LHA_eligible: true + LHA_cap: 9_467.64 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 21_312 + housing_benefit_non_dep_deductions: 0 + output: + # taper = 0.65 * (21,312 - 13,312) = 0.65 * 8,000 = 5,200 + # HB = 9,467.64 - 5,200 = 4,267.64 + # (the old order gave min(12,000 - 5,200, 9,467.64) = 6,800, which is + # 2,532.36 = rent - LHA more) + housing_benefit_entitlement: 4_267.64 + +- name: Taper larger than the LHA leaves no Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 30_000 + LHA_eligible: true + LHA_cap: 9_467.64 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 33_312 + housing_benefit_non_dep_deductions: 0 + output: + # taper = 0.65 * (33,312 - 13,312) = 0.65 * 20,000 = 13,000 > 9,467.64 + # HB = max(0, 9,467.64 - 13,000) = 0 + # (the old order gave min(30,000 - 13,000, 9,467.64) = 9,467.64) + housing_benefit_entitlement: 0 + +- name: Income below the applicable amount gets the LHA in full + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 30_000 + LHA_eligible: true + LHA_cap: 9_467.64 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 10_000 + housing_benefit_non_dep_deductions: 0 + output: + # taper = 0.65 * max(0, 10,000 - 13,312) = 0; HB = 9,467.64 + housing_benefit_entitlement: 9_467.64 + +- name: LHA couple with a non-dependant (rulespec-uk pipeline case 4) + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 9_000 + LHA_eligible: true + # min(9,000, LHA 8,000) = 8,000 + LHA_cap: 8_000 + housing_benefit_applicable_amount: 7_807.80 + housing_benefit_applicable_income: 12_000 + housing_benefit_non_dep_deductions: 3_346.20 + output: + # taper = 0.65 * (12,000 - 7,807.80) = 0.65 * 4,192.20 = 2,724.93 + # HB = 8,000 - 3,346.20 - 2,724.93 = 1,928.87 + # (the old order gave min(9,000 - 2,724.93, 8,000) - 3,346.20 + # = 6,275.07 - 3,346.20 = 2,928.87) + housing_benefit_entitlement: 1_928.87 + +- name: Council tenant with the same rent and taper is not capped + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: [person] + housing_benefit_applicable_amount: 7_807.80 + housing_benefit_applicable_income: 12_000 + housing_benefit_non_dep_deductions: 3_346.20 + households: + household: + members: [person] + rent: 9_000 + tenure_type: RENT_FROM_COUNCIL + output: + LHA_eligible: false + # eligible rent = rent = 9,000 + # HB = 9,000 - 3,346.20 - 2,724.93 = 2,928.87, as under the old order + housing_benefit_entitlement: 2_928.87 + +- name: Non-dependant deductions above the LHA leave no Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + benunit_rent: 9_000 + LHA_eligible: true + LHA_cap: 8_000 + housing_benefit_applicable_amount: 13_312 + housing_benefit_applicable_income: 10_000 + housing_benefit_non_dep_deductions: 8_500 + output: + # no taper; HB = max(0, 8,000 - 8,500) = 0 + housing_benefit_entitlement: 0 diff --git a/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py new file mode 100644 index 0000000000..a61eda1dfd --- /dev/null +++ b/policyengine_uk/tests/test_housing_benefit_lha_order_properties.py @@ -0,0 +1,428 @@ +"""Property-based tests for the order of the LHA cap and the taper. + +Where a maximum rent (LHA) applies, it is the eligible rent (SI 2006/213 and +SI 2006/214 reg 12D(2)(a)), and it is the rent where the rent is below the +LHA (reg 13D(5)). The appropriate maximum Housing Benefit is the eligible rent +less non-dependant deductions (reg 70; SPC reg 50), and 65% of the excess of +income over the applicable amount is deducted from it (SSCBA 1992 +s.130(3)(b); reg 71; SPC reg 51). So, with taper = 0.65 * max(0, income - +applicable amount): + + HB = max(0, eligible rent - non-dependant deductions - taper) + +where the eligible rent is min(rent, LHA) for LHA tenants and the rent for +social tenants. + +Invariants, for any generated population of benefit units: + +1. Bounds: 0 <= HB <= eligible rent <= rent, and for LHA tenants + HB <= LHA_cap = min(rent, LHA rate). +2. Closed form: HB equals the formula above. This pins the formula to the + law as read here; the hand-computed YAML cases, the bounds and the + differential are the independent checks. +3. Monotonicity: HB is non-increasing in applicable income and in + non-dependant deductions, and non-decreasing in rent, the LHA rate and the + applicable amount. +4. Rent above the LHA: for an LHA tenant whose rent is at or above the LHA + rate, raising the rent further leaves HB unchanged. +5. Differential against the formula before this change, which capped after + the taper (max(0, min(max(0, rent - taper), LHA_cap) - deductions)): + the two agree for social tenants, where the rent is at or below the LHA, + where there is no taper, and where the old award was 0. Otherwise the new + award is lower, and old - new = min(rent - LHA_cap, taper, old) exactly. + This difference is intended. +6. End to end: for pension-age renters, some with a working-age + non-dependant, housing_benefit satisfies the closed form in the model's + own applicable income, applicable amount and non-dependant deductions. + +Each test also runs an explicit example where the two orders differ (one +unit for each term of the difference in invariant 5), so every test fails +under the old order whatever Hypothesis draws. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +# Engine values are float32; 0.05 covers rounding on amounts up to 100,000. +TOLERANCE = 0.05 +PROPERTY_SETTINGS = settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +RENTED_TENURES = ["RENT_FROM_COUNCIL", "RENT_FROM_HA", "RENT_PRIVATELY"] +NON_INCREASING = ["income", "non_dep_deductions"] +NON_DECREASING = ["rent", "lha_rate", "applicable_amount"] + + +def between(low, high): + # Whole pence, so the arithmetic is exact to the penny. + return st.integers(low * 100, high * 100).map(lambda pence: pence / 100) + + +def money(high): + return between(0, high) + + +def above(floor, high): + return st.integers(0, high * 100).map( + lambda pence: (round(floor * 100) + pence) / 100 + ) + + +@st.composite +def units(draw): + # Hypothesis favours small draws. An LHA of a few pounds would leave + # every tapered award at 0, where the two orders agree, so the LHA and + # the applicable amount start at realistic levels. + lha_rate = draw(between(2_000, 20_000)) + applicable_amount = draw(between(5_000, 25_000)) + return dict( + tenure=draw(st.sampled_from(RENTED_TENURES)), + lha_rate=lha_rate, + # Many draws put the rent above the LHA and the income a little above + # the applicable amount, where capping before or after the taper + # differs. + rent=draw(st.one_of(money(40_000), above(lha_rate, 20_000))), + applicable_amount=applicable_amount, + income=draw( + st.one_of(st.just(0.0), money(60_000), above(applicable_amount, 10_000)) + ), + non_dep_deductions=draw(st.one_of(st.just(0.0), money(5_000))), + ) + + +POPULATION = st.lists(units(), min_size=10, max_size=40) +BUMP = money(10_000).filter(lambda bump: bump > 0) + +# One unit for each term of old - new = min(rent - LHA_cap, taper, old), +# worked by hand, and a council tenant for whom the orders agree. Taper is +# 0.65 * (income - applicable amount). +DIFFERENCE_EXAMPLES = [ + # The taper binds: taper 0.65 * 1,636.80 = 1,063.92; new 9,467.64 - + # 1,063.92 = 8,403.72; old min(30,000 - 1,063.92, 9,467.64) = 9,467.64. + dict( + tenure="RENT_PRIVATELY", + lha_rate=9_467.64, + rent=30_000.0, + applicable_amount=13_312.0, + income=14_948.80, + non_dep_deductions=0.0, + ), + # The rent excess binds: taper 0.65 * 8,000 = 5,200; new 9,467.64 - + # 5,200 = 4,267.64; old min(12,000 - 5,200, 9,467.64) = 6,800; the + # difference is 12,000 - 9,467.64 = 2,532.36. + dict( + tenure="RENT_PRIVATELY", + lha_rate=9_467.64, + rent=12_000.0, + applicable_amount=13_312.0, + income=21_312.0, + non_dep_deductions=0.0, + ), + # The old award binds: taper 0.65 * 4,192.20 = 2,724.93; new max(0, + # 8,000 - 6,000 - 2,724.93) = 0; old min(9,000 - 2,724.93, 8,000) - + # 6,000 = 275.07. + dict( + tenure="RENT_PRIVATELY", + lha_rate=8_000.0, + rent=9_000.0, + applicable_amount=7_807.80, + income=12_000.0, + non_dep_deductions=6_000.0, + ), + # rulespec-uk pipeline case 4: new 8,000 - 3,346.20 - 2,724.93 = + # 1,928.87; old 6,275.07 - 3,346.20 = 2,928.87. + dict( + tenure="RENT_PRIVATELY", + lha_rate=8_000.0, + rent=9_000.0, + applicable_amount=7_807.80, + income=12_000.0, + non_dep_deductions=3_346.20, + ), + # The same numbers for a council tenant: 2,928.87 under both orders. + dict( + tenure="RENT_FROM_COUNCIL", + lha_rate=8_000.0, + rent=9_000.0, + applicable_amount=7_807.80, + income=12_000.0, + non_dep_deductions=3_346.20, + ), +] + + +def calculate(population): + """Housing Benefit inputs and entitlement for independent benefit units. + + Each unit is a one-person benefit unit in its own household, so one + simulation can hold a population and its perturbed copies. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(population): + name = f"p{i}" + people[name] = {"age": {YEAR: 70}} + benunits[f"b{i}"] = { + "members": [name], + "BRMA_LHA_rate": {YEAR: unit["lha_rate"]}, + "housing_benefit_applicable_amount": {YEAR: unit["applicable_amount"]}, + "housing_benefit_applicable_income": {YEAR: unit["income"]}, + "housing_benefit_non_dep_deductions": {YEAR: unit["non_dep_deductions"]}, + } + households[f"h{i}"] = { + "members": [name], + "rent": {YEAR: unit["rent"]}, + "tenure_type": {YEAR: unit["tenure"]}, + } + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + values = { + variable: np.asarray(sim.calculate(variable, YEAR), dtype=float) + for variable in [ + "housing_benefit_entitlement", + "benunit_rent", + "BRMA_LHA_rate", + "LHA_cap", + "LHA_eligible", + "housing_benefit_applicable_amount", + "housing_benefit_applicable_income", + "housing_benefit_non_dep_deductions", + ] + } + rate = sim.tax_benefit_system.parameters( + YEAR + ).gov.dwp.housing_benefit.means_test.withdrawal_rate + values["lha"] = values["LHA_eligible"].astype(bool) + values["taper"] = rate * np.maximum( + 0, + values["housing_benefit_applicable_income"] + - values["housing_benefit_applicable_amount"], + ) + values["eligible_rent"] = np.where( + values["lha"], values["LHA_cap"], values["benunit_rent"] + ) + return values + + +def old_entitlement(values): + """housing_benefit_entitlement as computed before this change.""" + final_amount = np.maximum(0, values["benunit_rent"] - values["taper"]) + capped = np.minimum(final_amount, values["LHA_cap"]) + amount = np.where(values["lha"], capped, final_amount) + return np.maximum(0, amount - values["housing_benefit_non_dep_deductions"]) + + +@PROPERTY_SETTINGS +@given(POPULATION) +@example(DIFFERENCE_EXAMPLES) +def test_bounds_closed_form_and_differential(population): + values = calculate(population) + hb = values["housing_benefit_entitlement"] + rent = values["benunit_rent"] + lha = values["lha"] + tenures = np.array([unit["tenure"] for unit in population]) + # LHA tenants are exactly the private renters here. + assert np.array_equal(lha, tenures == "RENT_PRIVATELY") + assert np.allclose( + values["LHA_cap"], np.minimum(rent, values["BRMA_LHA_rate"]), atol=TOLERANCE + ) + + # 1. Bounds. + assert np.all(hb >= 0) + assert np.all(hb <= values["eligible_rent"] + TOLERANCE) + assert np.all(values["eligible_rent"] <= rent + TOLERANCE) + assert np.all(hb[lha] <= values["LHA_cap"][lha] + TOLERANCE) + + # 2. Closed form. + expected = np.maximum( + 0, + values["eligible_rent"] + - values["housing_benefit_non_dep_deductions"] + - values["taper"], + ) + assert np.allclose(hb, expected, atol=TOLERANCE), population + + # 5. Differential against capping after the taper. + old = old_entitlement(values) + rent_excess = rent - values["LHA_cap"] + agree = ~lha | (rent_excess <= 0) | (values["taper"] == 0) | (old == 0) + assert np.allclose(hb[agree], old[agree], atol=TOLERANCE), population + assert np.all(hb <= old + TOLERANCE) + # Intended difference: the taper now comes off the LHA, not the rent. + difference = np.where( + lha, np.minimum(np.minimum(rent_excess, values["taper"]), old), 0 + ) + assert np.allclose(old - hb, difference, atol=TOLERANCE), population + + +@settings(PROPERTY_SETTINGS, max_examples=10) +@given(POPULATION, st.lists(BUMP, min_size=5, max_size=5)) +@example(DIFFERENCE_EXAMPLES, [1_000.0] * 5) +def test_monotonicity(population, bumps): + fields = NON_INCREASING + NON_DECREASING + variants = [population] + [ + [{**unit, field: unit[field] + bump} for unit in population] + for field, bump in zip(fields, bumps) + ] + hb = calculate([unit for variant in variants for unit in variant])[ + "housing_benefit_entitlement" + ].reshape(len(variants), len(population)) + before = hb[0] + for after, field in zip(hb[1:], fields): + if field in NON_INCREASING: + assert np.all(after <= before + TOLERANCE), (population, field, bumps) + else: + assert np.all(after >= before - TOLERANCE), (population, field, bumps) + + # 4. For LHA tenants already at or above the LHA, more rent changes + # nothing: the eligible rent is the LHA. + at_or_above_lha = np.array( + [ + unit["tenure"] == "RENT_PRIVATELY" and unit["rent"] >= unit["lha_rate"] + for unit in population + ] + ) + after_rent = hb[1 + fields.index("rent")] + assert np.allclose( + after_rent[at_or_above_lha], before[at_or_above_lha], atol=TOLERANCE + ), (population, bumps) + + +@st.composite +def pension_age_households(draw): + lha_rate = draw(between(4_000, 15_000)) + return dict( + ages=draw(st.lists(st.integers(67, 95), min_size=1, max_size=2)), + # Often a little above the Pension Credit minimum guarantee, so the + # taper bites without removing the award. + state_pension=draw(st.one_of(above(14_000, 6_000), money(20_000))), + private_pension=draw(st.one_of(st.just(0.0), money(5_000))), + tenure=draw(st.sampled_from(RENTED_TENURES)), + rent=draw(st.one_of(above(lha_rate, 15_000), money(30_000))), + lha_rate=lha_rate, + # A working-age adult in their own benefit unit, whose presence + # brings a non-dependant deduction. + non_dependant_earnings=draw(st.one_of(st.none(), money(40_000))), + ) + + +# The single pensioner from the issue (State Pension 16,000, above the +# minimum guarantee; rent 30,000 against an LHA of 9,467.64), alone and with +# a working-age non-dependant, and a pension-age couple in the same flat. +# Each has rent above the LHA and income above the applicable amount, where +# the two orders differ. +END_TO_END_EXAMPLE = [ + dict( + ages=[70], + state_pension=16_000.0, + private_pension=0.0, + tenure="RENT_PRIVATELY", + rent=30_000.0, + lha_rate=9_467.64, + non_dependant_earnings=non_dependant_earnings, + ) + for non_dependant_earnings in [None, 20_000.0] +] + [ + dict( + ages=[70, 68], + state_pension=12_000.0, + private_pension=0.0, + tenure="RENT_PRIVATELY", + rent=30_000.0, + lha_rate=9_467.64, + non_dependant_earnings=None, + ) +] + + +def end_to_end(population): + """Housing Benefit and its inputs, all computed by the model.""" + people, benunits, households = {}, {}, {} + for i, unit in enumerate(population): + names = [] + for j, age in enumerate(unit["ages"]): + name = f"p{i}_{j}" + people[name] = { + "age": {YEAR: age}, + "state_pension_reported": {YEAR: unit["state_pension"]}, + "private_pension_income": {YEAR: unit["private_pension"]}, + } + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "BRMA_LHA_rate": {YEAR: unit["lha_rate"]}, + } + household_members = list(names) + if unit["non_dependant_earnings"] is not None: + # Younger than the pensioners, so never the household head who + # is liable for the rent. + name = f"n{i}" + people[name] = { + "age": {YEAR: 30}, + "employment_income": {YEAR: unit["non_dependant_earnings"]}, + } + benunits[f"n{i}"] = {"members": [name]} + household_members.append(name) + households[f"h{i}"] = { + "members": household_members, + "rent": {YEAR: unit["rent"]}, + "tenure_type": {YEAR: unit["tenure"]}, + } + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + values = { + variable: np.asarray(sim.calculate(variable, YEAR), dtype=float) + for variable in [ + "housing_benefit", + "housing_benefit_eligible", + "benunit_rent", + "BRMA_LHA_rate", + "LHA_cap", + "LHA_eligible", + "housing_benefit_applicable_amount", + "housing_benefit_applicable_income", + "housing_benefit_non_dep_deductions", + ] + } + rate = sim.tax_benefit_system.parameters( + YEAR + ).gov.dwp.housing_benefit.means_test.withdrawal_rate + values["lha"] = values["LHA_eligible"].astype(bool) + values["taper"] = rate * np.maximum( + 0, + values["housing_benefit_applicable_income"] + - values["housing_benefit_applicable_amount"], + ) + return values + + +@settings(PROPERTY_SETTINGS, max_examples=10) +@given(st.lists(pension_age_households(), min_size=5, max_size=20)) +@example(END_TO_END_EXAMPLE) +def test_end_to_end_pension_age_housing_benefit(population): + # The applicable income and amount and the non-dependant deductions are + # the model's own, so this holds whatever the earnings disregard or the + # Guarantee Credit passport do to them. Pensioners are exempt from the + # benefit cap, and household calculations claim every entitled benefit. + values = end_to_end(population) + rent = values["benunit_rent"] + lha = values["lha"] + eligible_rent = np.where(lha, values["LHA_cap"], rent) + expected = np.maximum( + 0, + eligible_rent - values["housing_benefit_non_dep_deductions"] - values["taper"], + ) + hb = values["housing_benefit"] + eligible = values["housing_benefit_eligible"].astype(bool) + assert np.allclose(hb[eligible], expected[eligible], atol=TOLERANCE), population + assert np.all(hb[~eligible] == 0) + lha_limit = np.minimum(rent, values["BRMA_LHA_rate"]) + assert np.all(hb[lha] <= lha_limit[lha] + TOLERANCE), population diff --git a/policyengine_uk/variables/gov/dwp/LHA_cap.py b/policyengine_uk/variables/gov/dwp/LHA_cap.py index d7139422ce..090fcd23fb 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_cap.py +++ b/policyengine_uk/variables/gov/dwp/LHA_cap.py @@ -9,8 +9,13 @@ class LHA_cap(Variable): value_type = float entity = BenUnit - label = "Applicable amount for LHA" - documentation = "Applicable amount for Local Housing Allowance" + label = "Maximum rent (LHA)" + documentation = ( + "The Housing Benefit maximum rent (LHA): the Local Housing Allowance " + "rate for the benefit unit, or its rent where that is lower (SI " + "2006/213 and 2006/214 reg 13D(5): where the LHA exceeds the cap " + "rent, the maximum rent (LHA) is the cap rent)." + ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py index f44c35d135..b8771d4d6b 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/entitlement/housing_benefit_entitlement.py @@ -3,21 +3,60 @@ class housing_benefit_entitlement(Variable): label = "Housing Benefit entitlement" + documentation = ( + "The appropriate maximum Housing Benefit (the eligible rent less " + "non-dependant deductions), less 65% of the excess of applicable " + "income over the applicable amount. Where the Local Housing Allowance " + "applies, the eligible rent is the maximum rent (LHA), which is the " + "lower of the LHA rate and the rent, so the cap applies before the " + "taper." + ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/130", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/12D", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/70", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/71", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/12D", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/13D", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/50", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/51", + "https://www.legislation.gov.uk/ukpga/1992/7/section/129", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13C", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/14D", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/68", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/69", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13C", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/14D", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/48", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/49", + ) def formula(benunit, period, parameters): + # Where a maximum rent (LHA) is determined, it is the eligible rent + # (SI 2006/213 and SI 2006/214 reg 12D(2)(a); NI SR 2006/405 and + # SR 2006/406 reg 13C(2)(a)). LHA_cap is the LHA rate or, where the + # rent is lower, the rent (the cap rent: reg 13D(5); NI reg 14D(5)). rent = benunit("benunit_rent", period) + lha_eligible = benunit("LHA_eligible", period) + eligible_rent = where(lha_eligible, benunit("LHA_cap", period), rent) + # The appropriate maximum Housing Benefit is the eligible rent less + # non-dependant deductions (SI 2006/213 reg 70, SI 2006/214 reg 50; + # NI regs 68 and 48). + non_dep_deductions = benunit("housing_benefit_non_dep_deductions", period) + maximum_housing_benefit = eligible_rent - non_dep_deductions + # 65% of the excess of income over the applicable amount is deducted + # from the maximum (SSCBA 1992 s.130(3)(b), SI 2006/213 reg 71, + # SI 2006/214 reg 51; NI: SSCB(NI)A 1992 s.129(3)(b), regs 69(b) and + # 49(b)). applicable_amount = benunit("housing_benefit_applicable_amount", period) income = benunit("housing_benefit_applicable_income", period) withdrawal_rate = parameters( period ).gov.dwp.housing_benefit.means_test.withdrawal_rate - reduced_income = max_(0, income - applicable_amount) - final_amount = max_(0, rent - reduced_income * withdrawal_rate) - capped_final_amount = min_(final_amount, benunit("LHA_cap", period)) - lha_eligible = benunit("LHA_eligible", period.this_year) - amount = where(lha_eligible, capped_final_amount, final_amount) - return max_(0, amount - benunit("housing_benefit_non_dep_deductions", period)) + taper = max_(0, income - applicable_amount) * withdrawal_rate + return max_(0, maximum_housing_benefit - taper)