diff --git a/changelog.d/council-tax-reduction-merton.md b/changelog.d/council-tax-reduction-merton.md new file mode 100644 index 000000000..e501e94f6 --- /dev/null +++ b/changelog.d/council-tax-reduction-merton.md @@ -0,0 +1 @@ +Add Merton working-age Council Tax Reduction. diff --git a/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..f3d18eff5 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,10 @@ +description: Maximum share of eligible Council Tax liability covered for working-age households under the Merton Council Tax Support scheme. +values: + 2026-04-01: 1 +metadata: + unit: /1 + period: year + label: Merton Council Tax Support maximum support rate + reference: + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2026 + href: https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..e4822aa0d --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit for working-age households under the Merton Council Tax Support scheme. +values: + 2026-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: Merton Council Tax Support capital limit + reference: + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2026 + href: https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..e9094c1da --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate for working-age households under the Merton Council Tax Support scheme. +values: + 2026-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Merton Council Tax Support withdrawal rate + reference: + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2026 + href: https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..a7821e390 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,27 @@ +description: Weekly non-dependant deduction schedule under the Merton Council Tax Support scheme. +brackets: + - threshold: + 2026-04-01: 0 + amount: + 2026-04-01: 5.20 + - threshold: + 2026-04-01: 279 + amount: + 2026-04-01: 10.60 + - threshold: + 2026-04-01: 485 + amount: + 2026-04-01: 13.30 + - threshold: + 2026-04-01: 605 + amount: + 2026-04-01: 15.95 +metadata: + amount_unit: currency-GBP + period: week + threshold_unit: currency-GBP + type: single_amount + label: Merton Council Tax Support non-dependant deduction schedule + reference: + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2026 + href: https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/non_dep_deduction/remunerative_work_hours.yaml b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/non_dep_deduction/remunerative_work_hours.yaml new file mode 100644 index 000000000..5062dde40 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/non_dep_deduction/remunerative_work_hours.yaml @@ -0,0 +1,10 @@ +description: Minimum weekly hours for remunerative work under the Merton Council Tax Support non-dependant deduction rules. +values: + 2026-04-01: 16 +metadata: + unit: hour + period: week + label: Merton Council Tax Support remunerative work hours + reference: + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2026 + href: https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml index 7c3c091aa..11f0cd3c7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml @@ -93,3 +93,161 @@ council_tax_reduction_scheme_supported: true simulated_council_tax_reduction_benunit: 1_800 council_tax_reduction: 1_800 + +- name: Merton working-age claimant can receive full support + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + households: + household: + members: [claimant] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_scheme_supported: true + simulated_council_tax_reduction_benunit: 1_800 + council_tax_reduction: 1_800 + council_tax_less_benefit: 0 + +- name: Merton working-age claimant above capital limit gets no local support + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 35 + council_tax_benefit_reported: 500 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + households: + household: + members: [claimant] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 16_001 + output: + council_tax_reduction_scheme_supported: true + simulated_council_tax_reduction_benunit: 0 + council_tax_benefit: 0 + council_tax_reduction: 0 + +- name: Merton applies its 2026 local non-dependant deduction schedule + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + employment_income: 15_000 + weekly_hours: 16 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + non_dep_benunit: + members: [non_dep] + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_scheme_supported: true + council_tax_reduction: 1_248.80 + +- name: Merton exempts UC non-dependants with no earned income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + weekly_hours: 16 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: false + claims_all_entitled_benefits: true + non_dep_benunit: + members: [non_dep] + would_claim_uc: true + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction: 1_800 + +- name: Merton UC claimant uses UC maximum amount and DWP income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: true + claims_all_entitled_benefits: true + uc_maximum_amount: 5_000 + uc_income_reduction: 2_500 + uc_earned_income: 6_000 + uc_unearned_income: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + universal_credit: 2_500 + council_tax_reduction: 1_100 + +- name: Merton UC claimant uses UC-reported capital + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + benunits: + claimant_benunit: + members: [claimant] + would_claim_uc: true + claims_all_entitled_benefits: true + uc_reported_capital: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 20_000 + output: + uc_assessable_capital: 0 + council_tax_reduction: 1_800 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py new file mode 100644 index 000000000..33e0fff1c --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -0,0 +1,142 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + + +def is_full_time_student_non_dep(person, period): + return (person("current_education", period) != EducationType.NOT_IN_EDUCATION) | ( + person("in_HE", period) + ) + + +def legacy_council_tax_reduction( + benunit, + period, + ctr, + working_age, + non_dep_deductions_variable, +): + is_household_head_benunit = benunit("benunit_contains_household_head", period) + would_claim = benunit("would_claim_council_tax_reduction", period) + applicable_amount = benunit("council_tax_reduction_applicable_amount", period) + applicable_income = benunit("council_tax_reduction_applicable_income", period) + universal_credit = benunit("universal_credit", period) + has_uc_award = universal_credit > 0 + uc_applicable_amount = benunit("uc_maximum_amount", period) + uc_applicable_income = ( + benunit("uc_earned_income", period) + + benunit("uc_unearned_income", period) + + universal_credit + ) + applicable_amount = where(has_uc_award, uc_applicable_amount, applicable_amount) + applicable_income = where(has_uc_award, uc_applicable_income, applicable_income) + relevant_income_based_benefit = benunit( + "council_tax_reduction_relevant_income_based_benefit", + period, + ) + liability = benunit.household( + "council_tax_reduction_maximum_eligible_liability", period + ) + non_dep_deductions = benunit(non_dep_deductions_variable, period) + excess_income = max_(0, applicable_income - applicable_amount) + excess_income = where( + relevant_income_based_benefit & ~has_uc_award, 0, excess_income + ) + preliminary_award = max_( + 0, + liability * ctr.maximum_support_rate + - excess_income * ctr.means_test.withdrawal_rate + - non_dep_deductions, + ) + capital = where( + has_uc_award, + benunit("uc_assessable_capital", period), + benunit.household("savings", period), + ) + capital_eligible = capital <= ctr.means_test.capital_limit + return ( + working_age + * is_household_head_benunit + * would_claim + * capital_eligible + * preliminary_award + ) + + +def local_non_dep_deductions( + benunit, + period, + individual_deduction_variable, + one_deduction_for_uc_couples=True, +): + deductions = benunit.members(individual_deduction_variable, period) + deduction_for_benunit = benunit.max(deductions) + if not one_deduction_for_uc_couples: + has_uc = benunit("universal_credit", period) > 0 + deduction_for_benunit = where( + has_uc, + benunit.sum(deductions), + deduction_for_benunit, + ) + is_benunit_head = benunit.members("is_benunit_head", period) + deductions_to_count = is_benunit_head * benunit.project(deduction_for_benunit) + deductions_in_household = benunit.max( + benunit.members.household.sum(deductions_to_count) + ) + return deductions_in_household - deduction_for_benunit + + +def normal_gross_income_non_dep_deduction( + person, + period, + ctr, + working_age, + exempt_income_based_benefits=True, + exempt_uc_no_earned_income=True, +): + gross_income_components = [ + "employment_income", + "self_employment_income", + "property_income", + "private_pension_income", + "savings_interest_income", + "dividend_income", + "state_pension", + ] + earned_income_components = [ + "employment_income", + "self_employment_income", + ] + gross_income = add(person, period, gross_income_components) + earned_income = add(person, period, earned_income_components) + weekly_benunit_gross_income = person.benunit.sum(gross_income) / WEEKS_IN_YEAR + weekly_benunit_earned_income = person.benunit.sum(earned_income) / WEEKS_IN_YEAR + benunit_weekly_hours = person.benunit.max(person("weekly_hours", period)) + in_remunerative_work = ( + benunit_weekly_hours >= ctr.non_dep_deduction.remunerative_work_hours + ) + weekly_deduction = where( + in_remunerative_work, + ctr.non_dep_deduction.amount.calc(weekly_benunit_gross_income), + ctr.non_dep_deduction.amount.calc(0), + ) + claimant_exempt = person.household( + "council_tax_reduction_household_has_non_dep_exemption", period + ) + full_time_student = is_full_time_student_non_dep(person, period) + income_based_benefit = ( + (person.benunit("income_support", period) > 0) + | (person.benunit("jsa_income", period) > 0) + | (person.benunit("esa_income", period) > 0) + | (person.benunit("pension_credit", period) > 0) + ) + has_uc = person.benunit("universal_credit", period) > 0 + no_earned_income = weekly_benunit_earned_income <= 0 + exempt = ( + claimant_exempt + | full_time_student + | (exempt_income_based_benefits & income_based_benefit) + | (exempt_uc_no_earned_income & has_uc & no_earned_income) + ) + return working_age * where(exempt, 0.0, weekly_deduction * WEEKS_IN_YEAR) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py index bbb774f34..00f99f3b1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.household.demographic.country import Country +from policyengine_uk.variables.household.demographic.locations import LocalAuthority def is_england_pensioner_scheme(country, has_pensioner): @@ -14,9 +15,18 @@ def is_wales_scheme(country): return country == Country.WALES -def is_supported_scheme(country, has_pensioner): +def is_merton(local_authority): + return local_authority == LocalAuthority.MERTON + + +def is_merton_working_age(local_authority, country, has_pensioner): + return (country == Country.ENGLAND) & ~has_pensioner & is_merton(local_authority) + + +def is_supported_scheme(country, has_pensioner, local_authority): return ( is_england_pensioner_scheme(country, has_pensioner) | is_scotland_scheme(country) | is_wales_scheme(country) + | is_merton_working_age(local_authority, country, has_pensioner) ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py new file mode 100644 index 000000000..759aa5d7c --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_household_has_non_dep_exemption(Variable): + value_type = bool + entity = Household + label = "CTR household has a non-dependant deduction exemption" + definition_period = YEAR + + def formula(household, period, parameters): + person = household.members + claimant_benunit = person.benunit("benunit_contains_household_head", period) + claimant_or_partner = claimant_benunit & person("is_adult", period) + is_blind = person("is_blind", period) & claimant_or_partner + attendance_allowance = ( + person("attendance_allowance", period) > 0 + ) & claimant_or_partner + pip_daily_living = (person("pip_dl", period) > 0) & claimant_or_partner + dla_care = (person("dla_sc", period) > 0) & claimant_or_partner + armed_forces_independence_payment = ( + person("armed_forces_independence_payment", period) > 0 + ) & claimant_or_partner + return household.any( + is_blind + | attendance_allowance + | pip_daily_living + | dla_care + | armed_forces_independence_payment + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py new file mode 100644 index 000000000..74df5864f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py @@ -0,0 +1,11 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_relevant_income_based_benefit(Variable): + value_type = bool + entity = BenUnit + label = "CTR claimant has an income-based passporting benefit" + definition_period = YEAR + + def formula(benunit, period, parameters): + return add(benunit, period, ["income_support", "jsa_income", "esa_income"]) > 0 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py index 3dcec3845..9fb0699fa 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py @@ -15,4 +15,5 @@ def formula(household, period, parameters): has_pensioner = household( "council_tax_reduction_household_has_pensioner", period ) - return is_supported_scheme(country, has_pensioner) + local_authority = household("local_authority", period) + return is_supported_scheme(country, has_pensioner, local_authority) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index 0e172f8c9..4a025b43e 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -5,6 +5,8 @@ is_wales_scheme, ) +LOCAL_COUNCIL_TAX_REDUCTION_VARIABLES = ["merton_council_tax_reduction"] + class simulated_council_tax_reduction_benunit(Variable): value_type = float @@ -75,10 +77,12 @@ def formula(benunit, period, parameters): - non_dep_deductions, ) capital_eligible = benunit.household("savings", period) <= capital_limit - return ( + national_ctr = ( national_scheme * is_household_head_benunit * would_claim * capital_eligible * preliminary_award ) + local_ctr = add(benunit, period, LOCAL_COUNCIL_TAX_REDUCTION_VARIABLES) + return national_ctr + local_ctr diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py new file mode 100644 index 000000000..03012a5f8 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + legacy_council_tax_reduction, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_merton_working_age, +) + + +class merton_council_tax_reduction(Variable): + value_type = float + entity = BenUnit + label = "Merton Council Tax Reduction" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + ctr = parameters(period).gov.local_authorities.merton.council_tax_reduction + local_authority = benunit.household("local_authority", period) + country = benunit.household("country", period) + has_pensioner = benunit.household( + "council_tax_reduction_household_has_pensioner", period + ) + working_age = is_merton_working_age(local_authority, country, has_pensioner) + return legacy_council_tax_reduction( + benunit, + period, + ctr, + working_age, + "merton_council_tax_reduction_non_dep_deductions", + ) diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py new file mode 100644 index 000000000..2907e2a70 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_individual_non_dep_deduction.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + normal_gross_income_non_dep_deduction, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_merton_working_age, +) + + +class merton_council_tax_reduction_individual_non_dep_deduction(Variable): + value_type = float + entity = Person + label = "Merton CTR individual non-dependent deduction" + definition_period = YEAR + unit = GBP + defined_for = "council_tax_reduction_individual_non_dep_deduction_eligible" + + def formula(person, period, parameters): + ctr = parameters(period).gov.local_authorities.merton.council_tax_reduction + household = person.household + working_age = is_merton_working_age( + household("local_authority", period), + household("country", period), + household("council_tax_reduction_household_has_pensioner", period), + ) + return normal_gross_income_non_dep_deduction( + person, + period, + ctr, + working_age, + exempt_uc_no_earned_income=True, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_non_dep_deductions.py new file mode 100644 index 000000000..5b72f7b5f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/merton/council_tax_reduction/merton_council_tax_reduction_non_dep_deductions.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + local_non_dep_deductions, +) + + +class merton_council_tax_reduction_non_dep_deductions(Variable): + value_type = float + entity = BenUnit + label = "Merton CTR non-dependent deductions" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + return local_non_dep_deductions( + benunit, + period, + "merton_council_tax_reduction_individual_non_dep_deduction", + one_deduction_for_uc_couples=False, + )