diff --git a/changelog.d/council-tax-reduction.added.md b/changelog.d/council-tax-reduction.added.md new file mode 100644 index 000000000..0e115725c --- /dev/null +++ b/changelog.d/council-tax-reduction.added.md @@ -0,0 +1 @@ +Added an initial `council_tax_reduction` framework covering Wales, Scotland, English pension-age households, and explicit working-age overrides for Stroud and Dudley, and netted Council Tax Reduction against household council tax in downstream income measures. diff --git a/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..fbb3c5252 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,12 @@ +description: Maximum share of eligible Council Tax liability covered for working-age households under the Chesterfield Council Tax Support scheme. +values: + 2025-04-01: 0.915 +metadata: + unit: /1 + period: year + label: Chesterfield Council Tax Support maximum support rate + reference: + - title: Council Tax Support + href: https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support + - title: Chesterfield Borough Council Council Tax Support Scheme + href: https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..91a43f918 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,12 @@ +description: Capital limit under the Chesterfield Council Tax Support means test. +values: + 2025-04-01: 16000 +metadata: + unit: currency-GBP + period: year + label: Chesterfield Council Tax Support capital limit + reference: + - title: Council Tax Support + href: https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support + - title: Chesterfield Borough Council Council Tax Support Scheme + href: https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..cb2e2bb4d --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,12 @@ +description: Withdrawal rate under the Chesterfield Council Tax Support means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Chesterfield Council Tax Support withdrawal rate + reference: + - title: Council Tax Support + href: https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support + - title: Chesterfield Borough Council Council Tax Support Scheme + href: https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..f5b4851a9 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,29 @@ +description: Weekly non-dependant deduction schedule under the Chesterfield Council Tax Support scheme. +brackets: + - threshold: + 2025-04-01: 0 + amount: + 2025-04-01: 4 + - threshold: + 2025-04-01: 266 + amount: + 2025-04-01: 8.1 + - threshold: + 2025-04-01: 463 + amount: + 2025-04-01: 10.2 + - threshold: + 2025-04-01: 577 + amount: + 2025-04-01: 12.2 +metadata: + amount_unit: currency-GBP + period: week + threshold_unit: currency-GBP + type: single_amount + label: Chesterfield Council Tax Support non-dependant deduction schedule + reference: + - title: Council Tax Support + href: https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support + - title: Chesterfield Borough Council Council Tax Support Scheme + href: https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_liability/cap_band_ratio.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_liability/cap_band_ratio.yaml new file mode 100644 index 000000000..ce09e72d6 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_liability/cap_band_ratio.yaml @@ -0,0 +1,12 @@ +description: Maximum council tax band ratio used to cap eligible liability for working-age households under the Dudley Council Tax Reduction scheme. +values: + 2025-04-01: 0.888888889 +metadata: + unit: /1 + period: year + label: Dudley Council Tax Reduction capped band ratio + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..b3df2c118 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,12 @@ +description: Maximum share of eligible Council Tax liability covered for working-age households under the Dudley Council Tax Reduction scheme. +values: + 2025-04-01: 0.4 +metadata: + unit: /1 + period: year + label: Dudley Council Tax Reduction maximum support rate + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..208f93c6e --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,12 @@ +description: Capital limit under the Dudley Council Tax Reduction means test. +values: + 2025-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: Dudley Council Tax Reduction capital limit + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..1e74d6b2b --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,12 @@ +description: Withdrawal rate under the Dudley Council Tax Reduction means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Dudley Council Tax Reduction withdrawal rate + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..959982c24 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,12 @@ +description: Weekly non-dependant deduction under the Dudley Council Tax Reduction scheme. +values: + 2025-04-01: 5 +metadata: + unit: currency-GBP + period: week + label: Dudley Council Tax Reduction non-dependant deduction amount + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..75be49823 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,12 @@ +description: Maximum share of eligible Council Tax liability covered for working-age households under the East Hertfordshire Council Tax Reduction scheme. +values: + 2025-04-01: 0.915 +metadata: + unit: /1 + period: year + label: East Hertfordshire Council Tax Reduction maximum support rate + reference: + - title: Council Tax Support + href: https://www.eastherts.gov.uk/benefits-and-financial-support/council-tax-support + - title: East Hertfordshire District Council Council Tax Reduction Scheme 2025/26 + href: https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..a55b3f569 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the East Hertfordshire Council Tax Reduction means test. +values: + 2025-04-01: 16000 +metadata: + unit: currency-GBP + period: year + label: East Hertfordshire Council Tax Reduction capital limit + reference: + - title: East Hertfordshire District Council Council Tax Reduction Scheme 2025/26 + href: https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..6a98ffe1c --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the East Hertfordshire Council Tax Reduction means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: East Hertfordshire Council Tax Reduction withdrawal rate + reference: + - title: East Hertfordshire District Council Council Tax Reduction Scheme 2025/26 + href: https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..7ea9e233f --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,27 @@ +description: Weekly non-dependant deduction schedule under the East Hertfordshire Council Tax Reduction scheme. +brackets: + - threshold: + 2025-04-01: 0 + amount: + 2025-04-01: 5 + - threshold: + 2025-04-01: 266 + amount: + 2025-04-01: 10.2 + - threshold: + 2025-04-01: 463 + amount: + 2025-04-01: 12.8 + - threshold: + 2025-04-01: 577 + amount: + 2025-04-01: 15.35 +metadata: + amount_unit: currency-GBP + period: week + threshold_unit: currency-GBP + type: single_amount + label: East Hertfordshire Council Tax Reduction non-dependant deduction schedule + reference: + - title: East Hertfordshire District Council Council Tax Reduction Scheme 2025/26 + href: https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax/band_ratio.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax/band_ratio.yaml new file mode 100644 index 000000000..ce9a12756 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax/band_ratio.yaml @@ -0,0 +1,38 @@ +A: + values: + 0001-01-01: 0.666666667 +B: + values: + 0001-01-01: 0.777777778 +C: + values: + 0001-01-01: 0.888888889 +D: + values: + 0001-01-01: 1 +E: + values: + 0001-01-01: 1.222222222 +F: + values: + 0001-01-01: 1.444444444 +G: + values: + 0001-01-01: 1.666666667 +H: + values: + 0001-01-01: 2 +I: + values: + 0001-01-01: 2.333333333 +description: England Council Tax band ratios relative to Band D. +metadata: + unit: /1 + period: year + label: England Council Tax band ratios + propagate_metadata_to_children: true + reference: + - title: Council Tax bands + href: https://www.gov.uk/council-tax-bands + - title: The Local Government Finance Act 1992 (Consequential Amendments) Regulations 2012 explanatory memorandum + href: https://www.legislation.gov.uk/uksi/2012/2914/pdfs/uksiem_20122914_en.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml new file mode 100644 index 000000000..bb0c52367 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml @@ -0,0 +1,10 @@ +description: Maximum share of eligible Council Tax liability covered for pensioner households under the England Council Tax Reduction scheme. +values: + 2013-04-01: 1 +metadata: + unit: /1 + period: year + label: England Council Tax Reduction pensioner maximum support rate + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 + href: https://www.legislation.gov.uk/uksi/2012/2885/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml new file mode 100644 index 000000000..c6543dad0 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit for pensioner households under the England Council Tax Reduction scheme. +values: + 2013-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: England Council Tax Reduction pensioner capital limit + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 + href: https://www.legislation.gov.uk/uksi/2012/2885/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..c8ca6101a --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate for pensioner households under the England Council Tax Reduction scheme. +values: + 2013-04-01: 0.2 +metadata: + unit: /1 + period: year + label: England Council Tax Reduction pensioner withdrawal rate + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 + href: https://www.legislation.gov.uk/uksi/2012/2885/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..d2042b610 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,10 @@ +description: Maximum share of eligible Council Tax liability covered under the Scotland Council Tax Reduction scheme. +values: + 2013-04-01: 1 +metadata: + unit: /1 + period: year + label: Scotland Council Tax Reduction maximum support rate + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2012 + href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..8a659bb8a --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the Scotland Council Tax Reduction scheme. +values: + 2013-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: Scotland Council Tax Reduction capital limit + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2012 + href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..21e164870 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the Scotland Council Tax Reduction scheme. +values: + 2013-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Scotland Council Tax Reduction withdrawal rate + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2012 + href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..5d4624f49 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stevenage/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 Stevenage Council Tax Reduction scheme. +values: + 2025-04-01: 0.915 +metadata: + unit: /1 + period: year + label: Stevenage Council Tax Reduction maximum support rate + reference: + - title: Stevenage Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..a50412072 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the Stevenage Council Tax Reduction means test. +values: + 2025-04-01: 16000 +metadata: + unit: currency-GBP + period: year + label: Stevenage Council Tax Reduction capital limit + reference: + - title: Stevenage Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..a44940a93 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the Stevenage Council Tax Reduction means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Stevenage Council Tax Reduction withdrawal rate + reference: + - title: Stevenage Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..448c37276 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,27 @@ +description: Weekly non-dependant deduction schedule under the Stevenage Council Tax Reduction scheme. +brackets: + - threshold: + 2025-04-01: 0 + amount: + 2025-04-01: 5 + - threshold: + 2025-04-01: 266 + amount: + 2025-04-01: 10.2 + - threshold: + 2025-04-01: 463 + amount: + 2025-04-01: 12.8 + - threshold: + 2025-04-01: 577 + amount: + 2025-04-01: 15.35 +metadata: + amount_unit: currency-GBP + period: week + threshold_unit: currency-GBP + type: single_amount + label: Stevenage Council Tax Reduction non-dependant deduction schedule + reference: + - title: Stevenage Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..d2955e2f0 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stroud/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 Stroud Council Tax Reduction scheme. +values: + 2025-04-01: 1 +metadata: + unit: /1 + period: year + label: Stroud Council Tax Reduction maximum support rate + reference: + - title: Council Tax Support Scheme for Pension Credit Age persons and Working Age persons 2025/26 + href: https://www.stroud.gov.uk/media/bygb4zeg/summary-of-council-tax-support-scheme-202526.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..e5d35223d --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit for working-age households under the Stroud Council Tax Reduction scheme. +values: + 2025-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: Stroud Council Tax Reduction capital limit + reference: + - title: Council Tax Support Scheme for Pension Credit Age persons and Working Age persons 2025/26 + href: https://www.stroud.gov.uk/media/bygb4zeg/summary-of-council-tax-support-scheme-202526.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..7e92c4439 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate for working-age households under the Stroud Council Tax Reduction scheme. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Stroud Council Tax Reduction withdrawal rate + reference: + - title: Council Tax Support Scheme for Pension Credit Age persons and Working Age persons 2025/26 + href: https://www.stroud.gov.uk/media/bygb4zeg/summary-of-council-tax-support-scheme-202526.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..377bc0caf --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,10 @@ +description: Maximum share of eligible Council Tax liability covered under the Wales Council Tax Reduction scheme. +values: + 2013-04-01: 1 +metadata: + unit: /1 + period: year + label: Wales Council Tax Reduction maximum support rate + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..c3e4c1f7c --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the Wales Council Tax Reduction scheme. +values: + 2013-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: Wales Council Tax Reduction capital limit + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..620e0f32b --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the Wales Council Tax Reduction scheme. +values: + 2013-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Wales Council Tax Reduction withdrawal rate + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_liability/cap_band_ratio.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_liability/cap_band_ratio.yaml new file mode 100644 index 000000000..8e623029d --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_liability/cap_band_ratio.yaml @@ -0,0 +1,10 @@ +description: Maximum Council Tax band ratio used to cap eligible liability for Class E claimants under the Warrington Council Tax Reduction scheme. +values: + 2025-04-01: 0.666666667 +metadata: + unit: /1 + period: year + label: Warrington Council Tax Reduction capped band ratio + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..cebd242ca --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,19 @@ +class_d: + band_a: + values: + 2025-04-01: 1 + other_bands: + values: + 2025-04-01: 0.915 +class_e: + values: + 2025-04-01: 1 +description: Maximum Council Tax Reduction support rates for working-age households under the Warrington Council Tax Reduction scheme. +metadata: + unit: /1 + period: year + label: Warrington Council Tax Reduction maximum support rates + propagate_metadata_to_children: true + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..e7392a9d7 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the Warrington Council Tax Reduction means test. +values: + 2025-04-01: 16000 +metadata: + unit: currency-GBP + period: year + label: Warrington Council Tax Reduction capital limit + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..847e60561 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the Warrington Council Tax Reduction means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Warrington Council Tax Reduction withdrawal rate + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..7b170a34e --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,27 @@ +description: Weekly non-dependant deduction schedule under the Warrington Council Tax Reduction scheme. +brackets: + - threshold: + 2025-04-01: 0 + amount: + 2025-04-01: 5 + - threshold: + 2025-04-01: 266 + amount: + 2025-04-01: 10.2 + - threshold: + 2025-04-01: 463 + amount: + 2025-04-01: 12.8 + - threshold: + 2025-04-01: 577 + amount: + 2025-04-01: 15.35 +metadata: + amount_unit: currency-GBP + period: week + threshold_unit: currency-GBP + type: single_amount + label: Warrington Council Tax Reduction non-dependant deduction schedule + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index e6ecca20a..68014013c 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -371,6 +371,17 @@ programs: variable: council_tax verified_start_year: 2022 + - id: council_tax_reduction + name: Council Tax Reduction + full_name: Council Tax Reduction + category: Benefits + agency: Local + status: partial + coverage: UK + variable: council_tax_reduction + verified_years: "2025-2025" + notes: Wales, Scotland, and English pension-age households use national or default-style rules; working-age England currently has explicit overrides for Stroud and Dudley, with unsupported authorities falling back to reported baseline data. + # --- Treasury programs --- - id: cost_of_living_support name: Cost of Living Support diff --git a/policyengine_uk/tests/microsimulation/reforms_config.yaml b/policyengine_uk/tests/microsimulation/reforms_config.yaml index 3fd787ee8..784c08543 100644 --- a/policyengine_uk/tests/microsimulation/reforms_config.yaml +++ b/policyengine_uk/tests/microsimulation/reforms_config.yaml @@ -16,8 +16,8 @@ reforms: parameters: gov.hmrc.child_benefit.amount.additional: 25 - name: Reduce Universal Credit taper rate to 20% - expected_impact: -41.9 - tolerance: 1.5 + expected_impact: -44.0 + tolerance: 2.0 parameters: gov.dwp.universal_credit.means_test.reduction_rate: 0.2 - name: Raise Class 1 main employee NICs rate to 10% 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 new file mode 100644 index 000000000..93ddadc1f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml @@ -0,0 +1,767 @@ +- name: Stroud working-age claimant keeps full support + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: STROUD + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1800 + council_tax_less_benefit: 0 + +- name: Stroud entitledto spot check returns full support on the billed liability + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: STROUD + council_tax_band: D + council_tax: 1866.47 + savings: 0 + output: + council_tax_reduction: 1866.47 + council_tax_less_benefit: 0 + +- name: Stroud claimant on income-based JSA keeps full support on the billed liability + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: STROUD + council_tax_band: D + council_tax: 1777.08 + savings: 0 + output: + council_tax_reduction: 1777.08 + council_tax_less_benefit: 0 + +- name: Stroud lone parent on income-based JSA keeps full support on the billed liability + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + child: + age: 5 + benunits: + benunit: + members: [claimant, child] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: false + is_couple: false + is_lone_parent: true + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant, child] + country: ENGLAND + local_authority: STROUD + council_tax_band: D + council_tax: 1777.08 + savings: 0 + output: + council_tax_reduction: 1777.08 + council_tax_less_benefit: 0 + +- name: Dudley working-age claimant faces 60 percent minimum payment and band C cap + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_maximum_eligible_liability: 1600 + council_tax_reduction: 640 + council_tax_less_benefit: 1160 + +- name: Dudley charges a flat weekly non-dependant deduction + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 380 + +- name: Dudley band C maximum award follows the published 2025-26 minimum payment + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: C + council_tax: 1359.05 + savings: 0 + output: + council_tax_reduction: 543.62 + council_tax_less_benefit: 815.43 + +- name: Dudley single claimant on income-based JSA keeps the full published 40 percent maximum award + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: C + council_tax: 1359.05 + savings: 0 + output: + council_tax_reduction: 543.62 + council_tax_less_benefit: 815.43 + +- name: Dudley waives the flat non-dependant deduction for PIP daily living claimants + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + pip_dl_category: STANDARD + non_dep: + age: 25 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 640 + +- name: East Hertfordshire working-age claimant pays the 8.5 percent minimum contribution + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: EAST_HERTFORDSHIRE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + +- name: East Hertfordshire lone parent on income-based JSA keeps the full class D award + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 1 + child: + age: 5 + benunits: + benunit: + members: [claimant, child] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: false + is_couple: false + is_lone_parent: true + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant, child] + country: ENGLAND + local_authority: EAST_HERTFORDSHIRE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + +- name: Stevenage working-age claimant pays the 8.5 percent minimum contribution + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: STEVENAGE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + +- name: Stevenage applies its local non-dependant deduction schedule + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + employment_income: 15_000 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: STEVENAGE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1116.6 + council_tax_less_benefit: 683.4 + +- name: Chesterfield working-age claimant pays the 8.5 percent minimum contribution + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: CHESTERFIELD + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + +- name: Chesterfield applies its local non-dependant deduction schedule + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + employment_income: 15_000 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: CHESTERFIELD + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1225.8 + council_tax_less_benefit: 574.2 + +- name: East Hertfordshire applies its local non-dependant deduction schedule + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + employment_income: 15_000 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: EAST_HERTFORDSHIRE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1116.6 + council_tax_less_benefit: 683.4 + +- name: Warrington working-age claimant above Band A gets 91.5 percent maximum support + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WARRINGTON + council_tax_band: C + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + +- name: Warrington single claimant on income-based JSA stays in class D and keeps 91.5 percent support + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WARRINGTON + council_tax_band: C + council_tax: 1520.88 + savings: 0 + output: + council_tax_reduction: 1391.61 + council_tax_less_benefit: 129.27 + +- name: Warrington lone parent on income-based JSA stays in class D and keeps 91.5 percent support + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + child: + age: 5 + benunits: + benunit: + members: [claimant, child] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: false + is_couple: false + is_lone_parent: true + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant, child] + country: ENGLAND + local_authority: WARRINGTON + council_tax_band: C + council_tax: 1520.88 + savings: 0 + output: + council_tax_reduction: 1391.61 + council_tax_less_benefit: 129.27 + +- name: Warrington class E working-age claimant above Band A is capped to Band A liability + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + employment_income: 8_000 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WARRINGTON + council_tax_band: C + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_maximum_eligible_liability: 1350 + council_tax_reduction: 707.22 + council_tax_less_benefit: 1092.78 + +- name: Welsh household uses the national Welsh CTR scheme + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: WALES + local_authority: WREXHAM + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_scheme_supported: true + council_tax_reduction: 1800 + +- name: Scottish household uses the national Scottish CTR scheme + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_scheme_supported: true + council_tax_reduction: 1800 + +- name: English pensioner household uses the prescribed full-support scheme + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 70 + is_SP_age: true + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 70 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WESTMINSTER + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_scheme_supported: true + council_tax_reduction: 1800 + +- name: Unsupported English working-age authorities keep reported baseline values + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + council_tax_benefit_reported: 500 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WESTMINSTER + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_scheme_supported: false + council_tax_reduction: 500 diff --git a/policyengine_uk/tests/policy/baseline/household/consumption/council_tax_less_benefit.yaml b/policyengine_uk/tests/policy/baseline/household/consumption/council_tax_less_benefit.yaml new file mode 100644 index 000000000..307e27eff --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/consumption/council_tax_less_benefit.yaml @@ -0,0 +1,28 @@ +- name: HBAI subtracts net council tax after CTR rather than gross council tax + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + council_tax_benefit_reported: 500 + employment_income: 10000 + benunits: + benunit: + members: [claimant] + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WESTMINSTER + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_less_benefit: 1300 + hbai_household_net_income: 8700 diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index 630c0790e..d2861ead5 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -8,4 +8,8 @@ class council_tax_benefit(Variable): definition_period = YEAR unit = GBP - adds = ["council_tax_benefit_reported"] + def formula(benunit, period, parameters): + supported = benunit.household("council_tax_reduction_scheme_supported", period) + simulated = benunit("simulated_council_tax_reduction_benunit", period) + reported = benunit("council_tax_benefit_reported", period) + return where(supported, simulated, reported) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md new file mode 100644 index 000000000..66b9be46f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md @@ -0,0 +1,79 @@ +# Council Tax Reduction + +This implementation currently simulates: + +- The statutory CTR scheme for pensioner households in England. +- The national CTR scheme in Wales. +- The national CTR scheme in Scotland. +- Working-age local schemes for Chesterfield, Dudley, East Hertfordshire, Stevenage, Stroud, and Warrington. + +For unsupported English working-age authorities, the model continues to use reported `council_tax_benefit` values in dataset mode rather than inventing scheme rules. + +The current implementation does not yet model: + +- Authority-specific income-banded English schemes. +- Alternative maximum / second adult rebate cases. +- Universal Credit-specific CTR adjustments beyond the standard income treatment used here. + +There is also a requests-based entitledto comparison harness at `scripts/entitledto_ctr_compare.py`. It replays the calculator from a saved browser storage state and currently covers three single-adult claimant variants: + +- `single_jsa` +- `single_jsa_pip_standard` +- `single_pension_credit` + +It does not yet cover couples, children, or other-adult/non-dependant household shapes. entitledto's session handling is also inconsistent enough that rerunning with a fresh storage state is sometimes necessary, and fresh anonymous calculator starts can hit entitledto's day limit. A typical single-scenario run is: + +`uv run --with requests --with beautifulsoup4 python scripts/entitledto_ctr_compare.py output/playwright/entitledto-live-state.json --postcode SG13 8EQ --band D --council-tax 1800 --scenario single_jsa` + +There is also a requests-based Turn2us probe at `scripts/turn2us_ctr_compare.py`. It currently replays the public Turn2us calculator for a single working-age JSA claimant and records the benefits that appear on the final results page for the supplied postcode. A typical multi-authority run is: + +`uv run --with requests python scripts/turn2us_ctr_compare.py "SG13 8EQ" "WA1 1UH" "DY1 1HF" "GL5 4UB"` + +## Validation notes + +Spot checks against public calculators and scheme sources currently show: + +- Stroud (`GL5 4UB`, area `Stroud`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns full Council Tax Support on an annual liability of `GBP 1,866.47`. PolicyEngine UK returns `council_tax_reduction = GBP 1,866.47`. +- Dudley (`DY1 1HF`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 15.64` per week of Council Tax Support, leaving `GBP 10.43` per week to pay. PolicyEngine UK returns `council_tax_reduction = GBP 543.62` per year and `council_tax_less_benefit = GBP 815.43` per year, matching Dudley Council's published 2025/26 scheme text and scheme PDF rather than the entitledto output. +- East Hertfordshire (`SG13 8EQ`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA, annual liability `GBP 1,800`): entitledto returns `GBP 31.59` per week of Council Tax Support and `GBP 2.93` per week left to pay. PolicyEngine UK applies the same `91.5%` maximum award rule, giving `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153` per year. +- East Hertfordshire (`SG13 8EQ`, band `D`, single working-age lone parent, one child aged `5`, no savings, income-based JSA, annual liability `GBP 1,800`): a headed browser run through entitledto returns total benefits of `GBP 367.64` per week, made up of `GBP 310.00` income-based Jobseeker's Allowance, `GBP 31.59` Council Tax Support, and `GBP 26.05` Child Benefit. The calculator shows a weekly Council Tax bill of `GBP 34.52` reduced to `GBP 2.93`. PolicyEngine UK now keeps this household on the East Herts class D maximum award, returning `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. +- East Hertfordshire (`SG13 8EQ`, band `D`, two working-age adults, no children, no savings, income-based JSA, annual liability `GBP 1,800`): a headed browser run through entitledto returns total benefits of `GBP 237.89` per week, made up of `GBP 206.30` income-based Jobseeker's Allowance and `GBP 31.59` Council Tax Support. The calculator again shows a weekly Council Tax bill of `GBP 34.52` reduced to `GBP 2.93`. PolicyEngine UK returns the same annual CTR position, `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. Because this check reused an existing entitledto calculation ID to get around the anonymous daily cap, the Council Tax page initially carried forward a stale `25%` single-adult discount and had to be corrected back to `none` before the result was trusted. +- Stevenage (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. +- Chesterfield (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. +- Warrington (`WA1 1UH`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 26.69` per week of Council Tax Support and `GBP 2.48` per week left to pay on a displayed bill of `GBP 29.17` per week, which is a `91.5%` maximum award on the displayed weekly bill. PolicyEngine UK applies the same rule structure. +- Policy in Practice Better Off Calculator (`SG13 8EQ`, East Hertfordshire, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA, monthly council tax liability `GBP 150`): the public calculator resolves East Hertfordshire from postcode and returns `GBP 137.25` per month of Council Tax Support. That is exactly a `91.5%` award on the entered liability, matching the scheme structure and the PolicyEngine UK rule. +- Policy in Practice Better Off Calculator (`DY1 1HF`, Dudley, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA, monthly council tax liability `GBP 113.25` with single-person discount): the public calculator resolves Dudley from postcode and returns `GBP 45.30` per month of Council Tax Support. That is exactly a `40%` award on the entered liability, matching Dudley's published scheme structure and the PolicyEngine UK rule. +- Policy in Practice Better Off Calculator (`WA1 1UH`, Warrington, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA, monthly council tax liability `GBP 126.74` with single-person discount): the public calculator resolves Warrington from postcode and returns `GBP 95.06` per month of Council Tax Support. That is a `75%` award on the entered liability, which does not match Warrington's published 2025/26 Class D passported-JSA scheme, entitledto's `91.5%` result for the same scenario, or the current PolicyEngine UK encoding. +- Policy in Practice Better Off Calculator (`GL5 4UB`, Stroud, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA, monthly council tax liability `GBP 148.09` with single-person discount): the public calculator resolves Stroud from postcode and returns `GBP 148.09` per month of Council Tax Support. That is full support on the entered liability, matching entitledto's behavior and the published Stroud scheme summary. +- Turn2us public calculator checks on the same four single working-age owner-occupier income-based JSA cases in East Hertfordshire, Warrington, Dudley, and Stroud all reached the final results page, but returned the same `Income-based Jobseekers Allowance` and `Universal Credit` lines for all four authorities and no CTR output. In these cases, Turn2us was not acting as an authority-specific CTR comparator. + +For the East Hertfordshire and Warrington calculator checks, entitledto's annual tab appears to multiply rounded weekly outputs by `52`, so the weekly figures are the closest comparison point. Additional browser-only probing suggests that reusing an existing entitledto calculation ID is good enough for lone-parent and couple spot checks, but not yet good enough for other-adult/non-dependant cases: the later pages can retain stale branch state, so those results should not be treated as validated until the flow is replayed from a fresh entitledto session. + +The public Policy in Practice calculator is testable through the browser, but not through a simple documented one-shot API. The frontend is a token-gated SPA on `betteroffcalculator.co.uk` backed by `api.betteroffcalculator.co.uk`; raw ad hoc `POST /api` requests return default baseline results unless the full calculator state has already been established in the session. + +Dudley references: + +- https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ +- https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf + +East Hertfordshire references: + +- https://www.eastherts.gov.uk/benefits-and-financial-support/council-tax-support +- https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf + +Stevenage references: + +- https://www.stevenage.gov.uk/benefits/council-tax-support +- https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf + +Chesterfield references: + +- https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support +- https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf + +Warrington references: + +- https://www.warrington.gov.uk/benefits-calculator +- https://www.warrington.gov.uk/income-and-capital +- https://www.warrington.gov.uk/non-dependants +- https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py new file mode 100644 index 000000000..6a2d45c07 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py @@ -0,0 +1,15 @@ +from policyengine_uk.model_api import * + + +class benunit_contains_household_head(Variable): + value_type = bool + entity = BenUnit + label = "Benefit unit contains the oldest adult in the household" + definition_period = YEAR + + def formula(benunit, period, parameters): + person = benunit.members + household = person.household + benunit_max_age = benunit.max(person("age", period)) + household_max_age = benunit.max(household.max(person("age", period))) + return benunit_max_age == household_max_age 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 new file mode 100644 index 000000000..fc46bd070 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -0,0 +1,126 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country +from policyengine_uk.variables.household.demographic.locations import ( + LocalAuthority, +) +from policyengine_uk.variables.input.council_tax_band import CouncilTaxBand + + +def is_dudley(local_authority): + return local_authority == LocalAuthority.DUDLEY + + +def is_chesterfield(local_authority): + return local_authority == LocalAuthority.CHESTERFIELD + + +def is_east_hertfordshire(local_authority): + return local_authority == LocalAuthority.EAST_HERTFORDSHIRE + + +def is_stevenage(local_authority): + return local_authority == LocalAuthority.STEVENAGE + + +def is_stroud(local_authority): + return local_authority == LocalAuthority.STROUD + + +def is_warrington(local_authority): + return local_authority == LocalAuthority.WARRINGTON + + +def is_england_pensioner_scheme(country, has_pensioner): + return (country == Country.ENGLAND) & has_pensioner + + +def is_scotland_scheme(country): + return country == Country.SCOTLAND + + +def is_wales_scheme(country): + return country == Country.WALES + + +def is_east_hertfordshire_working_age(local_authority, country, has_pensioner): + return ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_east_hertfordshire(local_authority) + ) + + +def is_chesterfield_working_age(local_authority, country, has_pensioner): + return ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_chesterfield(local_authority) + ) + + +def is_stevenage_working_age(local_authority, country, has_pensioner): + return ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_stevenage(local_authority) + ) + + +def is_stroud_working_age(local_authority, country, has_pensioner): + return (country == Country.ENGLAND) & ~has_pensioner & is_stroud(local_authority) + + +def is_warrington_working_age(local_authority, country, has_pensioner): + return ( + (country == Country.ENGLAND) & ~has_pensioner & is_warrington(local_authority) + ) + + +def is_supported_scheme(local_authority, country, has_pensioner): + return ( + is_england_pensioner_scheme(country, has_pensioner) + | is_scotland_scheme(country) + | is_wales_scheme(country) + | is_chesterfield_working_age(local_authority, country, has_pensioner) + | is_east_hertfordshire_working_age( + local_authority, + country, + has_pensioner, + ) + | is_stevenage_working_age(local_authority, country, has_pensioner) + | is_stroud_working_age(local_authority, country, has_pensioner) + | is_warrington_working_age(local_authority, country, has_pensioner) + | is_dudley_working_age(local_authority, country, has_pensioner) + ) + + +def is_dudley_working_age(local_authority, country, has_pensioner): + return (country == Country.ENGLAND) & ~has_pensioner & is_dudley(local_authority) + + +def english_council_tax_band_ratio(council_tax_band, band_ratios): + return select( + [ + council_tax_band == CouncilTaxBand.A, + council_tax_band == CouncilTaxBand.B, + council_tax_band == CouncilTaxBand.C, + council_tax_band == CouncilTaxBand.D, + council_tax_band == CouncilTaxBand.E, + council_tax_band == CouncilTaxBand.F, + council_tax_band == CouncilTaxBand.G, + council_tax_band == CouncilTaxBand.H, + council_tax_band == CouncilTaxBand.I, + ], + [ + band_ratios.A, + band_ratios.B, + band_ratios.C, + band_ratios.D, + band_ratios.E, + band_ratios.F, + band_ratios.G, + band_ratios.H, + band_ratios.I, + ], + default=band_ratios.D, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction.py new file mode 100644 index 000000000..90922d959 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction(Variable): + value_type = float + entity = Household + label = "Council Tax Reduction" + definition_period = YEAR + unit = GBP + + def formula(household, period, parameters): + person = household.members + return household.sum( + person.benunit("council_tax_benefit", period) + * person("is_benunit_head", period) + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py new file mode 100644 index 000000000..c09642958 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -0,0 +1,45 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicable_amount(Variable): + value_type = float + entity = BenUnit + label = "applicable Council Tax Reduction amount" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.housing_benefit.allowances + any_over_SP_age = benunit.any(benunit.members("is_SP_age", period)) + eldest_age = benunit("eldest_adult_age", period) + older_age_threshold = p.age_threshold.older + younger_age_threshold = p.age_threshold.younger + u_18 = eldest_age < younger_age_threshold + u_25 = eldest_age < older_age_threshold + o_25 = (eldest_age >= older_age_threshold) & ~any_over_SP_age + o_18 = (eldest_age >= younger_age_threshold) & ~any_over_SP_age + single = benunit("is_single_person", period) + couple = benunit("is_couple", period) + lone_parent = benunit("is_lone_parent", period) + single_personal_allowance = ( + u_25 * p.single.younger + + o_25 * p.single.older + + any_over_SP_age * p.single.aged + ) + couple_personal_allowance = ( + u_18 * p.couple.younger + + o_18 * p.couple.older + + any_over_SP_age * p.couple.aged + ) + lone_parent_personal_allowance = ( + u_18 * p.lone_parent.younger + + o_18 * p.lone_parent.older + + any_over_SP_age * p.lone_parent.aged + ) + personal_allowance = ( + single * single_personal_allowance + + couple * couple_personal_allowance + + lone_parent * lone_parent_personal_allowance + ) * WEEKS_IN_YEAR + premiums = benunit("benefits_premiums", period) + return personal_allowance + premiums diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py new file mode 100644 index 000000000..abf551dbe --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -0,0 +1,47 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicable_income(Variable): + value_type = float + entity = BenUnit + label = "relevant income for Council Tax Reduction means test" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + BENUNIT_MEANS_TESTED_BENEFITS = [ + "child_benefit", + "income_support", + "jsa_income", + "esa_income", + "universal_credit", + ] + PERSONAL_BENEFITS = [ + "carers_allowance", + "esa_contrib", + "jsa_contrib", + "state_pension", + "maternity_allowance", + "statutory_sick_pay", + "statutory_maternity_pay", + "ssmg", + ] + INCOME_COMPONENTS = [ + "employment_income", + "self_employment_income", + "property_income", + "private_pension_income", + ] + bi = parameters(period).gov.contrib.ubi_center.basic_income + benefits = add(benunit, period, BENUNIT_MEANS_TESTED_BENEFITS) + income = add(benunit, period, INCOME_COMPONENTS) + personal_benefits = add(benunit, period, PERSONAL_BENEFITS) + credits = add(benunit, period, ["tax_credits"]) + increased_income = income + personal_benefits + credits + benefits + + if not bi.interactions.include_in_means_tests: + increased_income -= add(benunit, period, ["basic_income"]) + + pension_contributions = add(benunit, period, ["pension_contributions"]) * 0.5 + tax = add(benunit, period, ["income_tax", "national_insurance"]) + return max_(0, increased_income - tax - pension_contributions) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_has_non_dep_exemption.py new file mode 100644 index 000000000..27ecca3be --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_has_non_dep_exemption.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_claimant_has_non_dep_exemption(Variable): + value_type = bool + entity = BenUnit + label = "CTR claimant has Dudley non-dependant deduction exemption" + definition_period = YEAR + + def formula(benunit, period, parameters): + pip_daily_living = benunit.any(benunit.members("pip_dl", period) > 0) + dla_care = benunit.any(benunit.members("dla_sc", period) > 0) + return pip_daily_living | dla_care 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..7f05aaf32 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py @@ -0,0 +1,22 @@ +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 + return household.any( + is_blind | attendance_allowance | pip_daily_living | dla_care + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py new file mode 100644 index 000000000..51c55d2f1 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py @@ -0,0 +1,11 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_household_has_pensioner(Variable): + value_type = bool + entity = Household + label = "Household has a pension-age member for CTR" + definition_period = YEAR + + def formula(household, period, parameters): + return household.any(household.members("is_SP_age", period)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_below_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_below_applicable_amount.py new file mode 100644 index 000000000..e4246a4c8 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_below_applicable_amount.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_income_below_applicable_amount(Variable): + value_type = bool + entity = BenUnit + label = "CTR applicable income is at or below the applicable amount" + definition_period = YEAR + + def formula(benunit, period, parameters): + applicable_amount = benunit("council_tax_reduction_applicable_amount", period) + applicable_income = benunit("council_tax_reduction_applicable_income", period) + return applicable_income <= applicable_amount diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py new file mode 100644 index 000000000..df63ec892 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py @@ -0,0 +1,124 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_chesterfield_working_age, + is_east_hertfordshire_working_age, + is_dudley_working_age, + is_stevenage_working_age, + is_warrington_working_age, +) + + +class council_tax_reduction_individual_non_dep_deduction(Variable): + value_type = float + entity = Person + label = "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): + p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction + chesterfield_params = parameters( + period + ).gov.local_authorities.chesterfield.council_tax_reduction + east_herts_params = parameters( + period + ).gov.local_authorities.east_hertfordshire.council_tax_reduction + stevenage_params = parameters( + period + ).gov.local_authorities.stevenage.council_tax_reduction + dudley_params = parameters( + period + ).gov.local_authorities.dudley.council_tax_reduction + warrington_params = parameters( + period + ).gov.local_authorities.warrington.council_tax_reduction + weekly_total_income = person("total_income", period) / WEEKS_IN_YEAR + weekly_earned_income = ( + person("employment_income", period) + + person("self_employment_income", period) + ) / WEEKS_IN_YEAR + classic_deduction = ( + p.amount.calc(weekly_total_income, right=True) * WEEKS_IN_YEAR + ) + + household = person.household + local_authority = household("local_authority", period) + country = household("country", period) + has_pensioner = household( + "council_tax_reduction_household_has_pensioner", period + ) + chesterfield_working_age = is_chesterfield_working_age( + local_authority, + country, + has_pensioner, + ) + east_herts_working_age = is_east_hertfordshire_working_age( + local_authority, + country, + has_pensioner, + ) + stevenage_working_age = is_stevenage_working_age( + local_authority, + country, + has_pensioner, + ) + dudley_working_age = is_dudley_working_age( + local_authority, + country, + has_pensioner, + ) + warrington_working_age = is_warrington_working_age( + local_authority, + country, + has_pensioner, + ) + east_herts_deduction = ( + east_herts_params.non_dep_deduction.amount.calc(weekly_earned_income) + * WEEKS_IN_YEAR + ) + chesterfield_deduction = ( + chesterfield_params.non_dep_deduction.amount.calc(weekly_earned_income) + * WEEKS_IN_YEAR + ) + stevenage_deduction = ( + stevenage_params.non_dep_deduction.amount.calc(weekly_earned_income) + * WEEKS_IN_YEAR + ) + dudley_deduction = dudley_params.non_dep_deduction.amount * WEEKS_IN_YEAR + warrington_deduction = ( + warrington_params.non_dep_deduction.amount.calc(weekly_earned_income) + * WEEKS_IN_YEAR + ) + claimant_exempt = person.household( + "council_tax_reduction_household_has_non_dep_exemption", period + ) + local_deduction = select( + [ + chesterfield_working_age, + east_herts_working_age, + stevenage_working_age, + warrington_working_age, + dudley_working_age, + ], + [ + chesterfield_deduction, + east_herts_deduction, + stevenage_deduction, + warrington_deduction, + dudley_deduction, + ], + classic_deduction, + ) + local_exemption_applies = ( + chesterfield_working_age + | east_herts_working_age + | stevenage_working_age + | warrington_working_age + | dudley_working_age + ) + return where( + local_exemption_applies & claimant_exempt, + 0.0, + local_deduction, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py new file mode 100644 index 000000000..36cb046b4 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): + value_type = bool + entity = Person + label = "eligible person for CTR non-dependent deduction" + definition_period = YEAR + + def formula(person, period, parameters): + return (person("age", period) >= 18) & ~person.benunit( + "benunit_contains_household_head", period + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py new file mode 100644 index 000000000..970d5e8f6 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py @@ -0,0 +1,76 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + english_council_tax_band_ratio, + is_warrington_working_age, + is_dudley_working_age, +) + + +class council_tax_reduction_maximum_eligible_liability(Variable): + value_type = float + entity = Household + label = "Maximum Council Tax liability eligible for CTR" + definition_period = YEAR + unit = GBP + + def formula(household, period, parameters): + england_council_tax = parameters( + period + ).gov.local_authorities.england.council_tax + dudley_ctr = parameters( + period + ).gov.local_authorities.dudley.council_tax_reduction + warrington_ctr = parameters( + period + ).gov.local_authorities.warrington.council_tax_reduction + council_tax = household("council_tax", period) + local_authority = household("local_authority", period) + country = household("country", period) + council_tax_band = household("council_tax_band", period) + has_pensioner = household( + "council_tax_reduction_household_has_pensioner", period + ) + person = household.members + claimant_benunit = person.benunit("benunit_contains_household_head", period) + claimant_income_below_applicable_amount = household.any( + claimant_benunit + & person.benunit( + "council_tax_reduction_income_below_applicable_amount", + period, + ) + ) + claimant_relevant_income_based_benefit = household.any( + claimant_benunit + & person.benunit( + "council_tax_reduction_relevant_income_based_benefit", + period, + ) + ) + + band_ratio = english_council_tax_band_ratio( + council_tax_band, + england_council_tax.band_ratio, + ) + dudley_cap_band_ratio = dudley_ctr.maximum_liability.cap_band_ratio + warrington_cap_band_ratio = warrington_ctr.maximum_liability.cap_band_ratio + band_c_liability = council_tax * dudley_cap_band_ratio / band_ratio + band_a_liability = council_tax * warrington_cap_band_ratio / band_ratio + dudley_working_age = is_dudley_working_age( + local_authority, + country, + has_pensioner, + ) + warrington_working_age = is_warrington_working_age( + local_authority, + country, + has_pensioner, + ) + dudley_capped = dudley_working_age & (band_ratio > dudley_cap_band_ratio) + warrington_capped = ( + warrington_working_age + & ~claimant_income_below_applicable_amount + & ~claimant_relevant_income_based_benefit + & (band_ratio > warrington_cap_band_ratio) + ) + capped_liability = where(dudley_capped, band_c_liability, council_tax) + return where(warrington_capped, band_a_liability, capped_liability) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py new file mode 100644 index 000000000..c6b5fc1d8 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_non_dep_deductions(Variable): + value_type = float + entity = BenUnit + label = "CTR non-dependent deductions" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + deductions = benunit.members( + "council_tax_reduction_individual_non_dep_deduction", period + ) + deductions_in_household = benunit.max(benunit.members.household.sum(deductions)) + deductions_in_benunit = benunit.sum(deductions) + return deductions_in_household - deductions_in_benunit 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..5efbe925f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py @@ -0,0 +1,13 @@ +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 new file mode 100644 index 000000000..e31d2acdf --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py @@ -0,0 +1,19 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_supported_scheme, +) + + +class council_tax_reduction_scheme_supported(Variable): + value_type = bool + entity = Household + label = "Supported CTR scheme is available" + definition_period = YEAR + + def formula(household, period, parameters): + local_authority = household("local_authority", period) + country = household("country", period) + has_pensioner = household( + "council_tax_reduction_household_has_pensioner", period + ) + return is_supported_scheme(local_authority, country, has_pensioner) 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 new file mode 100644 index 000000000..33e621153 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -0,0 +1,218 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_chesterfield_working_age, + is_east_hertfordshire_working_age, + is_england_pensioner_scheme, + is_dudley_working_age, + is_scotland_scheme, + is_stevenage_working_age, + is_stroud_working_age, + is_warrington_working_age, + is_wales_scheme, +) +from policyengine_uk.variables.input.council_tax_band import CouncilTaxBand + + +class simulated_council_tax_reduction_benunit(Variable): + value_type = float + entity = BenUnit + label = "Simulated Council Tax Reduction" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + local_authority_parameters = parameters(period).gov.local_authorities + england_pensioners_ctr = ( + local_authority_parameters.england.council_tax_reduction.pensioners + ) + wales_ctr = local_authority_parameters.wales.council_tax_reduction + scotland_ctr = local_authority_parameters.scotland.council_tax_reduction + chesterfield_ctr = local_authority_parameters.chesterfield.council_tax_reduction + east_herts_ctr = ( + local_authority_parameters.east_hertfordshire.council_tax_reduction + ) + stevenage_ctr = local_authority_parameters.stevenage.council_tax_reduction + stroud_ctr = local_authority_parameters.stroud.council_tax_reduction + warrington_ctr = local_authority_parameters.warrington.council_tax_reduction + dudley_ctr = local_authority_parameters.dudley.council_tax_reduction + supported = benunit.household("council_tax_reduction_scheme_supported", period) + is_household_head_benunit = benunit("benunit_contains_household_head", period) + would_claim = benunit("would_claim_council_tax_reduction", period) + local_authority = benunit.household("local_authority", period) + country = benunit.household("country", period) + council_tax_band = benunit.household("council_tax_band", period) + has_pensioner = benunit.household( + "council_tax_reduction_household_has_pensioner", period + ) + applicable_amount = benunit("council_tax_reduction_applicable_amount", period) + applicable_income = benunit("council_tax_reduction_applicable_income", period) + income_below_applicable_amount = benunit( + "council_tax_reduction_income_below_applicable_amount", + period, + ) + relevant_income_based_benefit = benunit( + "council_tax_reduction_relevant_income_based_benefit", + period, + ) + + england_pensioners = is_england_pensioner_scheme(country, has_pensioner) + wales = is_wales_scheme(country) + scotland = is_scotland_scheme(country) + chesterfield_working_age = is_chesterfield_working_age( + local_authority, + country, + has_pensioner, + ) + east_herts_working_age = is_east_hertfordshire_working_age( + local_authority, + country, + has_pensioner, + ) + stevenage_working_age = is_stevenage_working_age( + local_authority, + country, + has_pensioner, + ) + stroud_working_age = is_stroud_working_age( + local_authority, + country, + has_pensioner, + ) + warrington_working_age = is_warrington_working_age( + local_authority, + country, + has_pensioner, + ) + dudley_working_age = is_dudley_working_age( + local_authority, + country, + has_pensioner, + ) + warrington_band_a = council_tax_band == CouncilTaxBand.A + warrants_like_legacy_scheme = ( + chesterfield_working_age + | east_herts_working_age + | stevenage_working_age + | stroud_working_age + | warrington_working_age + | dudley_working_age + ) + legacy_passported_claimant = ( + warrants_like_legacy_scheme & relevant_income_based_benefit + ) + warrington_class_d = warrington_working_age & ( + income_below_applicable_amount | relevant_income_based_benefit + ) + east_herts_class_d = east_herts_working_age & ( + income_below_applicable_amount | relevant_income_based_benefit + ) + warrington_max_support = select( + [ + warrington_class_d & warrington_band_a, + warrington_class_d, + ], + [ + warrington_ctr.maximum_support_rate.class_d.band_a, + warrington_ctr.maximum_support_rate.class_d.other_bands, + ], + default=warrington_ctr.maximum_support_rate.class_e, + ) + max_support = select( + [ + england_pensioners, + wales, + scotland, + chesterfield_working_age, + east_herts_working_age, + stevenage_working_age, + stroud_working_age, + warrington_working_age, + dudley_working_age, + ], + [ + england_pensioners_ctr.maximum_support_rate, + wales_ctr.maximum_support_rate, + scotland_ctr.maximum_support_rate, + chesterfield_ctr.maximum_support_rate, + east_herts_ctr.maximum_support_rate, + stevenage_ctr.maximum_support_rate, + stroud_ctr.maximum_support_rate, + warrington_max_support, + dudley_ctr.maximum_support_rate, + ], + default=0.0, + ) + liability = benunit.household( + "council_tax_reduction_maximum_eligible_liability", period + ) + non_dep_deductions = benunit("council_tax_reduction_non_dep_deductions", period) + withdrawal_rate = select( + [ + england_pensioners, + wales, + scotland, + chesterfield_working_age, + east_herts_working_age, + stevenage_working_age, + stroud_working_age, + warrington_working_age, + dudley_working_age, + ], + [ + england_pensioners_ctr.means_test.withdrawal_rate, + wales_ctr.means_test.withdrawal_rate, + scotland_ctr.means_test.withdrawal_rate, + chesterfield_ctr.means_test.withdrawal_rate, + east_herts_ctr.means_test.withdrawal_rate, + stevenage_ctr.means_test.withdrawal_rate, + stroud_ctr.means_test.withdrawal_rate, + warrington_ctr.means_test.withdrawal_rate, + dudley_ctr.means_test.withdrawal_rate, + ], + default=0.0, + ) + capital_limit = select( + [ + england_pensioners, + wales, + scotland, + chesterfield_working_age, + east_herts_working_age, + stevenage_working_age, + stroud_working_age, + warrington_working_age, + dudley_working_age, + ], + [ + england_pensioners_ctr.means_test.capital_limit, + wales_ctr.means_test.capital_limit, + scotland_ctr.means_test.capital_limit, + chesterfield_ctr.means_test.capital_limit, + east_herts_ctr.means_test.capital_limit, + stevenage_ctr.means_test.capital_limit, + stroud_ctr.means_test.capital_limit, + warrington_ctr.means_test.capital_limit, + dudley_ctr.means_test.capital_limit, + ], + default=0.0, + ) + excess_income = max_(0, applicable_income - applicable_amount) + # The currently encoded English working-age schemes follow the legacy + # CTB-style treatment of income-based JSA, income support, and + # income-related ESA claimants. Those cases keep the maximum local + # award instead of entering the taper. + excess_income = where(legacy_passported_claimant, 0, excess_income) + preliminary_award = max_( + 0, + liability * max_support + - excess_income * withdrawal_rate + - non_dep_deductions, + ) + capital_eligible = benunit.household("savings", period) <= capital_limit + return ( + supported + * is_household_head_benunit + * would_claim + * capital_eligible + * preliminary_award + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py new file mode 100644 index 000000000..68fdd511c --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class would_claim_council_tax_reduction(Variable): + value_type = bool + entity = BenUnit + label = "Would claim Council Tax Reduction" + documentation = ( + "Whether this benefit unit would claim Council Tax Reduction if eligible." + ) + definition_period = YEAR + + def formula(benunit, period, parameters): + claims_all_entitled_benefits = benunit("claims_all_entitled_benefits", period) + reported_ctr = benunit("council_tax_benefit_reported", period) > 0 + return claims_all_entitled_benefits | reported_ctr diff --git a/policyengine_uk/variables/household/consumption/council_tax_less_benefit.py b/policyengine_uk/variables/household/consumption/council_tax_less_benefit.py index b1a407394..3a21182d9 100644 --- a/policyengine_uk/variables/household/consumption/council_tax_less_benefit.py +++ b/policyengine_uk/variables/household/consumption/council_tax_less_benefit.py @@ -3,16 +3,15 @@ class council_tax_less_benefit(Variable): label = "Council Tax (less CTB)" - documentation = "Council Tax minus the Council Tax Benefit" + documentation = "Council Tax minus Council Tax Reduction" entity = Household definition_period = YEAR value_type = float unit = GBP def formula(household, period, parameters): - person = household.members - council_tax_benefit = household.sum( - person.benunit("council_tax_benefit", period) - * person("is_benunit_head", period) + return max_( + 0, + household("council_tax", period) + - household("council_tax_reduction", period), ) - return household("council_tax", period) - council_tax_benefit diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index 0574b42a0..0bd60d27b 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -58,7 +58,7 @@ class hbai_household_net_income(Variable): # Reference for tax-free-childcare: https://assets.publishing.service.gov.uk/media/5e7b191886650c744175d08b/households-below-average-income-1994-1995-2018-2019.pdf ] subtracts = [ - "council_tax", + "council_tax_less_benefit", "domestic_rates", "income_tax", "national_insurance", @@ -79,7 +79,11 @@ def formula(household, period, parameters): ) - add( household, period, - [s for s in hbai_household_net_income.subtracts if s != "council_tax"], + [ + s + for s in hbai_household_net_income.subtracts + if s != "council_tax_less_benefit" + ], ) return add(household, period, hbai_household_net_income.adds) - add( household, period, hbai_household_net_income.subtracts diff --git a/scripts/entitledto_ctr_compare.py b/scripts/entitledto_ctr_compare.py new file mode 100644 index 000000000..da086bd5b --- /dev/null +++ b/scripts/entitledto_ctr_compare.py @@ -0,0 +1,721 @@ +#!/usr/bin/env python3 + +import argparse +import json +import re +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, Optional, Sequence +from urllib.parse import urljoin + +import requests +from bs4 import BeautifulSoup + + +USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/146.0.0.0 Safari/537.36" +) + + +@dataclass(frozen=True) +class Scenario: + description: str + household_overrides: Optional[Dict[str, str]] + age_overrides: Dict[str, str] + benefits_overrides: Dict[str, str] + partner_age_overrides: Optional[Dict[str, str]] = None + child_overrides: Optional[Sequence[Dict[str, str]]] = None + disability_overrides: Optional[Dict[str, str]] = None + net_income_overrides: Optional[Dict[str, str]] = None + housing_costs_overrides: Optional[Dict[str, str]] = None + + +SCENARIOS = { + "single_jsa": Scenario( + description="Single working-age claimant on income-based JSA.", + household_overrides=None, + age_overrides={ + "Age": "35", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + benefits_overrides={ + "ReceiveUniversalCredit": "False", + "ReceiveTransitionalElement": "False", + "IncomeBasedBenefit": "jobseekerallowanceincomebased", + "ReceivedManagedMigrationNotice": "False", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), + "single_jsa_pip_standard": Scenario( + description="Single working-age claimant on income-based JSA with standard daily living PIP.", + household_overrides=None, + age_overrides={ + "Age": "35", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "CurrentlyClaiming", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + disability_overrides={ + "PersonalIndependencePaymentDailyLiving.SelectedOption": "2", + "PersonalIndependencePaymentDailyLiving.Value": "73.9", + "PersonalIndependencePaymentDailyLiving.PaymentPeriod": "2", + }, + benefits_overrides={ + "ReceiveUniversalCredit": "False", + "ReceiveTransitionalElement": "False", + "IncomeBasedBenefit": "jobseekerallowanceincomebased", + "ReceivedManagedMigrationNotice": "False", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), + "single_pension_credit": Scenario( + description="Single pension-age claimant with Pension Credit and State Pension.", + household_overrides=None, + age_overrides={ + "Age": "70", + "DOB_Day": "1", + "DOB_Month": "1", + "DOB_Year": "1956", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + benefits_overrides={ + "HasPensionCredit": "True", + "ClientRetirementPension.SelectedOption": "1", + "ClientRetirementPension.Value": "185.15", + "ClientRetirementPension.PaymentPeriod": "2", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), + "couple_jsa": Scenario( + description="Couple with no children on income-based JSA.", + household_overrides={ + "HasPartner": "True", + }, + age_overrides={ + "Age": "35", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + partner_age_overrides={ + "Age": "35", + "Gender": "Female", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + benefits_overrides={ + "ReceiveUniversalCredit": "False", + "ReceiveTransitionalElement": "False", + "IncomeBasedBenefit": "jobseekerallowanceincomebased", + "ReceivedManagedMigrationNotice": "False", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), + "lone_parent_one_child_jsa": Scenario( + description="Lone parent with one child on income-based JSA.", + household_overrides={ + "HouseholdChildrenNumber": "1", + }, + age_overrides={ + "Age": "35", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + child_overrides=[ + { + "Age": "5", + "DOB_Day": "1", + "DOB_Month": "1", + "DOB_Year": "2021", + "PayForChildcare": "False", + "ChildcarePeriod": "2", + "ChildcareAmount": "0", + "IsDisabledPerson": "False", + "IsDisabledNotClaiming": "False", + } + ], + benefits_overrides={ + "ReceiveUniversalCredit": "False", + "ReceiveTransitionalElement": "False", + "IncomeBasedBenefit": "jobseekerallowanceincomebased", + "ReceivedManagedMigrationNotice": "False", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), +} + + +HOUSEHOLD_TEMPLATE = { + "CompanyName": "entitledto", + "HasSEISS": "False", + "HideNonDeps": "False", + "SiteId": "4", + "CalcYears": "22", + "HasPartner": "False", + "HouseholdChildrenNumber": "0", + "AnyoneElseLivesInYourHome": "No", + "ImmigrationControl": "True", + "ClientImmigrationStatus": "BritishOrIrish", + "CoronaVirusStatus": "None", + "ResCare": "False", + "VehiclesModel.DoYouOwnAVehicle": "False", + "VehiclesModel.NumberOfVehicles": "0", + "NavigationControls.SaveButtonIsPressed": "False", + "NavigationControls.ShowSaveButton": "True", + "NavigationControls.ShowNextButton": "True", +} + + +EMPTY_NET_INCOME = { + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", +} + + +def load_session(storage_state_path: Path) -> requests.Session: + state = json.loads(storage_state_path.read_text()) + session = requests.Session() + for cookie in state.get("cookies", []): + domain = cookie.get("domain", "") + if "entitledto.co.uk" not in domain: + continue + session.cookies.set( + cookie["name"], + cookie["value"], + domain=domain, + path=cookie.get("path", "/"), + ) + session.headers.update( + { + "User-Agent": USER_AGENT, + "Accept-Language": "en-US,en;q=0.9", + } + ) + return session + + +def find_resume_cid(session: requests.Session) -> Optional[str]: + for cookie in session.cookies: + match = re.match( + r"f_/benefits-calculator/Intro/Home\?cid([0-9a-f-]{36})", + cookie.name, + ) + if match: + return match.group(1) + return None + + +def extract_primary_form(html: str, current_url: str) -> tuple[str, Dict[str, str]]: + soup = BeautifulSoup(html, "html.parser") + candidate = None + for form in soup.find_all("form"): + if "/benefits-calculator/" in (form.get("action") or ""): + candidate = form + break + if candidate is None: + raise RuntimeError(f"No entitledto calculator form found at {current_url}") + + action = urljoin(current_url, candidate.get("action", current_url)) + payload: Dict[str, str] = {} + radio_groups = set() + + for element in candidate.find_all(["input", "select", "textarea"]): + name = element.get("name") + if not name: + continue + tag = element.name + element_type = (element.get("type") or "").lower() + + if tag == "select": + selected = element.find("option", selected=True) or element.find("option") + payload[name] = selected.get("value", "") if selected else "" + continue + + if tag == "textarea": + payload[name] = element.text or "" + continue + + if element_type in {"submit", "button", "image", "file", "reset"}: + continue + + value = element.get("value", "") + data_value = element.get("data-value") + if ( + data_value is not None + and element_type == "hidden" + and ( + value.startswith("System.String") + or value.startswith("System.Collections.Generic") + ) + ): + value = data_value + + if element_type == "radio": + if name in radio_groups: + continue + checked = candidate.find("input", attrs={"name": name, "checked": True}) + if checked is not None: + payload[name] = checked.get("value", "") + radio_groups.add(name) + continue + + if element_type == "checkbox": + if element.has_attr("checked"): + payload[name] = value or "on" + continue + + payload[name] = value + + return action, payload + + +def follow_post( + session: requests.Session, action: str, payload: Dict[str, str] +) -> requests.Response: + response = session.post(action, data=payload, allow_redirects=False, timeout=30) + if response.is_redirect or response.is_permanent_redirect: + location = response.headers["location"] + return session.get(urljoin(action, location), timeout=30) + return response + + +def page_title(html: str) -> str: + soup = BeautifulSoup(html, "html.parser") + return soup.title.get_text(strip=True) if soup.title else "" + + +def bootstrap_calculation( + session: requests.Session, + postcode: str, + housing_status: str, + household_overrides: Optional[Dict[str, str]] = None, +) -> requests.Response: + response = None + action = None + payload = None + resume_cid = find_resume_cid(session) + if resume_cid is not None: + response = session.get( + f"https://www.entitledto.co.uk/benefits-calculator/Intro/Home?cid={resume_cid}", + timeout=30, + ) + else: + response = session.get( + "https://www.entitledto.co.uk/benefits-calculator/", + timeout=30, + ) + for _ in range(5): + action, payload = extract_primary_form(response.text, response.url) + if "CalcIdent" in payload: + break + response = follow_post(session, action, payload) + if response is None or action is None or payload is None or "CalcIdent" not in payload: + raise RuntimeError( + "entitledto did not return the calculator start page with a CalcIdent" + ) + payload.update( + { + "NationalHousingStatus": housing_status, + "Postcode": postcode, + } + ) + response = follow_post(session, action, payload) + household_payload = dict(HOUSEHOLD_TEMPLATE) + action, household_defaults = extract_primary_form(response.text, response.url) + household_payload["CalcIdent"] = household_defaults["CalcIdent"] + household_payload["StartTime"] = household_defaults["StartTime"] + household_payload.update(household_overrides or {}) + return follow_post(session, action, household_payload) + + +def post_form_overrides( + session: requests.Session, + response: requests.Response, + overrides: Optional[Dict[str, str]] = None, +) -> requests.Response: + action, payload = extract_primary_form(response.text, response.url) + payload.update(overrides or {}) + return follow_post(session, action, payload) + + +def post_until( + session: requests.Session, + response: requests.Response, + overrides: Optional[Dict[str, str]], + predicate: Callable[[requests.Response], bool], + attempts: int = 3, +) -> requests.Response: + for _ in range(attempts): + response = post_form_overrides(session, response, overrides) + if predicate(response): + return response + return response + + +def require_page(response: requests.Response, fragment: str) -> None: + if fragment not in response.url: + raise RuntimeError( + f"Expected entitledto page containing {fragment!r}, got {response.url!r}" + ) + + +def normalize_text(html: str) -> str: + return " ".join(BeautifulSoup(html, "html.parser").get_text(" ", strip=True).split()) + + +def extract_money(pattern: str, text: str) -> Optional[str]: + match = re.search(pattern, text) + if not match: + return None + return match.group(1).replace(" ", "") + + +def parse_results(html: str) -> Dict[str, Optional[str]]: + text = normalize_text(html) + payment_period_match = re.search( + r"Council Tax Support £\s*([0-9][0-9,\.\s]*)\s*/\s*(weekly|4 weeks|monthly|yearly)", + text, + re.IGNORECASE, + ) + bill_match = re.search( + r"full Council Tax bill of £\s*([0-9][0-9,\.\s]*)\s*per\s*(week|4 weeks|month|year)" + r"\s*will be reduced to £\s*([0-9][0-9,\.\s]*)\s*per\s*(week|4 weeks|month|year)", + text, + re.IGNORECASE, + ) + total_match = re.search( + r"Total benefits entitlement.*?£\s*([0-9][0-9,\.\s]*)\s*/\s*(weekly|4 weeks|monthly|yearly)", + text, + re.IGNORECASE, + ) + return { + "council_tax_support": payment_period_match.group(1).replace(" ", "") + if payment_period_match + else None, + "council_tax_support_period": payment_period_match.group(2) + if payment_period_match + else None, + "council_tax_bill_before": bill_match.group(1).replace(" ", "") + if bill_match + else None, + "council_tax_bill_before_period": bill_match.group(2) if bill_match else None, + "council_tax_bill_after": bill_match.group(3).replace(" ", "") + if bill_match + else None, + "council_tax_bill_after_period": bill_match.group(4) if bill_match else None, + "total_benefits": total_match.group(1).replace(" ", "") if total_match else None, + "total_benefits_period": total_match.group(2) if total_match else None, + } + + +def run_scenario( + session: requests.Session, + scenario_name: str, + postcode: str, + housing_status: str, + council_tax_band: str, + council_tax: str, + use_band_amount: bool, + save_html_dir: Optional[Path], +) -> Dict[str, Optional[str]]: + scenario = SCENARIOS[scenario_name] + response = bootstrap_calculation( + session, + postcode, + housing_status, + household_overrides=scenario.household_overrides, + ) + require_page(response, "/AgeDisabilityStatus") + if scenario.partner_age_overrides is not None: + response = post_until( + session, + response, + scenario.age_overrides, + lambda resp: "client=Partner" in resp.url, + ) + elif scenario.child_overrides is not None: + response = post_until( + session, + response, + scenario.age_overrides, + lambda resp: "/Children" in resp.url, + ) + else: + response = post_until( + session, + response, + scenario.age_overrides, + lambda resp: ( + "/BenefitsYouCurrentlyReceive" in resp.url + or "/DisabilityBenefits" in resp.url + ), + ) + + if scenario.partner_age_overrides is not None: + require_page(response, "/AgeDisabilityStatus") + response = post_until( + session, + response, + scenario.partner_age_overrides, + lambda resp: "/BenefitsYouCurrentlyReceive" in resp.url, + ) + + if scenario.child_overrides is not None: + for child_overrides in scenario.child_overrides: + require_page(response, "/Children") + response = post_until( + session, + response, + child_overrides, + lambda resp: "/BenefitsYouCurrentlyReceive" in resp.url + or "/Children" in resp.url, + ) + + if "/DisabilityBenefits" in response.url: + if scenario.disability_overrides is None: + raise RuntimeError( + f"{scenario_name} reached DisabilityBenefits without a disability payload" + ) + response = post_until( + session, + response, + scenario.disability_overrides, + lambda resp: "/BenefitsYouCurrentlyReceive" in resp.url, + ) + + require_page(response, "/BenefitsYouCurrentlyReceive") + response = post_until( + session, + response, + scenario.benefits_overrides, + lambda resp: "/NetIncome" in resp.url, + ) + require_page(response, "/NetIncome") + response = post_form_overrides( + session, + response, + {**EMPTY_NET_INCOME, **(scenario.net_income_overrides or {})}, + ) + require_page(response, "/HousingCosts") + response = post_until( + session, + response, + scenario.housing_costs_overrides, + lambda resp: "/CouncilTax" in resp.url, + ) + require_page(response, "/CouncilTax") + response = post_form_overrides( + session, + response, + { + "CouncilTaxBand": council_tax_band, + "EligibleDisabilityReduction": "False", + "DiscountsApplicable": "0", + "AmountIsCorrect": "True" if use_band_amount else "False", + "CouncilTax": council_tax, + "CouncilTaxPeriod": "0", + }, + ) + + if "/Results/" not in response.url: + raise RuntimeError( + f"{scenario_name} did not reach entitledto results; stopped at {response.url}" + ) + + if save_html_dir is not None: + save_html_dir.mkdir(parents=True, exist_ok=True) + output_path = save_html_dir / f"{scenario_name}.html" + output_path.write_text(response.text) + + result = { + "scenario": scenario_name, + "description": scenario.description, + "postcode": postcode, + "housing_status": housing_status, + "council_tax_band": council_tax_band, + "council_tax": council_tax, + "results_url": response.url, + "results_title": page_title(response.text), + } + result.update(parse_results(response.text)) + return result + + +def run_scenario_with_retries( + storage_state_path: Path, + scenario_name: str, + postcode: str, + housing_status: str, + council_tax_band: str, + council_tax: str, + use_band_amount: bool, + save_html_dir: Optional[Path], + attempts: int, +) -> Dict[str, Optional[str]]: + last_error: Optional[Exception] = None + for attempt in range(1, attempts + 1): + try: + return run_scenario( + session=load_session(storage_state_path), + scenario_name=scenario_name, + postcode=postcode, + housing_status=housing_status, + council_tax_band=council_tax_band, + council_tax=council_tax, + use_band_amount=use_band_amount, + save_html_dir=save_html_dir, + ) + except Exception as error: + last_error = error + if attempt < attempts: + time.sleep(1) + assert last_error is not None + raise last_error + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("storage_state", type=Path) + parser.add_argument("--postcode", required=True) + parser.add_argument("--housing-status", default="MortgageOrOwned") + parser.add_argument("--band", default="D") + parser.add_argument("--council-tax", default="1800") + parser.add_argument( + "--use-band-amount", + action="store_true", + help="Use entitledto's band-derived bill instead of the provided annual bill.", + ) + parser.add_argument( + "--scenario", + action="append", + choices=[*SCENARIOS.keys(), "all"], + default=["single_jsa"], + help="Scenario to run. Repeatable. Use 'all' to run every built-in scenario.", + ) + parser.add_argument("--save-html-dir", type=Path) + parser.add_argument("--attempts", type=int, default=5) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + scenario_names = args.scenario + if "all" in scenario_names: + scenario_names = list(SCENARIOS.keys()) + else: + # Preserve order while removing accidental duplicates. + scenario_names = list(dict.fromkeys(scenario_names)) + + try: + results = [ + run_scenario_with_retries( + storage_state_path=args.storage_state, + scenario_name=scenario_name, + postcode=args.postcode, + housing_status=args.housing_status, + council_tax_band=args.band, + council_tax=args.council_tax, + use_band_amount=args.use_band_amount, + save_html_dir=args.save_html_dir, + attempts=args.attempts, + ) + for scenario_name in scenario_names + ] + except Exception as error: + print( + f"error: {error}\n" + "If entitledto returned Cloudflare again, refresh the storage state first " + "with `playwright-cli state-save` or another browser-derived cookie export.", + file=sys.stderr, + ) + return 1 + + print(json.dumps(results, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/policyengine_ctr_compare.py b/scripts/policyengine_ctr_compare.py new file mode 100644 index 000000000..4cb10db58 --- /dev/null +++ b/scripts/policyengine_ctr_compare.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sys +from dataclasses import dataclass +from typing import Callable + +from policyengine_uk import Simulation + + +@dataclass(frozen=True) +class CouncilConfig: + label: str + local_authority: str + council_tax_band: str + council_tax: float + + +@dataclass(frozen=True) +class Scenario: + description: str + build_situation: Callable[[CouncilConfig], dict] + + +SINGLE_JSA_WEEKLY = 92.05 +COUPLE_JSA_WEEKLY = 144.65 +STATE_PENSION_WEEKLY = 185.15 + + +COUNCILS = { + "east_hertfordshire": CouncilConfig( + label="East Hertfordshire", + local_authority="EAST_HERTFORDSHIRE", + council_tax_band="D", + council_tax=1800.0, + ), + "warrington": CouncilConfig( + label="Warrington", + local_authority="WARRINGTON", + council_tax_band="C", + council_tax=126.74 * 12, + ), + "dudley": CouncilConfig( + label="Dudley", + local_authority="DUDLEY", + council_tax_band="C", + council_tax=113.25 * 12, + ), + "stroud": CouncilConfig( + label="Stroud", + local_authority="STROUD", + council_tax_band="D", + council_tax=148.09 * 12, + ), +} + + +def base_benunit( + *, + members: list[str], + eldest_adult_age: int, + is_single_person: bool, + is_couple: bool, + is_lone_parent: bool, +) -> dict: + return { + "members": members, + "claims_all_entitled_benefits": {2025: False}, + "would_claim_uc": {2025: False}, + "is_single_person": {2025: is_single_person}, + "is_couple": {2025: is_couple}, + "is_lone_parent": {2025: is_lone_parent}, + "eldest_adult_age": {2025: eldest_adult_age}, + "benefits_premiums": {2025: 0}, + } + + +def base_household(council: CouncilConfig, members: list[str]) -> dict: + return { + "members": members, + "country": {2025: "ENGLAND"}, + "local_authority": {2025: council.local_authority}, + "council_tax_band": {2025: council.council_tax_band}, + "council_tax": {2025: council.council_tax}, + "savings": {2025: 0}, + } + + +def build_single_jsa(council: CouncilConfig) -> dict: + return { + "people": { + "claimant": { + "age": {2025: 35}, + "jsa_income_reported": {2025: SINGLE_JSA_WEEKLY * 52}, + "council_tax_benefit_reported": {2025: 1}, + } + }, + "benunits": { + "benunit": base_benunit( + members=["claimant"], + eldest_adult_age=35, + is_single_person=True, + is_couple=False, + is_lone_parent=False, + ) + }, + "households": { + "household": base_household(council, ["claimant"]), + }, + } + + +def build_couple_jsa(council: CouncilConfig) -> dict: + return { + "people": { + "claimant": { + "age": {2025: 35}, + "jsa_income_reported": {2025: COUPLE_JSA_WEEKLY * 52}, + "council_tax_benefit_reported": {2025: 1}, + }, + "partner": { + "age": {2025: 35}, + }, + }, + "benunits": { + "benunit": base_benunit( + members=["claimant", "partner"], + eldest_adult_age=35, + is_single_person=False, + is_couple=True, + is_lone_parent=False, + ) + }, + "households": { + "household": base_household(council, ["claimant", "partner"]), + }, + } + + +def build_lone_parent_one_child_jsa(council: CouncilConfig) -> dict: + return { + "people": { + "claimant": { + "age": {2025: 35}, + "jsa_income_reported": {2025: SINGLE_JSA_WEEKLY * 52}, + "council_tax_benefit_reported": {2025: 1}, + }, + "child": { + "age": {2025: 5}, + }, + }, + "benunits": { + "benunit": base_benunit( + members=["claimant", "child"], + eldest_adult_age=35, + is_single_person=False, + is_couple=False, + is_lone_parent=True, + ) + }, + "households": { + "household": base_household(council, ["claimant", "child"]), + }, + } + + +def build_single_pension_credit(council: CouncilConfig) -> dict: + return { + "people": { + "claimant": { + "age": {2025: 70}, + "state_pension": {2025: STATE_PENSION_WEEKLY * 52}, + "council_tax_benefit_reported": {2025: 1}, + } + }, + "benunits": { + "benunit": { + **base_benunit( + members=["claimant"], + eldest_adult_age=70, + is_single_person=True, + is_couple=False, + is_lone_parent=False, + ), + "would_claim_pc": {2025: True}, + } + }, + "households": { + "household": base_household(council, ["claimant"]), + }, + } + + +SCENARIOS = { + "single_jsa": Scenario( + description="Single working-age claimant on income-based JSA.", + build_situation=build_single_jsa, + ), + "couple_jsa": Scenario( + description="Couple with no children on income-based JSA.", + build_situation=build_couple_jsa, + ), + "lone_parent_one_child_jsa": Scenario( + description="Lone parent with one child on income-based JSA.", + build_situation=build_lone_parent_one_child_jsa, + ), + "single_pension_credit": Scenario( + description="Single pension-age claimant with State Pension and Pension Credit.", + build_situation=build_single_pension_credit, + ), +} + + +def run_case(council_name: str, scenario_name: str) -> dict: + council = COUNCILS[council_name] + scenario = SCENARIOS[scenario_name] + sim = Simulation(situation=scenario.build_situation(council)) + period = 2025 + return { + "council": council_name, + "council_label": council.label, + "scenario": scenario_name, + "description": scenario.description, + "council_tax_band": council.council_tax_band, + "council_tax": council.council_tax, + "council_tax_reduction": float(sim.calculate("council_tax_reduction", period)[0]), + "council_tax_less_benefit": float( + sim.calculate("council_tax_less_benefit", period)[0] + ), + "child_benefit": float(sim.calculate("child_benefit", period)[0]), + "pension_credit": float(sim.calculate("pension_credit", period)[0]), + "state_pension": float(sim.calculate("state_pension", period)[0]), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--scenario", + action="append", + choices=[*SCENARIOS.keys(), "all"], + default=None, + ) + parser.add_argument( + "--council", + action="append", + choices=[*COUNCILS.keys(), "all"], + default=None, + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + scenario_names = args.scenario or ["single_jsa"] + if "all" in scenario_names: + scenario_names = list(SCENARIOS.keys()) + else: + scenario_names = list(dict.fromkeys(scenario_names)) + + council_names = args.council or ["all"] + if "all" in council_names: + council_names = list(COUNCILS.keys()) + else: + council_names = list(dict.fromkeys(council_names)) + + results = [] + for scenario_name in scenario_names: + for council_name in council_names: + results.append(run_case(council_name, scenario_name)) + + json.dump(results, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/turn2us_ctr_compare.py b/scripts/turn2us_ctr_compare.py new file mode 100644 index 000000000..bc0c05533 --- /dev/null +++ b/scripts/turn2us_ctr_compare.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sys +from dataclasses import dataclass +from typing import Any, Callable + +import requests + + +BASE_URL = "https://benefits-calculator-api.turn2us.org.uk" +USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/136.0.0.0 Safari/537.36" +) +STATE = { + "partner": None, + "referral": None, + "subCategory": None, + "test": None, + "pipSessionId": None, + "isMobile": False, + "browserVersion": "136.0.0.0", + "browserName": "Chrome", + "OsName": "Mac OS", + "browserId": "policyengine_turn2us_probe", +} + + +@dataclass(frozen=True) +class Scenario: + description: str + build_steps: Callable[[dict[str, Any]], list[dict[str, Any]]] + + +def build_single_jsa_steps(postcode_payload: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "ClientMemberHousehold": "Yes", + "ClientPartnerStudent": "No", + "ClientPartnerPrison": "No", + "ClientPartnerHospital": "No", + }, + {}, + { + "ClientPostcode": postcode_payload, + "ClientLiveWithPartner": "No", + "ClientDob": "01/01/1991", + }, + {}, + {"ClientPartnerReceivingBens": "Yes"}, + { + "ClientPartnerUcReceiving": "No", + "ClientPartnerIrEsa": "No", + "ClientPartnerIncomeSupport": "No", + "ClientPartnerIbJsa": "Yes", + "IbJsaSingleCouple": "Single", + "ClientPartnerHousingBenefit": "No", + "ManagedMigration": "No", + }, + {"ClientIncomeFromBenefits": ["None of the above"]}, + {}, + {"ClientSicknessDisability": "No", "ClientRegisteredBlind": "No"}, + {}, + {"ClientCarer": "No", "ClientCarerAllow": "No", "ClientCarerSupport": "No"}, + {}, + { + "ClientIsApprovedFosterCarer": "No", + "ClientHasChildren": "No", + "ClientExpectingBaby": "No", + }, + {}, + {}, + {"ClientHousingCosts": "I own my home with a mortgage"}, + {"BetterOffCalc": "No", "ClientWorking": "Unemployed and looking for work"}, + {"ClientWarPension": "No"}, + {"ClientSpousalMaintenance": "No"}, + {}, + {"ClientPropertyCommercial": "No"}, + {"ClientCapitalEstimate": "£0 - £5000"}, + ] + + +def build_couple_jsa_steps(postcode_payload: dict[str, Any]) -> list[dict[str, Any]]: + steps = build_single_jsa_steps(postcode_payload) + steps[2] = { + **steps[2], + "ClientLiveWithPartner": "Yes", + "PartnerDob": "01/01/1991", + } + steps[5] = {**steps[5], "IbJsaSingleCouple": "Couple"} + # Turn2us inserts partner-only work / pension pages for couples. + steps.extend([{}, {}, {}, {}, {}]) + return steps + + +def build_lone_parent_one_child_jsa_steps( + postcode_payload: dict[str, Any] +) -> list[dict[str, Any]]: + steps = build_single_jsa_steps(postcode_payload) + steps[12] = { + **steps[12], + "ClientHasChildren": "Yes", + "ClientDependentChildren": "1", + "ClientExpectingBaby": "No", + } + steps[13] = { + "ChildGenderOne": "Male", + "ChildDobOne": "01/01/2021", + "ChildDisabilityOne": "No", + } + return steps + + +def build_single_pension_credit_steps( + postcode_payload: dict[str, Any] +) -> list[dict[str, Any]]: + steps = build_single_jsa_steps(postcode_payload) + steps[2] = {**steps[2], "ClientDob": "01/01/1956"} + steps[5] = { + "ClientPartnerUcReceiving": "No", + "ClientPartnerIrEsa": "No", + "ClientPartnerIncomeSupport": "No", + "ClientPartnerIbJsa": "No", + "ClientPartnerHousingBenefit": "No", + "ClientPartnerPensionCredit": "Yes", + "ClientSavingPension": "Yes", + "ManagedMigration": "No", + } + steps[17] = { + "ClientIncomeStatePension": {"weekly": "185.15", "period": "weekly"}, + "ClientStatePensionDeferred": {"weekly": "0", "period": "weekly"}, + "ClientIncomeOccupationalPension": {"weekly": "0", "period": "weekly"}, + } + return steps + + +SCENARIOS = { + "single_jsa": Scenario( + description="Single working-age claimant on income-based JSA.", + build_steps=build_single_jsa_steps, + ), + "couple_jsa": Scenario( + description="Couple with no children on income-based JSA.", + build_steps=build_couple_jsa_steps, + ), + "lone_parent_one_child_jsa": Scenario( + description="Lone parent with one child on income-based JSA.", + build_steps=build_lone_parent_one_child_jsa_steps, + ), + "single_pension_credit": Scenario( + description="Single pension-age claimant with Pension Credit and State Pension.", + build_steps=build_single_pension_credit_steps, + ), +} + + +def build_session() -> requests.Session: + session = requests.Session() + session.headers.update( + { + "User-Agent": USER_AGENT, + "Accept": "application/json, text/plain, */*", + "Origin": "https://benefits-calculator.turn2us.org.uk", + "Referer": "https://benefits-calculator.turn2us.org.uk/", + } + ) + return session + + +def resolve_postcode(session: requests.Session, postcode: str) -> dict[str, Any]: + response = session.post( + f"{BASE_URL}/api/data/postcode/", + json={"postcode": postcode, "browserId": STATE["browserId"]}, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + if not payload.get("valid"): + raise RuntimeError(f"Turn2us rejected postcode {postcode!r}: {payload}") + return payload["value"] + + +def start_survey(session: requests.Session) -> dict[str, Any]: + response = session.get(f"{BASE_URL}/api/data/survey", timeout=30) + response.raise_for_status() + return response.json() + + +def advance_survey( + session: requests.Session, state: dict[str, Any], answers: dict[str, Any] +) -> dict[str, Any]: + payload = { + **answers, + "session": state["session"], + "page": state["pageNumber"], + "action": 2, + **STATE, + } + response = session.post(f"{BASE_URL}/api/data/survey/", json=payload, timeout=30) + response.raise_for_status() + return response.json() + + +def fetch_results(session: requests.Session, survey_state: dict[str, Any]) -> dict[str, Any]: + response = session.get( + f"{BASE_URL}/api/data/result/?session={survey_state['session']}", + timeout=30, + ) + response.raise_for_status() + return response.json() + + +def benefit_names(result: dict[str, Any], bucket: str) -> list[str]: + return [ + item.get("benefitName") + for item in result["benefits"].get(bucket, []) or [] + if item.get("benefitName") + ] + + +def bucket_totals(result: dict[str, Any], bucket: str) -> list[dict[str, Any]]: + return [ + { + "benefit_name": item.get("benefitName"), + "weekly_payment": item.get("weeklyPayment"), + "monthly_payment": item.get("monthlyPayment"), + } + for item in result["benefits"].get(bucket, []) or [] + ] + + +def find_council_tax_mentions(result: dict[str, Any]) -> list[str]: + mentions: list[str] = [] + for bucket in ("meansTested", "nonMeansTested", "universalCredit"): + for item in result["benefits"].get(bucket, []) or []: + benefit_name = item.get("benefitName") or "" + if "council" in benefit_name.lower(): + mentions.append(benefit_name) + for element in item.get("elements", []) or []: + name = element.get("name") or "" + if "council" in name.lower(): + mentions.append(name) + for deduction in item.get("deductions", []) or []: + name = deduction.get("name") or "" + if "council" in name.lower(): + mentions.append(name) + return mentions + + +def run_case(postcode: str, scenario_name: str) -> dict[str, Any]: + session = build_session() + postcode_payload = resolve_postcode(session, postcode) + survey_state = start_survey(session) + scenario = SCENARIOS[scenario_name] + for answers in scenario.build_steps(postcode_payload): + survey_state = advance_survey(session, survey_state, answers) + for _ in range(10): + if survey_state["pageNumber"] == 109: + break + survey_state = advance_survey(session, survey_state, {}) + result = fetch_results(session, survey_state) + return { + "scenario": scenario_name, + "description": scenario.description, + "postcode": postcode, + "authority": postcode_payload["authority"], + "authority_code": postcode_payload["authorityCode"], + "country": postcode_payload["country"], + "final_page_number": survey_state["pageNumber"], + "final_page_title": survey_state["page"]["title"], + "reached_results_page": survey_state["pageNumber"] == 109, + "means_tested": bucket_totals(result, "meansTested"), + "universal_credit": bucket_totals(result, "universalCredit"), + "non_means_tested": bucket_totals(result, "nonMeansTested"), + "council_tax_mentions": find_council_tax_mentions(result), + "messages_with_council_tax_terms": [ + message + for message in result.get("messages", []) or [] + if "council" in json.dumps(message).lower() + ], + "raw_result_path": None, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("postcode", nargs="+") + parser.add_argument( + "--scenario", + action="append", + choices=[*SCENARIOS.keys(), "all"], + default=None, + ) + parser.add_argument("--save-raw-dir") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + scenario_names = args.scenario or ["single_jsa"] + if "all" in scenario_names: + scenario_names = list(SCENARIOS.keys()) + else: + scenario_names = list(dict.fromkeys(scenario_names)) + results = [] + for scenario_name in scenario_names: + for postcode in args.postcode: + case = run_case(postcode, scenario_name) + if args.save_raw_dir is not None: + from pathlib import Path + + raw_dir = Path(args.save_raw_dir) + raw_dir.mkdir(parents=True, exist_ok=True) + out_path = raw_dir / ( + f"{scenario_name}_{postcode.replace(' ', '_')}.json" + ) + out_path.write_text(json.dumps(case, indent=2)) + case["raw_result_path"] = str(out_path) + results.append(case) + json.dump(results, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())