diff --git a/changelog.d/cb-qyp-school-leaver-routes.fixed.md b/changelog.d/cb-qyp-school-leaver-routes.fixed.md new file mode 100644 index 0000000000..ae0e08f434 --- /dev/null +++ b/changelog.d/cb-qyp-school-leaver-routes.fixed.md @@ -0,0 +1 @@ +- Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7): a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, and two new inputs place the 20-week extension period and the terminal date. Housing Benefit, the legacy benefits and Council Tax Reduction young persons follow. Child Benefit is not paid for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). diff --git a/changelog.d/ctc-qyp-school-leaver-routes.fixed.md b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md new file mode 100644 index 0000000000..33a0ea5ad5 --- /dev/null +++ b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md @@ -0,0 +1 @@ +- Counted a 16-year-old who has left education as a Child Tax Credit qualifying young person until the 31 August after their 16th birthday, in any year that starts before it (Child Tax Credit Regulations 2002 reg. 4), and added an input for the 20 weeks after leaving while registered for work or training (reg. 5(3)(b)). Remunerative work of 24 hours a week ends both for a young person who has left education (reg. 5(4)(a)). diff --git a/changelog.d/means-tests-dependants-income.fixed.md b/changelog.d/means-tests-dependants-income.fixed.md new file mode 100644 index 0000000000..30f5b270a6 --- /dev/null +++ b/changelog.d/means-tests-dependants-income.fixed.md @@ -0,0 +1 @@ +Housing Benefit, Income Support, tax credit and Council Tax Reduction means tests no longer count a child's or young person's own income, tax or personal benefits as the claimant's (HB Regs 2006 reg 25(3), IS Regs 1987 reg 23(2), Tax Credits (Definition and Calculation of Income) Regs 2002 reg 3(1), the Council Tax Reduction applicant-and-partner rules). diff --git a/changelog.d/qyp-income-support-exclusion.fixed.md b/changelog.d/qyp-income-support-exclusion.fixed.md new file mode 100644 index 0000000000..35769651aa --- /dev/null +++ b/changelog.d/qyp-income-support-exclusion.fixed.md @@ -0,0 +1 @@ +- Stopped counting a young person who receives Income Support in their own right as a qualifying young person for Pension Credit, Child Benefit (and so Housing Benefit and the legacy benefits) and Child Tax Credit (SPC Regs 2002 reg. 4A(5); Child Benefit (General) Regulations 2006 reg. 8(2)(a); CTC Regs 2002 reg. 5(4)(c)), read from their own reported Income Support while it was in payment. diff --git a/changelog.d/qyp-terminal-date-19.fixed.md b/changelog.d/qyp-terminal-date-19.fixed.md new file mode 100644 index 0000000000..f6ffcb4951 --- /dev/null +++ b/changelog.d/qyp-terminal-date-19.fixed.md @@ -0,0 +1 @@ +- Placed the 1 September following a qualifying young person's 19th birthday from their date of birth, for Universal Credit and Pension Credit (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 reg. 4A(1)(b)). A 19-year-old in education now counts for Universal Credit by default in household calculations, as in the microdata, and Pension Credit applies the date, which it did not model. diff --git a/changelog.d/uc-pc-qyp-sixteen-route.fixed.md b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md new file mode 100644 index 0000000000..7c8723f135 --- /dev/null +++ b/changelog.d/uc-pc-qyp-sixteen-route.fixed.md @@ -0,0 +1 @@ +- Counted a 16-year-old outside education as a Universal Credit and Pension Credit qualifying young person until the 1 September following their 16th birthday (UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a)), in any year that starts before that date. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml new file mode 100644 index 0000000000..09616dbd7b --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age from which Pension Credit qualifying young person status is limited to the period before the 1 September following the 19th birthday. +values: + 0001-01-01: 19 +metadata: + unit: year + label: Pension Credit qualifying young person terminal date age limit + reference: + - title: The State Pension Credit Regulations 2002 reg. 4A(1)(b) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml new file mode 100644 index 0000000000..2266a1fe0c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age limit below which a person who has left education or training may be a Child Tax Credit qualifying young person for 20 weeks while registered for work or training. +values: + 0001-01-01: 18 +metadata: + unit: year + label: Child Tax Credit extension period age limit + reference: + - title: The Child Tax Credit Regulations 2002 reg. 5(3)(b) + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml new file mode 100644 index 0000000000..dc614090d1 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml @@ -0,0 +1,9 @@ +description: Weekly hours of paid work from which a person is in remunerative work for Child Tax Credit rules. +values: + 0001-01-01: 24 +metadata: + unit: hour + label: Child Tax Credit remunerative work hours + reference: + - title: The Child Tax Credit Regulations 2002 reg. 2(1), "remunerative work" + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/2 diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml new file mode 100644 index 0000000000..54c952537e --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age limit below which a person who has left education or training may be a Child Benefit qualifying young person during an extension period. +values: + 0001-01-01: 18 +metadata: + unit: year + label: Child Benefit extension period age limit + reference: + - title: Child Benefit (General) Regulations 2006 reg. 5(1) + href: https://www.legislation.gov.uk/uksi/2006/223/regulation/5 diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml new file mode 100644 index 0000000000..e6271ff838 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml @@ -0,0 +1,9 @@ +description: Weekly hours of paid work from which a person is in remunerative work for Child Benefit rules. +values: + 0001-01-01: 24 +metadata: + unit: hour + label: Child Benefit remunerative work hours + reference: + - title: Child Benefit (General) Regulations 2006 reg. 1(3), "remunerative work" + href: https://www.legislation.gov.uk/uksi/2006/223/regulation/1 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml index aaf0b00010..4b062a0214 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml @@ -92,6 +92,9 @@ age: 19 age_started_or_accepted_current_education_or_training: 18 current_education: POST_SECONDARY + # Set explicitly: a person left unset takes the date fact's value, + # which is true for anyone under 20 on 6 October. + is_before_universal_credit_qualifying_young_person_terminal_date: false under_terminal_date_age_limit: age: 18 current_education: POST_SECONDARY diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml new file mode 100644 index 0000000000..a77dc789c2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -0,0 +1,355 @@ +# Child Tax Credit qualifying young persons who have left education: Child +# Tax Credit Regulations 2002 reg. 4 (from the 16th birthday until the 31 +# August that next follows, with no education condition), reg. 5(3)(b) (20 +# weeks after leaving, under 18, while registered for work or training) and +# the second condition in reg. 5(4), which applies to both: not in +# remunerative work (24 hours a week or more, reg. 2(1)) after leaving, and +# not receiving a benefit in their own right. +# +# The child element was £3,455 in 2024-25, the last year of tax credits +# (gov/dwp/tax_credits/child_tax_credit/elements/child_element.yaml). + +- name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4(1)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + is_child_or_qualifying_young_person_for_child_tax_credit: [false, true] + is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit: true + CTC_child_element: 3_455 + +- name: Case 2, a 16-year-old who has left education and works 24 hours a week is not a qualifying young person (reg. 5(4)(a)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_tax_credit: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + # Child Benefit reg. 4 has no remunerative work bar. + is_qualifying_young_person_for_child_benefit: [false, true] + +- name: Case 3, working 23 hours a week does not end the period. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + weekly_hours: 23 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 4, remunerative work does not matter for a 16-year-old still at school. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pupil: + age: 16 + current_education: UPPER_SECONDARY + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, pupil] + output: + # Reg. 5(4)(a) applies to a person "having ceased" education or training. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 5, a 16-year-old apprentice working 30 hours a week is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + output: + # Education "by virtue of his employment" is not the first condition + # (reg. 5(3)(a)(ii)), so the apprentice has left it and is in + # remunerative work. + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 6, born 15 September 2007, the period runs to 31 August 2024. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20070915 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 7, born 31 August 2007, reg. 4(1A) gives no period to the next 31 August. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20070831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 8, born 31 August 2008, the year in which they are a child until 30 August counts. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + date_of_birth: 20080831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 9, a 16-year-old receiving a benefit in their own right is not a qualifying young person (reg. 5(4)(c)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 10, a 17-year-old who has left education is not a qualifying young person by default. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 11, a 17-year-old in the 20 weeks after leaving, registered for work or training, is a qualifying young person (reg. 5(3)(b)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 12, the 20 weeks end if the 17-year-old starts remunerative work. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 13, the 20 weeks are for under-18s only. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 14, a lone 16-year-old who has left education is not their own qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + benunit: + members: [school_leaver] + output: + is_qualifying_young_person_for_child_tax_credit: true + is_child_or_qualifying_young_person_for_child_tax_credit: false + CTC_child_element: 0 + +- name: Case 15, a 16-year-old in full-time advanced education working 24 hours a week is a qualifying young person (reg. 4(2), 5(4)(a), 5(5)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 16 + date_of_birth: 20080101 + current_education: TERTIARY + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # Advanced education fails the first condition, which reg. 4(2) lifts. + # It is still full-time education (reg. 5(5)), so the student has not + # "ceased" it and the remunerative work bar does not apply. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 16, the same advanced student aged 17, outside the reg. 4 period, is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + date_of_birth: 20070101 + current_education: TERTIARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml index 0f57841821..7aa89636c1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml @@ -218,7 +218,33 @@ num_relevant_children_for_housing_benefit_childcare: 0 housing_benefit_applicable_income_childcare_element: 0 -- name: Disabled 16-year-old outside education and training is not a relevant young person +- name: Disabled 16-year-old outside education and training is a relevant young person until the 31 August after their 16th birthday + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9_000 + young_person: + age: 16 + is_disabled_for_benefits: true + is_in_non_advanced_education: false + is_in_approved_training: false + benunits: + benunit: + members: [claimant, young_person] + output: + # A 16-year-old who has left education is a young person until that 31 + # August (Child Benefit (General) Regulations 2006 reg. 4; HB reg 19(1)), + # in any year that starts before it. HB reg 28(6) then counts a disabled + # one: min(9,000, one-child cap * 52). + is_child_or_young_person_for_legacy_benefits: [false, true] + num_relevant_children_for_housing_benefit_childcare: 1 + housing_benefit_applicable_income_childcare_element: 9_000 + +- name: Disabled 16-year-old outside education and training is not a relevant young person after that 31 August period: 2025 input: people: @@ -231,11 +257,13 @@ is_disabled_for_benefits: true is_in_non_advanced_education: false is_in_approved_training: false + is_before_first_september_following_16th_birthday: false benunits: benunit: members: [claimant, young_person] output: # HB reg 28(6) requires a child of the family, including a qualifying # young person; the disability age extension does not create that status. + is_child_or_young_person_for_legacy_benefits: [false, false] num_relevant_children_for_housing_benefit_childcare: 0 housing_benefit_applicable_income_childcare_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/is_uc_work_allowance_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/is_uc_work_allowance_eligible.yaml index 4710360580..362a550cd7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/is_uc_work_allowance_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/is_uc_work_allowance_eligible.yaml @@ -79,7 +79,7 @@ output: is_uc_work_allowance_eligible: false -- name: Case 6, a nonqualifying 16-year-old does not create a work allowance. +- name: Case 6, a 16-year-old outside education past the reg. 5(1)(a) route does not create a work allowance. period: 2025 input: people: @@ -91,6 +91,7 @@ age: 16 is_in_non_advanced_education: false is_in_approved_training: false + is_before_first_september_following_16th_birthday: false uc_limited_capability_for_WRA: false benunits: benunit: @@ -98,6 +99,25 @@ output: is_uc_work_allowance_eligible: false +- name: Case 6a, a 16-year-old outside education in the reg. 5(1)(a) route creates a work allowance. + period: 2025 + input: + people: + person1: + age: 40 + is_parent: true + uc_limited_capability_for_WRA: false + person2: + age: 16 + is_in_non_advanced_education: false + is_in_approved_training: false + uc_limited_capability_for_WRA: false + benunits: + benunit: + members: [person1, person2] + output: + is_uc_work_allowance_eligible: true + - name: Case 7, a looked-after child does not create a work allowance. period: 2025 input: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/means_tests_young_person_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/means_tests_young_person_income.yaml new file mode 100644 index 0000000000..87f5a99ce4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/means_tests_young_person_income.yaml @@ -0,0 +1,111 @@ +# A child's or young person's own income is never the claimant's: HB Regs +# 2006 reg 25(3), HB (SPC) Regs 2006 reg 23(1), IS Regs 1987 reg 23(2), Tax +# Credits (Definition and Calculation of Income) Regs 2002 reg 3(1), and the +# applicant-and-partner rules of every Council Tax Reduction scheme (SSI +# 2021/249 reg 36(2) says it in terms). +# +# The family in each case: a lone parent and a 17-year-old at school who earns +# £8,000, below the income tax and National Insurance thresholds. Child +# Benefit is not claimed, so it adds nothing to the income totals. + +- name: Case 1, a young person's earnings are not the parent's income in any of the four means tests. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + employment_income: 8_000 + benunits: + benunit: + members: [parent, student] + would_claim_child_benefit: false + would_claim_uc: false + households: + household: + members: [parent, student] + country: ENGLAND + rent: 6_000 + council_tax: 1_800 + output: + # The student is still the family's young person. + is_child_or_young_person_for_legacy_benefits: [false, true] + housing_benefit_applicable_income: 0 + income_support_applicable_income: 0 + tax_credits_applicable_income: 0 + council_tax_reduction_applicable_income: 0 + +- name: Case 2, the parent's own earnings still count. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + employment_income: 5_000 + student: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + employment_income: 8_000 + benunits: + benunit: + members: [parent, student] + would_claim_child_benefit: false + would_claim_uc: false + households: + household: + members: [parent, student] + country: ENGLAND + rent: 6_000 + council_tax: 1_800 + output: + # £5,000 less the lone parent earnings disregard of £25 a week. + housing_benefit_applicable_income: 3_700 + # £5,000 less the lone parent disregard of £20 a week. + income_support_applicable_income: 3_960 + tax_credits_applicable_income: 5_000 + council_tax_reduction_applicable_income: 5_000 + +- name: Case 3, a pension-age Housing Benefit claimant's grandchild's earnings are not the claimant's. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_parent: true + is_claimant_or_partner: true + # Above the Pension Credit guarantee, so no passport zeroes the income. + private_pension_income: 20_000 + student: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + employment_income: 8_000 + benunits: + benunit: + members: [grandparent, student] + would_claim_child_benefit: false + would_claim_uc: false + households: + household: + members: [grandparent, student] + country: ENGLAND + rent: 6_000 + council_tax: 1_800 + output: + housing_benefit_pension_age_regulations_apply: true + pension_credit: 0 + # The pension of £20,000 less income tax of 20% of (20,000 - 12,570), + # £1,486; not the student's £8,000. + housing_benefit_applicable_income: 18_514 + council_tax_reduction_applicable_income: 18_514 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml new file mode 100644 index 0000000000..3d25203df1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml @@ -0,0 +1,165 @@ +# Income Support in a young person's own right ends qualifying young person +# status for Pension Credit (SPC Regs 2002 reg. 4A(5)), Child Benefit (Child +# Benefit (General) Regulations 2006 reg. 8(2)(a)) and Child Tax Credit (CTC +# Regs 2002 reg. 5(4)(c)). Universal Credit's list (UC Regs 2013 reg. 5(5)) +# names Universal Credit, Employment and Support Allowance and Jobseeker's +# Allowance only. +# +# Income Support stopped paying on 31 March 2026 +# (gov/dwp/income_support/active.yaml), so reported receipt counts to 2025-26. +# 2025-26 amounts: Child Benefit eldest child £26.05 a week; Pension Credit +# first-child addition £78.10 a week. CTC child element £3,455 in 2024-25. + +- name: Case 1, a 17-year-old at school with no Income Support is every programme's qualifying young person. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + # 78.10 * 52. + child_minimum_guarantee_addition: 4_061.20 + # 26.05 * 52. + child_benefit_entitlement: 1_354.60 + +- name: Case 2, reported Income Support of their own ends it for Pension Credit and Child Benefit, not Universal Credit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + child_minimum_guarantee_addition: 0 + child_benefit_entitlement: 0 + is_child_or_young_person_for_legacy_benefits: [false, false] + +- name: Case 3, the same for Child Tax Credit in 2024-25. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 4, without it the Child Tax Credit child element is paid. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 5, it ends the 16-year-old's status too, which has no education condition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, school_leaver] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + +- name: Case 6, a report of Income Support after it stopped paying does not count. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, true] + +- name: Case 7, the input can be set directly. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + receives_income_support_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml new file mode 100644 index 0000000000..b06ae2d535 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_no_education_route.yaml @@ -0,0 +1,335 @@ +# UC Regs 2013 reg. 5(1)(a) and SPC Regs 2002 reg. 4A(1)(a): a person who has +# reached 16 is a qualifying young person "up to, but not including, the 1st +# September following" their 16th birthday, whatever their education. The +# model counts the route for a year that starts (6 April) before that date. +# Ages are ages on 6 October; with no date of birth, a whole age places the +# birthday on 6 April (months_since_last_birthday 6). + +- name: A 16-year-old outside education is a qualifying young person for UC and Pension Credit. + # 16th birthday 6 April 2026; the route runs to 31 August 2026. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + output: + is_in_non_advanced_education: [false, false] + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + is_child_or_qualifying_young_person_for_pension_credit: [false, true] + +- name: A 16-year-old apprentice qualifies through reg. 5(1)(a), which has no contract-of-employment exclusion. + # Reg. 5(3) excludes training under a contract of employment from the + # education route (b) only. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + benunits: + benunit: + members: [parent, young_person] + output: + is_in_non_advanced_education: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: A 16-year-old outside education whose route has ended for the year is not a qualifying young person. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + is_before_first_september_following_16th_birthday: false + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + +- name: The education route still applies after the 1 September following the 16th birthday. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: UPPER_SECONDARY + is_before_first_september_following_16th_birthday: false + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: A 17-year-old with a 16th birthday on 15 September 2025 is in the route until 31 August 2026. + # Born 15 September 2009: aged 17 on 6 October 2026. The 1 September + # following the 16th birthday is 1 September 2026, after 6 April 2026. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + date_of_birth: 20090915 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + output: + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: A 16th birthday on 1 September runs the route to the 17th birthday. + # Born 1 September 2009: the 1 September following the 16th birthday (1 + # September 2025) is 1 September 2026, after 6 April 2026. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + date_of_birth: 20090901 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + output: + is_before_first_september_following_16th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + +- name: A 16th birthday on 31 August ends the route that day. + # Born 31 August 2009: the route ran only on 31 August 2025, before the + # 2026-27 fiscal year starts. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 17 + date_of_birth: 20090831 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + output: + is_before_first_september_following_16th_birthday: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: A 16-year-old receiving Universal Credit in their own right is not a qualifying young person. + # Reg. 5(5) and reg. 4A(5) apply to both routes. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: A looked-after 16-year-old is a qualifying young person but not the family's. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + is_looked_after_by_local_authority: true + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_child_or_qualifying_young_person_for_pension_credit: [false, false] + +- name: Neither 15- nor 18-year-olds outside education are qualifying young persons. + # A 15-year-old is a child (Welfare Reform Act 2012 s. 40); an 18-year-old's + # 1 September following their 16th birthday has passed. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + child: + age: 15 + current_education: NOT_IN_EDUCATION + adult_child: + age: 18 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, child, adult_child] + output: + is_before_first_september_following_16th_birthday: [false, true, false] + is_qualifying_young_person_for_universal_credit: [false, false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, true, false] + is_qualifying_young_person_for_pension_credit: [false, false, false] + +- name: A lone parent with a 16-year-old outside education gets the child element and a work allowance. + # 2026-27: born 6 April 2010, before 6 April 2017, so the higher first-child + # amount: GBP 351.88 a month x 12 = GBP 4,222.56. Responsibility for a + # qualifying young person gives the work allowance (reg. 22(1)(b)): no + # housing element, so GBP 710 a month x 12 = GBP 8,520. Maximum amount: the + # single 25-or-over standard allowance GBP 424.90 x 12 = GBP 5,098.80, plus + # the child element, = GBP 9,321.36. + period: 2026 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + output: + uc_individual_child_element: [0, 4_222.56] + uc_child_element: 4_222.56 + is_responsible_for_child_or_qualifying_young_person_for_universal_credit: true + is_uc_work_allowance_eligible: true + uc_work_allowance: 8_520 + uc_maximum_amount: 9_321.36 + is_benefit_cap_single_claimant_rate: false + +- name: A pensioner with a 16-year-old outside education gets the Pension Credit child addition. + # 2026-27: born 6 April 2010, before 6 April 2017, so the first child + # addition (Sch. IIA para. 10): GBP 81.07 a week x 52 = GBP 4,215.64. The + # single standard minimum guarantee is GBP 238.00 x 52 = GBP 12,376, so the + # minimum guarantee is GBP 16,591.64. + period: 2026 + input: + people: + pensioner: + age: 70 + is_SP_age: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [pensioner, young_person] + households: + household: + members: [pensioner, young_person] + output: + child_minimum_guarantee_addition: 4_215.64 + standard_minimum_guarantee: 12_376 + minimum_guarantee: 16_591.64 + +- name: In 2025-26 the 16-year-old counts first under the two-child limit, and the youngest child loses the element. + # 2025-26: the 16-year-old (born 6 April 2009) is a qualifying young person + # through reg. 5(1)(a), so the eldest of three. The higher first-child + # amount: GBP 339 a month x 12 = GBP 4,068. The 7-year-old (born 6 April + # 2018): GBP 292.81 x 12 = GBP 3,513.72. The 5-year-old (born 6 April 2020, + # after 6 April 2017) is the third child, so no element (reg. 24A). Total + # GBP 7,581.72. + period: 2025 + input: + people: + parent: + age: 40 + is_parent: true + young_person: + age: 16 + current_education: NOT_IN_EDUCATION + child_7: + age: 7 + child_5: + age: 5 + benunits: + benunit: + members: [parent, young_person, child_7, child_5] + households: + household: + members: [parent, young_person, child_7, child_5] + output: + uc_child_index: [-1, 1, 2, 3] + uc_individual_child_element: [0, 4_068, 3_513.72, 0] + uc_is_child_limit_affected: [false, false, false, true] + uc_child_element: 7_581.72 + +- name: A date of birth given for one person leaves another's age-placed date unchanged. + # The 17-year-old's date of birth (15 September 2009) puts them in the + # route; the 16-year-old elsewhere has none, so age places their birthday + # on 6 April 2010 and they are in it too. + period: 2026 + input: + people: + parent_1: + age: 34 + is_parent: true + young_person_17: + age: 17 + date_of_birth: 20090915 + current_education: NOT_IN_EDUCATION + parent_2: + age: 40 + is_parent: true + young_person_16: + age: 16 + current_education: NOT_IN_EDUCATION + benunits: + benunit_1: + members: [parent_1, young_person_17] + benunit_2: + members: [parent_2, young_person_16] + households: + household_1: + members: [parent_1, young_person_17] + household_2: + members: [parent_2, young_person_16] + output: + is_before_first_september_following_16th_birthday: [false, true, false, true] + is_qualifying_young_person_for_universal_credit: [false, true, false, true] + is_qualifying_young_person_for_pension_credit: [false, true, false, true] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml new file mode 100644 index 0000000000..3a3e2493f8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml @@ -0,0 +1,296 @@ +# A young person in non-advanced education or approved training is a +# qualifying young person "up to, but not including, the 1st September +# following their 19th birthday" (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 +# reg. 4A(1)(b)). The model places that date from the date of birth and +# counts a year that starts (6 April) before it, so every 19-year-old counts +# unless an input says the date has passed. +# +# 2026-27 amounts: Universal Credit child element for a first child born +# before 6 April 2017, £351.88 a month +# (gov/dwp/universal_credit/elements/child/first/higher_amount.yaml); +# Pension Credit first-child addition, £81.07 a week +# (gov/dwp/pension_credit/guarantee_credit/child/first/addition.yaml). + +- name: Case 1, a 19-year-old at school is a Universal Credit and Pension Credit qualifying young person by default. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_before_universal_credit_qualifying_young_person_terminal_date: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + # 351.88 * 12. + uc_individual_child_element: [0, 4_222.56] + is_uc_work_allowance_eligible: true + +- name: Case 2, the Universal Credit input ends Universal Credit status only. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: false + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + uc_individual_child_element: [0, 0] + is_uc_work_allowance_eligible: false + +- name: Case 3, the date fact set false ends both. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # The parent, not set, takes the default, true: harmless, since every + # reader is behind the qualifying young person age limit. + is_before_universal_credit_qualifying_young_person_terminal_date: [true, false] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + +- name: Case 4, a pensioner gets the Pension Credit child addition for a 19-year-old at school. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_qualifying_young_person_for_pension_credit: [false, true] + # 81.07 * 52. + child_minimum_guarantee_addition: 4_215.64 + +- name: Case 5, no Pension Credit child addition once the 1 September after the 19th birthday has passed. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + child_minimum_guarantee_addition: 0 + +- name: Case 6, born 31 August 2007, the period ends on the 19th birthday, in the year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + date_of_birth: 20070831 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: Case 7, born 15 September 2006, before the date until 31 August 2026 but aged 20 on 6 October. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060915 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + # The age limit, read on 6 October, excludes them. + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 8, born 31 August 2006, the 1 September after the 19th birthday was in 2025. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060831 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: Case 9, a course begun at 19 does not qualify, whatever the date (reg. 5(2); reg. 4A(2)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 19 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 10, a 19-year-old outside education is not a qualifying young person, whatever the date. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_adult: + age: 19 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_adult] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 11, born 1 September 2006, the 1 September following the 19th birthday is in 2026, not the birthday itself. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060901 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # The 19th birthday is 1 September 2025; the 1 September that follows it + # is in 2026, inside the year. + is_before_first_september_following_19th_birthday: [false, true] + # The age limit, read on 6 October, excludes them. + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 12, setting the date fact for one 19-year-old leaves another's status as the formula gives it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + first: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + second: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, first, second] + output: + # The second takes the default, true, which is the formula's value for + # anyone under 20 on 6 October. + is_before_first_september_following_19th_birthday: [true, false, true] + is_qualifying_young_person_for_universal_credit: [false, false, true] + is_qualifying_young_person_for_pension_credit: [false, false, true] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml index a9d6e1605e..ce709391d1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/means_test_income_members.yaml @@ -47,7 +47,7 @@ output: universal_credit: 9_321.36 -- name: A 17-year-old outside education is not a UC young person, so their pay does not count +- name: A 17-year-old outside education and past the 1 September after their 16th birthday is not a UC young person, so their pay does not count period: 2026 absolute_error_margin: 1 input: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml new file mode 100644 index 0000000000..75a9d25169 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -0,0 +1,394 @@ +# The Child Benefit qualifying young person conditions for a young person who +# has left education: Child Benefit (General) Regulations 2006 reg. 4 (until +# the 31 August after the 16th birthday), reg. 5 (the 20-week extension +# period for a 16- or 17-year-old), reg. 7 (until the terminal date) and +# reg. 7(3) (no Child Benefit by virtue of reg. 7 in a week of remunerative +# work). Reg. 2(2): a qualifying young person until the last applicable +# condition ceases to be met. Reg. 8: not while receiving a benefit in their +# own right. +# +# Child Benefit in 2026-27: £27.05 a week for the eldest child and £17.90 for +# each other child (gov/hmrc/child_benefit/amount). Housing Benefit and the +# other legacy schemes take their young person from the same definition +# (SSCBA 1992 s.142; HB Regs 2006 reg. 19). + +- name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_or_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, true] + # 27.05 * 52. + child_benefit_entitlement: 1_406.60 + # The legacy schemes' young person follows Child Benefit. + is_child_or_young_person_for_legacy_benefits: [false, true] + is_lone_parent: true + +- name: Case 2, a 16-year-old apprentice is a qualifying young person under reg. 4, which has no education condition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + # Reg. 7(3) bars only Child Benefit payable through reg. 7. + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 3, born 15 September 2009, the 31 August after the 16th birthday is in 2026-27 (reg. 4). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20090915 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 4, born 31 August 2009, reg. 4 ended on 1 September 2025, before the year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20090831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + is_child_or_young_person_for_legacy_benefits: [false, false] + is_lone_parent: false + +- name: Case 5, a 17-year-old and an 18-year-old who have left education are not qualifying young persons by default. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + seventeen: + age: 17 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + eighteen: + age: 18 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, seventeen, eighteen] + output: + is_qualifying_young_person_for_child_benefit: [false, false, false] + child_benefit_entitlement: 0 + +- name: Case 6, a 16-year-old receiving a benefit in their own right is not a qualifying young person (reg. 8). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 7, a 17-year-old in an extension period is a qualifying young person (reg. 5). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + child_benefit_entitlement: 1_406.60 + is_lone_parent: true + +- name: Case 8, the extension period needs the young person not to be in remunerative work (reg. 5(2)(c)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_benefit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 9, the extension period is for under-18s only (reg. 5(1)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 10, an 18-year-old before the terminal date is a qualifying young person (reg. 7). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 23 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_benefit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + is_lone_parent: true + +- name: Case 11, in remunerative work a young person before the terminal date stays a qualifying young person but brings no Child Benefit (reg. 7(3)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 24 + employment_income: 20_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + households: + household: + members: [parent, school_leaver] + country: ENGLAND + output: + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_or_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, false] + child_benefit_entitlement: 0 + # Still a young person for Housing Benefit and the other legacy schemes. + is_child_or_young_person_for_legacy_benefits: [false, true] + is_lone_parent: true + # Their £20,000 of earnings is not the parent's (HB Regs 2006 reg 25(3), + # IS Regs 1987 reg 23(2), CTR applicant-and-partner rules; #2387). The + # parent has no income of their own. + housing_benefit_applicable_income: 0 + income_support_applicable_income: 0 + council_tax_reduction_applicable_income: 0 + +- name: Case 12, reg. 7(3) does not stop Child Benefit payable through another condition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 40 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + # Reg. 4 also applies at 16, and has no remunerative work bar. + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 13, a working school leaver before the terminal date does not take the eldest-child rate from a younger child. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 30 + is_claimant_or_partner: false + child: + age: 10 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver, child] + output: + child_benefit_child_index: [-1, -1, 1] + # One child at the eldest rate: 27.05 * 52. + child_benefit_entitlement: 1_406.60 + +- name: Case 14, the terminal date condition ends at 20. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 20 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 15, a 16-year-old at school and a 16-year-old who has left are both qualifying young persons, so two rates. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + at_school: + age: 16 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, at_school, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true, true] + # (27.05 + 17.90) * 52. + child_benefit_entitlement: 2_337.40 + +- name: Case 16, a lone 16-year-old who has left education is not their own qualifying young person. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + benunit: + members: [school_leaver] + output: + is_qualifying_young_person_for_child_benefit: true + is_child_or_qualifying_young_person_for_child_benefit: false + child_benefit_entitlement: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml index d08a38d0e9..1abdbeba87 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml @@ -85,12 +85,9 @@ is_council_tax_reduction_lone_parent: true # Lone parent aged 18 or over: 95.55 * 52. council_tax_reduction_applicable_amount: 4_968.60 - # Child Benefit 1,406.60 plus the young person's earnings of 2,000, which - # are below the income tax and National Insurance thresholds. Counting a - # young person's own earnings is an older approximation of the model's, - # not the law (reg. 36(2) leaves them out); this pins that the member is - # in the family, not a statutory income figure. - council_tax_reduction_applicable_income: 3_406.60 + # Child Benefit, 27.05 * 52. The young person's own earnings of 2,000 are + # not the applicant's (SSI 2021/249 reg. 36(2)). + council_tax_reduction_applicable_income: 1_406.60 - name: Case 3, England keeps the Child Benefit young person past the Universal Credit terminal date. period: 2026 @@ -121,7 +118,10 @@ is_child_or_young_person_for_council_tax_reduction: [false, true] is_council_tax_reduction_lone_parent: true council_tax_reduction_applicable_amount: 4_968.60 - council_tax_reduction_applicable_income: 3_406.60 + # Child Benefit only: the young person's own earnings are not the + # applicant's (SI 2012/2885 Sch 1 para 11 counts the applicant and + # partner). + council_tax_reduction_applicable_income: 1_406.60 - name: Case 4, Wales keeps the Child Benefit young person past the Universal Credit terminal date. period: 2026 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index e67b1f7183..c3807f0b2e 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -38,12 +38,28 @@ 12-year-old explains the flag and makes them a couple. Before/after families share one Simulation to avoid repeated model construction, but occupy distinct households and benefit units. +5. UC reg 5(1)(a) and SPC reg 4A(1)(a): the year counts in the route when it + starts (6 April) before the 1 September following the 16th birthday, for + every date of birth checked against calendar arithmetic. Within ages 16 to + 19 and without own-right benefits, that route alone makes a UC or PC + qualifying young person whatever the education inputs; without it the + education route is unchanged. Own-right benefits exclude both routes. UC + and PC agree below 19, where only UC's terminal date differs. Child + Benefit reg 4 and CTC reg 4 (until the 31 August after the 16th birthday) + count for the same years. Scope: these are tests of the ENCODED statutory limbs, not full entitlement. Education status proxies full-time non-advanced study: hours, employment -contracts, approved providers and interruptions are unobserved. CB/CTC leaver -extensions are omitted, as is UC/PC's unconditional age-16-to-next-September -route. UC's terminal date is an input; PC's September terminal date is absent. +contracts, approved providers and interruptions are unobserved. Child +Benefit's extension period and terminal date (regs 5 and 7) and CTC's 20 +weeks after leaving (reg 5(3)(b)) are inputs, false here, with their own +tests in test_child_benefit_school_leaver_routes_properties.py and +test_child_tax_credit_school_leaver_routes_properties.py. The route to the 1 +September after the 16th birthday (UC, PC) or the 31 August before it (Child +Benefit reg 4, CTC reg 4), which has no education condition, counts for a +year that starts before that date (invariant 5). Nobody here is in +remunerative work, which ends CTC's route. UC's terminal date is an input; +PC's September terminal date is absent. Own-benefit receipt collapses different programme-specific benefit lists. UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's fallback assumes dependence at 16--17 regardless of study, requires an @@ -57,11 +73,12 @@ the independent scalar references below. No network or survey data is used. """ +from datetime import date, timedelta from itertools import product from unittest.mock import patch import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -177,16 +194,34 @@ def statutory_child(age): return age < 16 +def in_route_to_first_september_after_16th_birthday(p): + # UC reg 5(1)(a), SPC reg 4A(1)(a) and Child Benefit reg 4 have no + # education condition. With no date of birth, a whole age on 6 October + # places the birthday on 6 April, so the year (from 6 April) starts before + # the 1 September following the 16th birthday exactly when that age is + # under 17. Child Benefit's 31 August is the day before. + return p.get("is_before_first_september_following_16th_birthday", p["age"] < 17) + + def qualifying_young_person(p, programme): - # Education/training routes only (see omissions in module docstring): + # Every programme: the education/training route, and the route with no + # education condition. # CB: https://www.legislation.gov.uk/uksi/2006/223/regulation/3 + # CB at 16: https://www.legislation.gov.uk/uksi/2006/223/regulation/4 + # CTC at 16: https://www.legislation.gov.uk/uksi/2002/2007/regulation/4 # CB own-benefit restriction: https://www.legislation.gov.uk/uksi/2006/223/regulation/8 # UC: https://www.legislation.gov.uk/uksi/2013/376/regulation/5 # CTC: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 # PC: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: return False - if not (non_advanced_education(p) or p["is_in_approved_training"]): + studying = non_advanced_education(p) or p["is_in_approved_training"] + if in_route_to_first_september_after_16th_birthday(p): + # CTC reg 4 lifts only the first condition, for a person who has left + # education; one still in it is tested as a student below. + if programme != "child_tax_credit" or not studying: + return True + if not studying: return False if ( p["age"] >= 19 @@ -903,3 +938,108 @@ def test_frs_shaped_units_keep_the_under_20_presumption(families): ] assert_values(sim, "is_claimant_or_partner", expected) assert expected == [c for f in families for c in claimants_or_partners(f)] + + +ROUTE = "is_before_first_september_following_16th_birthday" + + +def first_september_following_16th_birthday(born): + # Calendar arithmetic, independent of the model's month grid: a 16th + # birthday before 1 September is followed by 1 September of the same year, + # and one on or after it by the next year's. A 29 February birthday is + # before 1 September whichever day it is kept on in other years. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/5 + # https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A + return date(born.year + 16 + ((born.month, born.day) >= (9, 1)), 9, 1) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_route_matches_calendar(births, year): + families = [ + [person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}"))] + for born in births + ] + # The fiscal year starts on 6 April. + expected = [ + date(year, 4, 6) < first_september_following_16th_birthday(born) + for born in births + ] + assert_values(simulate(families, year), ROUTE, expected, year) + + +def test_route_year_starts_before_first_september_for_every_birthday(): + # Every date of birth from 2008 to 2011: ages 14 to 18 on 6 October 2026, + # including the 31 August / 1 September and 6 April / 6 October edges. + start, end = date(2008, 1, 1), date(2012, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + assert_route_matches_calendar(births, YEAR) + + +@PROPERTY_SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_route_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_route_matches_calendar(births, year) + + +def test_route_alone_qualifies_and_leaves_the_education_route_unchanged(): + # Ages 14 to 21, every education value, own-right benefits, approved + # training, apprenticeship and looked-after status, with the route input + # off then on (innermost, so each subject's two rows are adjacent). + rows = list( + product( + range(14, 22), + EDUCATIONS, + product((False, True), repeat=4), + (False, True), + ) + ) + families = [ + [ + person(40, is_parent=True, is_benunit_head=True, **{ROUTE: False}), + person( + age, + current_education=education, + receives_benefits_in_own_right=own_benefits, + is_in_approved_training=training, + is_apprentice=apprentice, + is_looked_after_by_local_authority=looked_after, + **{ROUTE: route}, + ), + ] + for age, education, flags, route in rows + for own_benefits, training, apprentice, looked_after in [flags] + ] + # The oracle: the route, else the education route as before. + assert_definitions(families) + sim = simulate(families) + age = np.array([row[0] for row in rows[::2]]) + own_benefits = np.array([row[2][0] for row in rows[::2]]) + qyp = { + programme: sim.calculate(f"is_qualifying_young_person_for_{programme}", YEAR)[ + 1::2 + ].reshape(-1, 2) + for programme in ("universal_credit", "pension_credit") + } + for programme, status in qyp.items(): + # Switching the route on never removes the status. + assert (status[:, 1] >= status[:, 0]).all(), programme + # With the route on, ages 16 to 19 without own-right benefits qualify, + # whatever the education inputs, and nobody else does. + np.testing.assert_array_equal( + status[:, 1], (16 <= age) & (age < 20) & ~own_benefits, err_msg=programme + ) + # Own-right benefits exclude both routes. + assert not status[own_benefits].any(), programme + # Below 19 the two programmes' definitions are the same. + below_19 = age < 19 + np.testing.assert_array_equal( + qyp["universal_credit"][below_19], qyp["pension_credit"][below_19] + ) diff --git a/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py new file mode 100644 index 0000000000..a29e48450f --- /dev/null +++ b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py @@ -0,0 +1,327 @@ +"""Child Benefit qualifying young persons who have left education. + +Child Benefit (General) Regulations 2006: a person aged 16 to 19 is a +qualifying young person while any of regs 3 to 7 applies (reg. 2(2)) and +they are not receiving a benefit in their own right (reg. 8). + +- Reg. 3: non-advanced education or approved training, begun before 19. +- Reg. 4: under 17 and left education, until the 31 August next following + the 16th birthday (the 1 September for a 16th birthday on 31 August). +- Reg. 5: under 18, in an extension period, not in remunerative work. +- Reg. 7: until the terminal date after leaving; reg. 7(3) pays no Child + Benefit "by virtue of this regulation" in a week of remunerative work (24 + hours or more, reg. 1(3)). + +Invariants: + +1. Reg. 4, against calendar arithmetic that shares no code with the model: + a person outside education is a qualifying young person for a year + exactly when they are 16 to 19 on 6 October and the year starts (6 April) + on or before the reg. 4 end date, for every date of birth. +2. Over every combination of age, education, training, own-right benefits, + the three leaver facts and hours: the status equals the union of the four + conditions within ages 16 to 19 and without own-right benefits. So each + condition ends at its age limit, own-right benefits exclude every + condition, and with the three leaver facts off the status is reg. 3's + alone, as before. +3. Switching a leaver fact on never removes the status. +4. Child Benefit is payable for the benefit unit's child or qualifying young + person unless they qualify through reg. 7 alone and are in remunerative + work. They stay a qualifying young person, and a legacy young person. +5. Child Benefit entitlement is the eldest payable member at the eldest + rate and each other at the additional rate, for 52 weeks. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2026 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +ROUTE = "is_before_first_september_following_16th_birthday" +EXTENSION = "is_in_child_benefit_extension_period" +TERMINAL = "is_before_child_benefit_terminal_date" +QYP = "is_qualifying_young_person_for_child_benefit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "is_looked_after_by_local_authority": False, + "age_started_or_accepted_current_education_or_training": 17, + "weekly_hours": 0, + EXTENSION: False, + TERMINAL: False, + "is_claimant_or_partner": False, + **inputs, + } + + +def simulate(units, year=YEAR): + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit): + name = f"p{i}_{j}" + situation["people"][name] = {} + people.append(inputs) + names.append(name) + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = {"members": names} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def regulation_4_end_date(born): + # Reg. 4(2): "the 31st August next following the person's 16th birthday"; + # reg. 4(3): for a 16th birthday on 31 August, "the 1st September + # immediately following". A 29 February birthday is before 31 August + # whichever day it is kept on in other years. + # https://www.legislation.gov.uk/uksi/2006/223/regulation/4 + if (born.month, born.day) == (8, 31): + return date(born.year + 16, 9, 1) + return date(born.year + 16 + ((born.month, born.day) > (8, 31)), 8, 31) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_regulation_4_matches_calendar(births, year): + units = [ + [ + person(40, is_claimant_or_partner=True, date_of_birth=0), + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")), + ] + for born in births + ] + # "Has not passed": the year, from 6 April, reaches the period if it + # starts on or before the end date. + expected = [ + 16 <= age_on_6_october(born, year) < 20 + and date(year, 4, 6) <= regulation_4_end_date(born) + for born in births + ] + actual = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal(actual, expected) + return expected + + +def test_regulation_4_for_every_date_of_birth(): + # Every date of birth from 2006 to 2011: ages 14 to 20 on 6 October 2026, + # with the 31 August and 1 September edges in each year. + start, end = date(2006, 1, 1), date(2012, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = assert_regulation_4_matches_calendar(births, YEAR) + # Not vacuous, and the edges fall where the regulation puts them. + by_birth = dict(zip(births, expected)) + assert by_birth[date(2010, 6, 1)] and not by_birth[date(2009, 6, 1)] + # 16 on 31 August 2025: the period ended on 1 September 2025. + assert not by_birth[date(2009, 8, 31)] + # 16 on 1 September 2025: the period runs to 31 August 2026. + assert by_birth[date(2009, 9, 1)] + # 16 on 6 October 2026 is 16 for the year; 16 on 7 October is still 15. + assert by_birth[date(2010, 10, 6)] and not by_birth[date(2010, 10, 7)] + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_regulation_4_matches_calendar(births, year) + + +def regulation_3(p): + # Education status stands in for full-time non-advanced education. + # https://www.legislation.gov.uk/uksi/2006/223/regulation/3 + studying = ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + begun_before_19 = ( + p["age"] < 19 or p["age_started_or_accepted_current_education_or_training"] < 19 + ) + return studying and begun_before_19 + + +def remunerative_work(p): + # https://www.legislation.gov.uk/uksi/2006/223/regulation/1 + return p["weekly_hours"] >= 24 + + +def regulation_5(p): + # https://www.legislation.gov.uk/uksi/2006/223/regulation/5 + return p["age"] < 18 and p[EXTENSION] and not remunerative_work(p) + + +def qualifying_young_person(p): + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + return regulation_3(p) or p[ROUTE] or regulation_5(p) or p[TERMINAL] + + +def payable(p): + # Reg. 7(3) bars Child Benefit payable through reg. 7 only. + if p["is_claimant_or_partner"]: + return False + if p["age"] < 16: + return True + if not qualifying_young_person(p): + return False + through_another = regulation_3(p) or p[ROUTE] or regulation_5(p) + return through_another or not remunerative_work(p) + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + receives_benefits_in_own_right=own_right, + weekly_hours=hours, + **{ROUTE: route, EXTENSION: extension, TERMINAL: terminal}, + ) + for age, education, training, own_right, hours in product( + range(14, 22), EDUCATIONS, (False, True), (False, True), (0, 23, 24) + ) + for route, extension, terminal in product((False, True), repeat=3) +] + + +def test_status_is_the_union_of_the_four_conditions(): + # 8 ages * every education value * 2 * 2 * 3 hours, each with the 8 + # combinations of the three leaver facts (adjacent rows). + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in GRID + ] + sim = simulate(units) + status = sim.calculate(QYP, YEAR)[1::2] + # Invariant 2. + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in GRID]) + age = np.array([p["age"] for p in GRID]) + assert not status[(age < 16) | (age >= 20)].any() + assert not status[ + np.array([p["receives_benefits_in_own_right"] for p in GRID]) + ].any() + assert status.any() + # With the leaver facts off, reg. 3 alone, as before. + off = np.array([not (p[ROUTE] or p[EXTENSION] or p[TERMINAL]) for p in GRID]) + np.testing.assert_array_equal( + status[off], + [ + 16 <= p["age"] < 20 + and not p["receives_benefits_in_own_right"] + and regulation_3(p) + for p, o in zip(GRID, off) + if o + ], + ) + # Invariant 3: within each block of 8, a row with more facts on never + # loses the status. Rows are ordered (route, extension, terminal). + blocks = status.reshape(-1, 8) + for less, more in ( + (0, 1), + (0, 2), + (0, 4), + (1, 3), + (1, 5), + (2, 3), + (2, 6), + (4, 5), + (4, 6), + (3, 7), + (5, 7), + (6, 7), + ): + assert (blocks[:, more] >= blocks[:, less]).all(), (less, more) + # Invariant 4. + np.testing.assert_array_equal( + sim.calculate("is_child_benefit_payable_for_person", YEAR)[1::2], + [payable(p) for p in GRID], + ) + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_benefit", YEAR + )[1::2] + legacy = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR)[1::2] + np.testing.assert_array_equal(family, (age < 16) | status) + np.testing.assert_array_equal(legacy, family) + barred = family & ~np.array([payable(p) for p in GRID]) + # Not vacuous: some young persons are barred, all of them 16 or over, + # before the terminal date and working 24 hours. + assert barred.any() + assert all( + p[TERMINAL] and p["weekly_hours"] == 24 and p["age"] >= 16 + for p, b in zip(GRID, barred) + if b + ) + + +member = st.builds( + person, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + receives_benefits_in_own_right=st.booleans(), + weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans(), TERMINAL: st.booleans()}, +) + + +@st.composite +def benefit_units(draw): + claimants = draw(st.lists(member, min_size=1, max_size=2)) + others = draw(st.lists(member, max_size=4)) + return [dict(p, is_claimant_or_partner=True) for p in claimants] + others + + +@SETTINGS +@given(st.lists(benefit_units(), min_size=1, max_size=6), st.sampled_from([2024, 2026])) +def test_child_benefit_entitlement_counts_payable_members(units, year): + # Invariant 5, from the inputs and the weekly rates alone. + sim = simulate(units, year) + rates = sim.tax_benefit_system.parameters(f"{year}-06-01").gov.hmrc.child_benefit + entitlement = sim.calculate("child_benefit_entitlement", year) + for unit, actual in zip(units, entitlement): + number = sum(payable(p) for p in unit) + weekly = ( + 0 + if number == 0 + else rates.amount.eldest + (number - 1) * rates.amount.additional + ) + np.testing.assert_allclose(actual, weekly * 52, rtol=1e-6) diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py new file mode 100644 index 0000000000..18d63930ee --- /dev/null +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -0,0 +1,344 @@ +"""Child Tax Credit qualifying young persons who have left education. + +Child Tax Credit Regulations 2002: + +- Reg. 5(3)(a), (ab), (3A): the first condition is full-time non-advanced + education or approved training, begun before 19. +- Reg. 4(1)-(2): from the 16th birthday until the 31 August that next + follows, the first condition is not required. Reg. 4(1A): a 16th birthday + on 31 August gets no such period. +- Reg. 5(3)(b): under 18, the 20 weeks after leaving while registered for + work or training satisfy the first condition. +- Reg. 5(4), the second condition, always applies: a person who has ceased + full-time education or approved training must not be in remunerative work + (24 hours a week or more, reg. 2(1)), and no one may be receiving a benefit + in their own right. Full-time education at any level counts (reg. 5(5)), so + an advanced student has not ceased; an apprentice is treated as having + ceased. + +Invariants: + +1. Reg. 4, against calendar arithmetic that shares no code with the model: + a person outside education, not working, is a qualifying young person for + a year exactly when they are 16 to 19 on 6 October and some of the year, + from 6 April, falls on or before the last day they are a child or in the + reg. 4 period. For a 16th birthday on 31 August that day is 30 August. +2. Over every combination of age, education, training, apprenticeship, + own-right benefits, the age the course began, the two leaver facts and + hours: the status is the education condition; or the reg. 4 period, + unless the person has ceased education or training and is in remunerative + work; or the 20 weeks for a person under 18 who has ceased and is not in + remunerative work; within ages 16 to 19 and without own-right benefits. + So remunerative work ends both routes for a person who has ceased and + never a student's status, an advanced student keeps the reg. 4 period + whatever their hours, the 20 weeks end at 18 and never reach a student + whose course began at 19, and with the leaver facts off the status is the + education condition's alone, as before. +3. Switching a leaver fact on never removes the status. +4. The benefit unit's child or qualifying young person is a child or + qualifying young person who is not its claimant or partner. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2024 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +ROUTE = "is_before_first_september_following_16th_birthday" +EXTENSION = "is_in_child_tax_credit_extension_period" +QYP = "is_qualifying_young_person_for_child_tax_credit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "is_looked_after_by_local_authority": False, + "age_started_or_accepted_current_education_or_training": 17, + "weekly_hours": 0, + EXTENSION: False, + "is_claimant_or_partner": False, + **inputs, + } + + +def simulate(units, year=YEAR): + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit): + name = f"p{i}_{j}" + situation["people"][name] = {} + people.append(inputs) + names.append(name) + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = {"members": names} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def last_day_as_child_or_in_regulation_4_period(born): + # Reg. 4(1): "until 31st August which next follows" the 16th birthday. + # Reg. 4(1A): a 16th birthday on 31 August starts no such period, so the + # last day covered is the day before it, the last day under 16. + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/4 + if (born.month, born.day) == (8, 31): + return date(born.year + 16, 8, 30) + return date(born.year + 16 + ((born.month, born.day) > (8, 31)), 8, 31) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_regulation_4_matches_calendar(births, year): + units = [ + [ + person(40, is_claimant_or_partner=True, date_of_birth=0), + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")), + ] + for born in births + ] + expected = [ + 16 <= age_on_6_october(born, year) < 20 + and date(year, 4, 6) <= last_day_as_child_or_in_regulation_4_period(born) + for born in births + ] + actual = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal(actual, expected) + return expected + + +def test_regulation_4_for_every_date_of_birth(): + # Every date of birth from 2004 to 2009: ages 14 to 20 on 6 October 2024. + start, end = date(2004, 1, 1), date(2010, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = assert_regulation_4_matches_calendar(births, YEAR) + by_birth = dict(zip(births, expected)) + assert by_birth[date(2008, 6, 1)] and not by_birth[date(2007, 6, 1)] + # 16 on 31 August 2023: reg. 4(1A) gives no period to 31 August 2024. + assert not by_birth[date(2007, 8, 31)] + # 16 on 31 August 2024: a child until 30 August 2024, in the year. + assert by_birth[date(2008, 8, 31)] + # 16 on 1 September 2023: the period runs to 31 August 2024. + assert by_birth[date(2007, 9, 1)] + # 16 on 6 October 2024 is 16 for the year; 16 on 7 October is still 15. + assert by_birth[date(2008, 10, 6)] and not by_birth[date(2008, 10, 7)] + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_regulation_4_matches_calendar(births, year) + + +def receiving_education_or_training(p): + # Education status stands in for full-time non-advanced education that is + # not by virtue of employment (reg. 5(3)(a)), or approved training ((ab)). + return ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + + +def has_ceased_education_or_training(p): + # Reg. 5(4)(a), (5): full-time education at any level, not received by + # virtue of employment, or approved training, has not ceased. + in_full_time_education = ( + p["current_education"] != "NOT_IN_EDUCATION" and not p["is_apprentice"] + ) + return not (in_full_time_education or p["is_in_approved_training"]) + + +def first_condition_through_education(p): + # Reg. 5(3A): begun before 19. + begun_before_19 = ( + p["age"] < 19 or p["age_started_or_accepted_current_education_or_training"] < 19 + ) + return receiving_education_or_training(p) and begun_before_19 + + +def qualifying_young_person(p): + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + if first_condition_through_education(p): + return True + ceased = has_ceased_education_or_training(p) + # Reg. 2(1): remunerative work is 24 hours a week or more. + working = p["weekly_hours"] >= 24 + # Reg. 4(2): the period needs only the second condition. + if p[ROUTE] and not (ceased and working): + return True + # Reg. 5(3)(b): under 18, having ceased, and reg. 5(4)(a). + return p["age"] < 18 and p[EXTENSION] and ceased and not working + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + is_apprentice=apprentice, + receives_benefits_in_own_right=own_right, + weekly_hours=hours, + age_started_or_accepted_current_education_or_training=entry_age, + **{ROUTE: route, EXTENSION: extension}, + ) + for age, education, training, apprentice, own_right, hours, entry_age in product( + range(14, 22), + EDUCATIONS, + (False, True), + (False, True), + (False, True), + (0, 23, 24), + # A course begun at 19 fails reg. 5(3A). Such a student has not left + # education, so neither leaver fact reaches them. + (17, 19), + ) + for route, extension in product((False, True), repeat=2) +] + + +def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in GRID + ] + sim = simulate(units) + status = sim.calculate(QYP, YEAR)[1::2] + # Invariant 2. + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in GRID]) + age = np.array([p["age"] for p in GRID]) + hours = np.array([p["weekly_hours"] for p in GRID]) + student = np.array([receiving_education_or_training(p) for p in GRID]) + ceased = np.array([has_ceased_education_or_training(p) for p in GRID]) + route = np.array([p[ROUTE] for p in GRID]) + own_right = np.array([p["receives_benefits_in_own_right"] for p in GRID]) + assert not status[(age < 16) | (age >= 20)].any() + assert not status[own_right].any() + # A student whose course began at 19 does not qualify through the 20 + # weeks, which need them to have ceased. (The grid sets the reg. 4 fact + # directly; from a date of birth it is never true at 19.) + late_student = np.array( + [ + receiving_education_or_training(p) + and p["age"] >= 19 + and p["age_started_or_accepted_current_education_or_training"] >= 19 + and not p[ROUTE] + for p in GRID + ] + ) + assert late_student.any() and not status[late_student].any() + # Remunerative work ends the status of a person who has ceased education + # or training, and not a student's. + assert not status[ceased & (hours >= 24)].any() + assert status[student & (hours >= 24)].any() + # An advanced student in the reg. 4 period qualifies whatever their hours. + advanced = np.array([p["current_education"] == "TERTIARY" for p in GRID]) + in_age = (age >= 16) & (age < 20) & ~own_right + advanced_in_route = advanced & ~ceased & route & in_age + assert advanced_in_route.any() and status[advanced_in_route].all() + assert (advanced_in_route & (hours >= 24)).any() + # The 20 weeks alone do not reach an 18-year-old. + alone = np.array([p[EXTENSION] and not p[ROUTE] for p in GRID]) + assert not status[alone & ~student & (age >= 18)].any() + assert status[alone & ~student & (age == 17)].any() + # With the leaver facts off, the education condition alone, as before. + off = np.array([not (p[ROUTE] or p[EXTENSION]) for p in GRID]) + np.testing.assert_array_equal( + status[off], + [ + 16 <= p["age"] < 20 + and not p["receives_benefits_in_own_right"] + and first_condition_through_education(p) + for p, o in zip(GRID, off) + if o + ], + ) + # Invariant 3: rows are ordered (route, extension) within blocks of 4. + blocks = status.reshape(-1, 4) + for less, more in ((0, 1), (0, 2), (1, 3), (2, 3)): + assert (blocks[:, more] >= blocks[:, less]).all(), (less, more) + # Invariant 4, for members who are not the claimant. + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", YEAR + ) + np.testing.assert_array_equal(family[1::2], (age < 16) | status) + assert not family[0::2].any() + + +member = st.builds( + person, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + receives_benefits_in_own_right=st.booleans(), + is_looked_after_by_local_authority=st.booleans(), + weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans()}, +) + + +@st.composite +def benefit_units(draw): + claimants = draw(st.lists(member, min_size=1, max_size=2)) + others = draw(st.lists(member, max_size=4)) + return [dict(p, is_claimant_or_partner=True) for p in claimants] + others + + +@SETTINGS +@given(st.lists(benefit_units(), min_size=1, max_size=6), st.sampled_from([2020, 2024])) +def test_family_members_in_any_benefit_unit(units, year): + # Invariants 2 and 4 on benefit units of any shape, where a claimant or + # partner can be a qualifying young person by status. + sim = simulate(units, year) + people = [p for unit in units for p in unit] + status = sim.calculate(QYP, year) + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in people]) + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", year + ) + expected = [ + (p["age"] < 16 or qualifying_young_person(p)) + and not p["is_claimant_or_partner"] + and not p["is_looked_after_by_local_authority"] + for p in people + ] + np.testing.assert_array_equal(family, expected) diff --git a/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py b/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py index c57d295b08..1bf2efbf8f 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py @@ -32,8 +32,8 @@ 36(1)). A household head aged 18 or 19 who applies alone was reached by the income mask only as a Child Benefit young person; they stay in the means test where Scotland's working-age scheme does not read them as a - young person. A member who is not the applicant counts only as that - scheme's young person. + young person. A member who is not the applicant never counts: a young + person's income is not the applicant's. The pensioner test and the claimant and partner are supplied, so these tests exercise the young person rule alone; the YAML cases use the derived tests. @@ -452,11 +452,10 @@ def test_applicants_own_earnings_count_under_every_scheme(): "is_child_or_young_person_for_council_tax_reduction", year )[0::3] income = sim.calculate("council_tax_reduction_applicable_income", year) - np.testing.assert_allclose(income, earnings * (heads | young_person), atol=0.01) - # The applicant's earnings count in every case. - np.testing.assert_allclose(income[heads], earnings, atol=0.01) - # Not vacuous: some applicants are not young persons under their scheme, - # and a member who is neither does not count. - assert (heads & ~young_person).any() - assert (income[~heads & ~young_person] == 0).all() + np.testing.assert_allclose(income, earnings * heads, atol=0.01) + # The applicant's earnings count in every case, whether or not their + # scheme reads them as a young person. + assert (heads & ~young_person).any() and (heads & young_person).any() + # Not vacuous: a non-applicant who is the scheme's young person does not + # count, nor one who is not. assert (~heads & ~young_person).any() and (~heads & young_person).any() diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index 2ad7e658bb..20db7f7554 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -206,8 +206,10 @@ def families(draw): def excluded_members(draw): """An adult who is neither claimant, partner nor in the family. - They are not in education, so a 16 to 19 year old is not a qualifying - young person. Most are primed with what barred or opened the claim when + They are not in education and past the 31 August following their 16th + birthday (Child Benefit (General) Regulations 2006 reg. 4), so a 16 to 19 + year old is not a qualifying young person. Most are primed with what + barred or opened the claim when every member counted: over state pension age, income-related ESA, an Income Support report, or caring. Some are primed with what the s.124(1)(g) proxy reads for a couple: over the qualifying age, with @@ -216,6 +218,7 @@ def excluded_members(draw): adult = { **draw(adult_inputs(min_age=16)), "current_education": "NOT_IN_EDUCATION", + "is_before_first_september_following_16th_birthday": False, } primed = draw( st.sampled_from(["random", "elderly", "esa", "award", "carer", "pension"]) diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 5cba93171c..8775832915 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -183,13 +183,15 @@ def families(draw): def other_members(draw): """An adult who is neither claimant, partner nor a dependant. - They are under state pension age and not in education, so a 16 to 19 year - old is not a qualifying young person. They have no income other than the - awards they report. + They are under state pension age, not in education and past the 1 + September following their 16th birthday (UC Regs 2013 reg. 5(1)(a)), so a + 16 to 19 year old is not a qualifying young person. They have no income + other than the awards they report. """ return { "age": draw(st.integers(16, 60)), "current_education": "NOT_IN_EDUCATION", + "is_before_first_september_following_16th_birthday": False, **draw(award_reports()), } diff --git a/policyengine_uk/tests/test_means_test_income_properties.py b/policyengine_uk/tests/test_means_test_income_properties.py index 49a9088ef2..f48d432917 100644 --- a/policyengine_uk/tests/test_means_test_income_properties.py +++ b/policyengine_uk/tests/test_means_test_income_properties.py @@ -1,10 +1,19 @@ -"""A means test never counts the income of someone outside its family. +"""A means test counts the claimant's and partner's income, no one else's. -Invariant: for any benefit unit, giving income to a member who is neither the -claimant, the partner, nor the programme's own child or young person (for -example an 18- or 19-year-old presumed the claimant's child but not in -education, or a third adult) never changes a means test's income. The UC, HB, -IS, tax credit, CTR and Pension Credit income totals are covered. +Invariants: + +1. For any benefit unit, giving income to a member who is neither the + claimant, the partner, nor the programme's own child or young person (for + example an 18- or 19-year-old presumed the claimant's child but not in + education, a 16-year-old outside education and past the 1 September + following their 16th birthday, or a third adult) never changes a means + test's income. The UC, HB, IS, tax credit, CTR and Pension Credit income + totals are covered. +2. Giving income to the family's own child or young person never changes it + either (HB Regs 2006 reg 25(3); IS Regs 1987 reg 23(2); Tax Credits + (Definition and Calculation of Income) Regs 2002 reg 3(1); SSI 2021/249 reg + 36(2) and the applicant-and-partner rules of the other Council Tax + Reduction schemes). Each example builds families twice in one simulation: once with the other member's income and once without, in separate households and benefit units. @@ -45,9 +54,10 @@ def families(draw): # A head, an optional partner, and one member who by construction is # outside every programme's family: a 16-19-year-old at least 16 years # younger than the head (presumed the head's child) who is not in - # non-advanced education, so no programme's young person; or a third adult - # no older than the partner (the partner, earlier in member order, wins any - # age tie). + # non-advanced education and is past the 1 September following their 16th + # birthday (UC Regs 2013 reg. 5(1)(a)), so no programme's young person; or + # a third adult no older than the partner (the partner, earlier in member + # order, wins any age tie). kind = draw(st.sampled_from(["presumed_child", "third_adult"])) if kind == "presumed_child": other_age = draw(st.integers(16, 19)) @@ -72,6 +82,7 @@ def families(draw): { "age": other_age, "current_education": education, + "is_before_first_september_following_16th_birthday": False, "employment_income": draw(st.floats(1, 50_000)), "private_pension_income": draw(money), "savings_interest_income": draw(money), @@ -132,3 +143,81 @@ def test_income_of_non_claimants_never_counts(units): would_claim_uc, units[i], ) + + +@st.composite +def families_with_a_dependant(draw): + # A head, an optional partner, and the family's own child or young + # person: a child under 16, or a 16- to 19-year-old at school on a course + # begun at 17, at least 16 years younger than the head and with the head + # flagged a parent, so every programme counts them as the family's. + dependant_age = draw(st.integers(10, 19)) + head_age = draw(st.integers(dependant_age + 16, 60)) + family = [ + { + "age": head_age, + "is_parent": True, + "employment_income": draw(money), + } + ] + if draw(st.booleans()): + family.append( + { + "age": draw(st.integers(dependant_age + 16, 60)), + "is_parent": True, + "employment_income": draw(money), + } + ) + family.append( + { + "age": dependant_age, + "current_education": "UPPER_SECONDARY", + "age_started_or_accepted_current_education_or_training": 17, + "is_before_universal_credit_qualifying_young_person_terminal_date": True, + "employment_income": draw(st.floats(1, 50_000)), + "private_pension_income": draw(money), + "savings_interest_income": draw(money), + } + ) + return family + + +@settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(families_with_a_dependant(), min_size=1, max_size=6)) +def test_income_of_children_and_young_persons_never_counts(units): + # Invariant 2: the same families with the dependant's income removed. + without = [ + family[:-1] + + [ + { + **family[-1], + "employment_income": 0, + "private_pension_income": 0, + "savings_interest_income": 0, + } + ] + for family in units + ] + for would_claim_uc in (None, False): + sim = Simulation(situation=situation(units + without, would_claim_uc)) + offsets = np.cumsum([0] + [len(f) for f in units]) + n = len(units) + # Not vacuous: the dependant is every legacy programme's own child or + # young person. + for variable in FAMILY_FLAGS[1:]: + flags = sim.calculate(variable, YEAR) + for i in range(n): + assert flags[offsets[i + 1] - 1], (variable, units[i]) + for variable in INCOME_TESTS: + values = sim.calculate(variable, YEAR) + for i in range(n): + assert abs(values[i] - values[n + i]) < 0.01, ( + variable, + would_claim_uc, + units[i], + ) diff --git a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py new file mode 100644 index 0000000000..fef49a5512 --- /dev/null +++ b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py @@ -0,0 +1,149 @@ +"""Income Support in a young person's own right and qualifying young person +status. + +A person receiving Income Support is not a qualifying young person for +Pension Credit (SPC Regs 2002 reg. 4A(5)), Child Benefit (Child Benefit +(General) Regulations 2006 reg. 8(2)(a)) or Child Tax Credit (CTC Regs 2002 +reg. 5(4)(c)). Universal Credit's list (UC Regs 2013 reg. 5(5)) does not +include Income Support. + +Invariants: + +1. Over every combination of age, education, training, the other own-right + benefits and the 16th-birthday fact: with Income Support in their own + right, nobody is a Pension Credit, Child Benefit or Child Tax Credit + qualifying young person. +2. Without it, each of those three statuses is exactly what it is when the + input is left alone, so nothing else changes. +3. Universal Credit status never depends on it. +4. Without it, Pension Credit and Universal Credit agree on every combination; + with it, Pension Credit is the narrower. +5. By default the input is the person's own reported Income Support while + Income Support is in payment (to 2025-26) and no reform removes it, and + false otherwise. +""" + +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2025 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +SIXTEEN = "is_before_first_september_following_16th_birthday" +INCOME_SUPPORT = "receives_income_support_in_own_right" +EXCLUDING = ("pension_credit", "child_benefit", "child_tax_credit") +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def simulate(people, year=YEAR): + """Each person alone in a benefit unit: the status tests are personal.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i in range(len(people)): + situation["people"][f"p{i}"] = {} + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def status(sim, programme, year=YEAR): + return sim.calculate(f"is_qualifying_young_person_for_{programme}", year) + + +BASE = [ + { + "age": age, + "current_education": education, + "is_in_approved_training": training, + "receives_benefits_in_own_right": own_right, + "age_started_or_accepted_current_education_or_training": 17, + SIXTEEN: sixteen, + } + for age, education, training, own_right, sixteen in product( + range(14, 22), EDUCATIONS, (False, True), (False, True), (False, True) + ) +] + + +def test_income_support_ends_three_statuses_and_not_universal_credit(): + untouched = simulate(BASE) + without = simulate([dict(p, **{INCOME_SUPPORT: False}) for p in BASE]) + with_it = simulate([dict(p, **{INCOME_SUPPORT: True}) for p in BASE]) + for programme in EXCLUDING: + before = status(untouched, programme) + # Not vacuous: there is a status to lose. + assert before.any(), programme + # Invariant 1. + assert not status(with_it, programme).any(), programme + # Invariant 2. + np.testing.assert_array_equal(status(without, programme), before) + # Invariant 3. + universal_credit = status(untouched, "universal_credit") + np.testing.assert_array_equal(status(with_it, "universal_credit"), universal_credit) + np.testing.assert_array_equal(status(without, "universal_credit"), universal_credit) + # Invariant 4. + np.testing.assert_array_equal(status(without, "pension_credit"), universal_credit) + assert (status(with_it, "pension_credit") <= universal_credit).all() + assert universal_credit.any() + + +@SETTINGS +@given( + st.lists( + st.tuples(st.integers(0, 60), st.sampled_from([0, 0, 1, 2_500, 9_000])), + min_size=1, + max_size=40, + ), + st.sampled_from([2021, 2024, 2025, 2026, 2028]), +) +def test_default_follows_reported_income_support_while_it_is_paid(rows, year): + # Invariant 5. Income Support paid its last awards on 31 March 2026. + people = [{"age": age, "income_support_reported": amount} for age, amount in rows] + sim = simulate(people, year) + expected = [year <= 2025 and amount > 0 for _, amount in rows] + np.testing.assert_array_equal(sim.calculate(INCOME_SUPPORT, year), expected) + + +def test_no_receipt_once_a_reform_removes_income_support(): + # Invariant 5 under a reform: a 17-year-old at school reporting £3,000 of + # Income Support of their own is excluded while Income Support exists, + # and is a qualifying young person again once a reform neutralizes it. + student = { + "age": 17, + "current_education": "UPPER_SECONDARY", + "income_support_reported": 3_000, + } + kept = simulate([student]) + assert kept.calculate(INCOME_SUPPORT, YEAR).all() + removed = simulate([student]) + removed.tax_benefit_system.neutralize_variable("income_support") + removed.delete_arrays("income_support") + assert not removed.calculate(INCOME_SUPPORT, YEAR).any() + for programme in EXCLUDING: + assert not status(kept, programme).any(), programme + assert status(removed, programme).all(), programme diff --git a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py new file mode 100644 index 0000000000..a91cb639fa --- /dev/null +++ b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py @@ -0,0 +1,311 @@ +"""The 1 September following the 19th birthday, for Universal Credit and +Pension Credit qualifying young persons. + +A young person enrolled on, or accepted for, non-advanced education or +approved training is a qualifying young person "up to, but not including, +the 1st September following their 19th birthday" (UC Regs 2013 reg. 5(1)(b); +SPC Regs 2002 reg. 4A(1)(b)). + +Invariants: + +1. is_before_first_september_following_19th_birthday is true exactly when + the year starts (6 April) before that 1 September, for every date of + birth, against calendar arithmetic that shares no code with the model. +2. Universal Credit's own terminal-date variable follows that fact unless it + is set, and setting it changes Universal Credit only. +3. Over every combination of age, education, training, apprenticeship, + own-right benefits, entry age and the two date facts: Universal Credit + and Pension Credit agree, and both equal the regulation's conditions. +4. With the 19th-birthday fact on, both statuses are the earlier definition + without a terminal date, so nothing changes below 19 or for a 19-year-old + before the date; switching the fact on never removes the status; and it + matters only for a 19-year-old in education or training. +5. The formula gives true for everyone under 20 on 6 October, whether the + date of birth or only the age is given, and true is each terminal-date + variable's default. So a situation that sets one of them for some people + leaves everyone else's status as it would be with nothing set. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2026 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +SIXTEEN = "is_before_first_september_following_16th_birthday" +NINETEEN = "is_before_first_september_following_19th_birthday" +UC_TERMINAL = "is_before_universal_credit_qualifying_young_person_terminal_date" +UC = "is_qualifying_young_person_for_universal_credit" +PC = "is_qualifying_young_person_for_pension_credit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "age_started_or_accepted_current_education_or_training": 17, + **inputs, + } + + +def simulate(people, year=YEAR): + """Each person alone in a benefit unit: the status tests are personal.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i in range(len(people)): + situation["people"][f"p{i}"] = {} + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def first_september_following_19th_birthday(born): + # A 19th birthday before 1 September is followed by 1 September of the + # same year, and one on or after it by the next year's. A 29 February + # birthday is before 1 September whichever day it is kept on. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/5 + # https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A + return date(born.year + 19 + ((born.month, born.day) >= (9, 1)), 9, 1) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_fact_matches_calendar(births, year): + people = [ + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")) + for born in births + ] + expected = [ + date(year, 4, 6) < first_september_following_19th_birthday(born) + for born in births + ] + sim = simulate(people, year) + # Invariant 1. + np.testing.assert_array_equal(sim.calculate(NINETEEN, year), expected) + # Invariant 2: Universal Credit's variable follows it when not set. + np.testing.assert_array_equal(sim.calculate(UC_TERMINAL, year), expected) + return expected + + +def test_fact_for_every_date_of_birth(): + # Every date of birth from 2004 to 2009: ages 16 to 22 on 6 October 2026. + start, end = date(2004, 1, 1), date(2010, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = dict(zip(births, assert_fact_matches_calendar(births, YEAR))) + # 19 on 31 August 2026: the period ends that day, in the year. + assert expected[date(2007, 8, 31)] + # 19 on 31 August 2025: it ended before the year. + assert not expected[date(2006, 8, 31)] + # 19 on 1 September 2025: it runs to 31 August 2026. + assert expected[date(2006, 9, 1)] + # Everyone aged 19 on 6 October 2026 is before the date. + assert all( + before + for born, before in expected.items() + if age_on_6_october(born, YEAR) == 19 + ) + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_fact_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_fact_matches_calendar(births, year) + + +def studying(p): + # Education status stands in for a non-advanced course of more than 12 + # hours a week; reg. 5(3) and reg. 4A(3) exclude a contract of employment. + return ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + + +def qualifying_young_person(p, terminal_date=True): + # Reg. 5(1), (2), (5); reg. 4A(1), (2), (5). + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + if p[SIXTEEN]: + return True + if not studying(p): + return False + if ( + p["age"] >= 19 + and p["age_started_or_accepted_current_education_or_training"] >= 19 + ): + return False + return p["age"] < 19 or p[NINETEEN] or not terminal_date + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + is_apprentice=apprentice, + receives_benefits_in_own_right=own_right, + age_started_or_accepted_current_education_or_training=entry_age, + **{SIXTEEN: sixteen, NINETEEN: nineteen}, + ) + for age, education, training, apprentice, own_right, entry_age, sixteen in product( + range(14, 22), + EDUCATIONS, + (False, True), + (False, True), + (False, True), + (17, 19, 1000), + (False, True), + ) + for nineteen in (False, True) +] + + +def test_universal_credit_and_pension_credit_agree_on_every_combination(): + sim = simulate(GRID) + uc, pc = sim.calculate(UC, YEAR), sim.calculate(PC, YEAR) + expected = np.array([qualifying_young_person(p) for p in GRID]) + # Invariant 3. + np.testing.assert_array_equal(uc, expected) + np.testing.assert_array_equal(pc, expected) + # Invariant 4. Rows are adjacent pairs: the 19th-birthday fact off, on. + off, on = expected[0::2], expected[1::2] + assert (on >= off).all() + np.testing.assert_array_equal( + on, [qualifying_young_person(p, terminal_date=False) for p in GRID[1::2]] + ) + changed = on != off + assert changed.any() + assert all( + p["age"] == 19 and studying(p) and not p[SIXTEEN] + for p, c in zip(GRID[1::2], changed) + if c + ) + + +def test_universal_credit_input_overrides_the_fact_for_universal_credit_only(): + # Invariant 2: a 19-year-old at school, with each programme's input set + # against the fact. + rows = list(product((False, True), repeat=2)) + people = [ + person( + 19, + current_education="UPPER_SECONDARY", + **{SIXTEEN: False, NINETEEN: nineteen, UC_TERMINAL: uc_terminal}, + ) + for nineteen, uc_terminal in rows + ] + sim = simulate(people) + np.testing.assert_array_equal(sim.calculate(UC, YEAR), [u for _, u in rows]) + np.testing.assert_array_equal(sim.calculate(PC, YEAR), [n for n, _ in rows]) + + +DEFAULTED = ( + NINETEEN, + UC_TERMINAL, + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", +) + + +def test_formula_is_true_under_20_and_is_each_default(): + # Invariant 5, from age and months alone: every age to 25 and every + # month since the last birthday, in three years. + rows = list(product(range(26), range(12))) + for year in (2022, 2026, 2030): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (age, months) in enumerate(rows): + situation["people"][f"p{i}"] = { + "age": {year: age}, + "months_since_last_birthday": {year: months}, + } + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + under_20 = np.array([age < 20 for age, _ in rows]) + for variable in DEFAULTED: + values = sim.calculate(variable, year) + assert values[under_20].all(), (variable, year) + assert sim.tax_benefit_system.variables[variable].default_value is True + # Not vacuous: the fact is false for some people of 20 or more. + assert not sim.calculate(NINETEEN, year)[~under_20].all() + + +def test_setting_one_person_leaves_the_others_status(): + # Invariant 5 in a household: two 19-year-olds at school, course begun + # at 17. Each terminal-date variable is set false for the first only; + # the second stays a qualifying young person for both programmes. + for variable in DEFAULTED: + people = { + "parent": {"age": {YEAR: 70}, "is_claimant_or_partner": {YEAR: True}}, + **{ + name: { + "age": {YEAR: 19}, + "current_education": {YEAR: "UPPER_SECONDARY"}, + "age_started_or_accepted_current_education_or_training": {YEAR: 17}, + "is_claimant_or_partner": {YEAR: False}, + } + for name in ("first", "second") + }, + } + people["first"][variable] = {YEAR: False} + situation = { + "people": people, + "benunits": {"b": {"members": list(people)}}, + "households": {"h": {"members": list(people)}}, + } + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + uc = sim.calculate(UC, YEAR) + pc = sim.calculate(PC, YEAR) + # The second student is a qualifying young person for both. + assert uc[2] and pc[2], variable + # The first loses the status the variable governs. + if variable == UC_TERMINAL: + assert not uc[1] and pc[1] + elif variable == NINETEEN: + assert not uc[1] and not pc[1] + else: + assert uc[1] and not pc[1] diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index b97d9ac316..ba52f588f4 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -31,14 +31,13 @@ class housing_benefit_applicable_income(Variable): unit = GBP def formula(benunit, period, parameters): - # Members whose income counts: the claimant and partner and, as the model did - # before, the programme's own children or young persons. The regulations count - # only the claimant's and partner's (HB Regs 2006 reg 25); dropping dependants' - # own income is a follow-up. Anyone else in the benefit unit does not count. + # Members whose income counts: the claimant and partner (SSCBA 1992 + # s.136(1); HB Regs 2006 reg 25(1), HB (SPC) Regs 2006 reg 23(1)). "The + # income and capital of a child or young person shall not be treated as + # the income and capital of the claimant" (HB Regs 2006 reg 25(3)). + # Anyone else in the benefit unit does not count either. person = benunit.members - members = person("is_claimant_or_partner", period) | person( - "is_child_or_young_person_for_legacy_benefits", period - ) + members = person("is_claimant_or_partner", period) pension_age_regulations = benunit( "housing_benefit_pension_age_regulations_apply", period ) diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index eea502562e..52c5e0ddba 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -10,14 +10,13 @@ class income_support_applicable_income(Variable): reference = "https://www.legislation.gov.uk/uksi/1987/1967/schedule/8" def formula(benunit, period, parameters): - # Members whose income counts: the claimant and partner and, as the model did - # before, the programme's own children or young persons. The regulations count - # only the claimant's and partner's (IS Regs 1987 reg 23); dropping dependants' - # own income is a follow-up. Anyone else in the benefit unit does not count. + # Members whose income counts: the claimant and partner (IS Regs 1987 + # reg 23(1)). The income of a child or young person in the claimant's + # family "shall not be treated as the income or capital of the + # claimant" (reg 23(2)). Anyone else in the benefit unit does not count + # either. person = benunit.members - members = person("is_claimant_or_partner", period) | person( - "is_child_or_young_person_for_legacy_benefits", period - ) + members = person("is_claimant_or_partner", period) IS = parameters(period).gov.dwp.income_support INCOME_COMPONENTS = [ "employment_income", diff --git a/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py b/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py new file mode 100644 index 0000000000..de6be75a66 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class is_in_child_tax_credit_extension_period(Variable): + value_type = bool + entity = Person + label = "In a Child Tax Credit extension period" + documentation = ( + "Whether, for some of the year, the person has ceased full-time " + "education or approved training, HMRC was notified within three " + "months that they are registered for work or training with a " + "qualifying body (the Careers Service, Connexions or the Ministry of " + "Defence), and no more than 20 weeks have passed since they left " + "(Child Tax Credit Regulations 2002 reg. 5(3)(b)(i)-(iii)). The model " + "applies the age limit (reg. 5(3)(b)) and the second condition (reg. " + "5(4)). Surveys do not record the registration, so this is false " + "unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5" diff --git a/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py new file mode 100644 index 0000000000..5369423124 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class is_in_remunerative_work_for_child_tax_credit(Variable): + value_type = bool + entity = Person + label = "In remunerative work for Child Tax Credit" + documentation = ( + "Whether the person works at least 24 hours a week for payment or in " + "expectation of payment (Child Tax Credit Regulations 2002 reg. " + "2(1)), read from average weekly hours over the year. The Working " + "Tax Credit rules the definition refers to, for counting hours and " + "for excluded work, are not modelled." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/2" + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.tax_credits.child_tax_credit.eligibility + return person("weekly_hours", period) >= p.remunerative_work_hours diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 2c5e98dd46..8183c1c81b 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -1,26 +1,87 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) class is_qualifying_young_person_for_child_tax_credit(Variable): value_type = bool entity = Person label = "Qualifying young person for Child Tax Credit" + documentation = ( + "A person aged 16 to 19 who meets the Child Tax Credit Regulations " + "2002's conditions. The first condition (reg. 5(3)) is full-time " + "non-advanced education or approved training, begun before 19, or, " + "under 18, the 20 weeks after leaving while registered for work or " + "training. Reg. 4(1) lifts the first condition from the 16th " + "birthday until the 31 August that next follows it. That period " + "reads is_before_first_september_following_16th_birthday, so it " + "counts in any year that starts before the 31 August. For a 16th " + "birthday on 31 August, reg. 4(1A) gives no such period, where " + "Universal Credit gives that day and Child Benefit two: the year " + "counts all the same, as the one in which the person is a child " + "until 30 August, and not the year after. The second condition " + "(reg. 5(4)) always applies: a young person who has ceased full-time " + "education or approved training must not be in remunerative work, and " + "no one may be receiving a benefit in their own right. Full-time " + "education at any level counts (reg. 5(5)), so an advanced student " + "has not ceased it; an apprentice's education, received by virtue of " + "employment, is treated as having ceased. The own-right benefits are " + "read from the inputs for Universal Credit, Employment and Support " + "Allowance, Jobseeker's Allowance and Income Support; reg. 5(4)(c) " + "lists Universal Credit, Income Support and only the income-related " + "and income-based allowances, which the inputs do not separate. " + "Interruptions (reg. 5(7)) are not modelled." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5" + reference = [ + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/4", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5", + ] def formula(person, period, parameters): + p = parameters(period).gov.dwp.tax_credits.child_tax_credit.eligibility + # Reg. 5(3)(a), (ab) and (3A). + receiving_education_or_training = person( + "meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit", + period, + ) + in_education_or_training = receiving_education_or_training & person( + "meets_qualifying_young_person_entry_condition_for_child_tax_credit", + period, + ) + # Reg. 4(1): from the 16th birthday to the 31 August that next + # follows. + before_31_august_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5(3)(b): 20 weeks after leaving, under 18. + in_extension_period = ( + person("age", period) < p.extension_period_age_limit + ) & person("is_in_child_tax_credit_extension_period", period) + # Reg. 5(4)(a) bars remunerative work only for a person "having + # ceased to receive full-time education or approved training". + # Full-time education at a school or college counts at any level + # (reg. 5(5)), so failing the non-advanced test of reg. 5(3)(a) does + # not mean having ceased it. + in_full_time_education = ( + person("current_education", period) != EducationType.NOT_IN_EDUCATION + ) & ~person("is_apprentice", period) + ceased = ~in_full_time_education & ~person("is_in_approved_training", period) + working = person("is_in_remunerative_work_for_child_tax_credit", period) + # Reg. 4(2): the period needs no first condition, only the second. + in_regulation_4_period = before_31_august_following_16th_birthday & ~( + ceased & working + ) + # Reg. 5(3)(b) is for a person who has ceased. + in_20_weeks = in_extension_period & ceased & ~working return ( person( "meets_qualifying_young_person_age_condition_for_child_tax_credit", period, ) - & person( - "meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_child_tax_credit", - period, - ) + & (in_education_or_training | in_regulation_4_period | in_20_weeks) + # Reg. 5(4)(c). & ~person("receives_benefits_in_own_right", period) + & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py index 2897b52f3a..4a3eba2ffc 100644 --- a/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py +++ b/policyengine_uk/variables/gov/dwp/is_responsible_for_child_or_young_person_for_uc_or_housing_benefit.py @@ -11,11 +11,12 @@ class is_responsible_for_child_or_young_person_for_uc_or_housing_benefit(Variabl The family rates apply if either scheme's test is met, so neither scheme's families are moved onto the single rate by the other's narrower definition. - UC reg 5(1)(a) also makes every 16-year-old a qualifying young person until - the 1 September after their 16th birthday, whatever their education. Annual - ages cannot place that date, so a 16-year-old in the benefit unit who is not - a claimant or partner counts here; the UC child element does not yet model - reg 5(1)(a). + The UC test includes reg 5(1)(a), which makes a 16-year-old a qualifying + young person until the 1 September after their 16th birthday, whatever + their education. Housing Benefit's young person follows Child Benefit, + whose school-leaver rules (Child Benefit (General) Regulations 2006 regs 4, + 5 and 7) are not modelled, so a 16-year-old in the benefit unit who is not + a claimant or partner also counts here for the year. """ value_type = bool diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index ee1d028e8d..4cf4be9fd0 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -5,22 +5,48 @@ class is_qualifying_young_person_for_pension_credit(Variable): value_type = bool entity = Person label = "Qualifying young person for Pension Credit" + documentation = ( + "A person aged 16 to 19 who is either before the 1 September following " + "their 16th birthday, whatever their education (reg. 4A(1)(a)), or in " + "non-advanced education or approved training that meets the entry " + "condition, before the 1 September following their 19th birthday " + "(reg. 4A(1)(b), (2) and (3)), and who is not receiving Universal " + "Credit, Employment and Support Allowance, Jobseeker's Allowance or " + "Income Support in their own right (reg. 4A(5))." + ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" def formula(person, period, parameters): - return ( + # Reg. 4A(1)(a): no education or training condition. + before_first_september_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 4A(1)(b), (2) and (3). + in_education_or_training = ( person( - "meets_qualifying_young_person_age_condition_for_pension_credit", + "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", period, ) & person( - "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", + "meets_qualifying_young_person_entry_condition_for_pension_credit", period, ) & person( - "meets_qualifying_young_person_entry_condition_for_pension_credit", + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", period, ) + ) + return ( + person( + "meets_qualifying_young_person_age_condition_for_pension_credit", + period, + ) + & ( + before_first_september_following_16th_birthday + | in_education_or_training + ) + # Reg. 4A(5). & ~person("receives_benefits_in_own_right", period) + & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py new file mode 100644 index 0000000000..0e616c8a64 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class meets_qualifying_young_person_terminal_date_condition_for_pension_credit( + Variable +): + value_type = bool + entity = Person + label = "Meets qualifying young person terminal date condition for Pension Credit" + documentation = ( + "A young person in education or training is a qualifying young person " + "up to, but not including, the 1 September following their 19th " + "birthday (SPC Regs 2002 reg. 4A(1)(b)). Under 19 that date is always " + "ahead; at 19 it is read from " + "is_before_first_september_following_19th_birthday. Its default is " + "true, the value the formula gives everyone under 20 on 6 October, so " + "setting it for some people leaves everyone else under 20 where the " + "formula puts them; older people are excluded by the age limit." + ) + definition_period = YEAR + default_value = True + reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.pension_credit.guarantee_credit.child.eligibility + age = person("age", period) + return (age < p.terminal_date_age_limit) | person( + "is_before_first_september_following_19th_birthday", period + ) diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index 389e51840d..8424884687 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -12,14 +12,12 @@ class tax_credits_applicable_income(Variable): ) def formula(benunit, period, parameters): - # Members whose income counts: the claimant and partner and, as the model did - # before, the programme's own children or young persons. The regulations count - # only the claimant's and partner's (TCA 2002 s.7); dropping dependants' own - # income is a follow-up. Anyone else in the benefit unit does not count. + # Members whose income counts: the claimant or, on a joint claim, the + # claimants (TCA 2002 s.7; Tax Credits (Definition and Calculation of + # Income) Regs 2002 reg 3(1)). A child's or qualifying young person's + # own income does not count, nor anyone else's in the benefit unit. person = benunit.members - members = person("is_claimant_or_partner", period) | person( - "is_child_or_qualifying_young_person_for_child_tax_credit", period - ) + members = person("is_claimant_or_partner", period) TC = parameters(period).gov.dwp.tax_credits STEP_1_COMPONENTS = [ "private_pension_income", diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py index b9f6e4a784..de58b11b88 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py @@ -6,11 +6,23 @@ class is_before_universal_credit_qualifying_young_person_terminal_date(Variable) entity = Person label = "Before the Universal Credit qualifying young person terminal date" documentation = ( - "Whether this person is before the Universal Credit terminal date " - "for a 19-year-old qualifying young person. This captures date-of-birth " - "and assessment-period detail that is not otherwise available in the " - "annual model." + "Whether this person is before the Universal Credit terminal date for " + "a 19-year-old qualifying young person: the 1 September following " + "their 19th birthday (UC Regs 2013 reg. 5(1)(b)). It follows the date " + "of birth (is_before_first_september_following_19th_birthday), so a " + "year counts if it starts before that date. Datasets and situations " + "can set it directly: set it to false for a 19-year-old whose 1 " + "September has passed. Its default is true, the value the date fact " + "gives everyone under 20 on 6 October, so setting it for some people " + "leaves everyone else under 20 where the date fact puts them; older " + "people are excluded by the age limit before it is read." ) definition_period = YEAR - default_value = False - reference = "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders" + default_value = True + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders", + ) + + def formula(person, period, parameters): + return person("is_before_first_september_following_19th_birthday", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 2b1608d539..1b1cfd602d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -5,16 +5,25 @@ class is_qualifying_young_person_for_universal_credit(Variable): value_type = bool entity = Person label = "Qualifying young person for Universal Credit" + documentation = ( + "A person aged 16 to 19 who is either before the 1 September following " + "their 16th birthday, whatever their education (reg. 5(1)(a)), or in " + "non-advanced education or approved training that meets the entry and " + "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who is not " + "receiving Universal Credit, Employment and Support Allowance or " + "Jobseeker's Allowance (reg. 5(5))." + ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/5" def formula(person, period, parameters): - return ( + # Reg. 5(1)(a): no education or training condition. + before_first_september_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5(1)(b), (2) and (3). + in_education_or_training = ( person( - "meets_qualifying_young_person_age_condition_for_universal_credit", - period, - ) - & person( "meets_qualifying_young_person_education_or_training_condition_for_universal_credit", period, ) @@ -26,5 +35,15 @@ def formula(person, period, parameters): "meets_qualifying_young_person_terminal_date_condition_for_universal_credit", period, ) + ) + return ( + person( + "meets_qualifying_young_person_age_condition_for_universal_credit", + period, + ) + & ( + before_first_september_following_16th_birthday + | in_education_or_training + ) & ~person("receives_benefits_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py index 73837e7b14..9ab4e6a4f5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py @@ -7,8 +7,19 @@ class meets_qualifying_young_person_terminal_date_condition_for_universal_credit value_type = bool entity = Person label = "Meets qualifying young person terminal date condition for Universal Credit" + documentation = ( + "A young person in education or training is a qualifying young person " + "up to, but not including, the 1 September following their 19th " + "birthday (UC Regs 2013 reg. 5(1)(b)). Under 19 that date is always " + "ahead; at 19 it is read from " + "is_before_universal_credit_qualifying_young_person_terminal_date, " + "which follows the date of birth unless set." + ) definition_period = YEAR - reference = "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders", + ) def formula(person, period, parameters): p = parameters(period).gov.dwp.universal_credit.elements.child.eligibility diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py b/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py index fbf470039c..785e042a10 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py @@ -8,7 +8,7 @@ class child_benefit_child_index(Variable): definition_period = YEAR def formula(person, period, parameters): - child = person("is_child_or_qualifying_young_person_for_child_benefit", period) + child = person("is_child_benefit_payable_for_person", period) rank = ( person.get_rank( person.benunit, diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py index e8e66493c9..3f5b702adc 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py @@ -12,7 +12,7 @@ class child_benefit_respective_amount(Variable): "https://www.legislation.gov.uk/ukpga/1992/4/part/IX", "https://www.legislation.gov.uk/uksi/2006/965/regulation/2", ) - defined_for = "is_child_or_qualifying_young_person_for_child_benefit" + defined_for = "is_child_benefit_payable_for_person" def formula(person, period, parameters): eligible = True diff --git a/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py b/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py new file mode 100644 index 0000000000..64f48ee6e7 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class is_before_child_benefit_terminal_date(Variable): + value_type = bool + entity = Person + label = "Before the Child Benefit terminal date after leaving education" + documentation = ( + "Whether, for some of the year, the person has left relevant " + "education or approved training and the week that includes their " + "terminal date has not ended. The terminal date is the last day of " + "February, May, August or November, whichever comes first after they " + "left, or after their last external examination if they were entered " + "for one (Child Benefit (General) Regulations 2006 reg. 7(2), Cases 1 " + "and 2). The model holds one education status for the year and no " + "leaving date, so this is false unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/7" diff --git a/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py b/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py new file mode 100644 index 0000000000..8114bf40e2 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class is_child_benefit_payable_for_person(Variable): + value_type = bool + entity = Person + label = "Child Benefit is payable for this person" + documentation = ( + "Whether the benefit unit's Child Benefit includes an amount for this " + "person: they are its child or qualifying young person, and Child " + "Benefit is not barred by remunerative work. Child Benefit is not " + "payable for a qualifying young person 'by virtue of' the terminal " + "date rule for any week they are in remunerative work (Child Benefit " + "(General) Regulations 2006 reg. 7(3)). So a young person who " + "qualifies only through the terminal date, and works 24 hours or " + "more a week, stays a qualifying young person (and a Housing Benefit " + "young person) but brings no Child Benefit. One who also meets " + "another condition is paid through that one (reg. 2(2))." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/223/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/7", + ) + + def formula(person, period, parameters): + another_condition = ( + ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_child_benefit", + period, + ) + ) + | person("is_before_first_september_following_16th_birthday", period) + | person( + "meets_qualifying_young_person_extension_period_condition_for_child_benefit", + period, + ) + ) + only_through_terminal_date = ( + person("is_qualifying_young_person_for_child_benefit", period) + & ~person("is_child_for_child_benefit", period) + & ~another_condition + ) + barred = only_through_terminal_date & person( + "is_in_remunerative_work_for_child_benefit", period + ) + return ( + person("is_child_or_qualifying_young_person_for_child_benefit", period) + & ~barred + ) diff --git a/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py b/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py new file mode 100644 index 0000000000..beb5ed3938 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class is_in_child_benefit_extension_period(Variable): + value_type = bool + entity = Person + label = "In a Child Benefit extension period" + documentation = ( + "Whether the person is in a Child Benefit extension period in the " + "year: they have ceased education or training, are registered for " + "work, education or training with a qualifying body (the Careers " + "Service, Connexions or the Ministry of Defence), the person " + "responsible for them was entitled to Child Benefit for them " + "immediately before and asked for the extension within three months, " + "and the 20 weeks from the week after they left have not run out " + "(Child Benefit (General) Regulations 2006 reg. 5(2)(a), (b), (d), " + "(e) and (f), and (3)). The model applies the age limit (reg. 5(1)) " + "and the bar on remunerative work (reg. 5(2)(c)). Surveys do not " + "record registration or the request, so this is false unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/5" diff --git a/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py new file mode 100644 index 0000000000..10a05ee191 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class is_in_remunerative_work_for_child_benefit(Variable): + value_type = bool + entity = Person + label = "In remunerative work for Child Benefit" + documentation = ( + "Whether the person works at least 24 hours a week for payment or in " + "expectation of payment (Child Benefit (General) Regulations 2006 " + "reg. 1(3)), read from average weekly hours over the year." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/1" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.child_benefit.eligibility + return person("weekly_hours", period) >= p.remunerative_work_hours diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 898a91b7cd..2a122a392c 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -5,25 +5,69 @@ class is_qualifying_young_person_for_child_benefit(Variable): value_type = bool entity = Person label = "Qualifying young person for Child Benefit" + documentation = ( + "A person aged 16 to 19 who meets any of the conditions in Child " + "Benefit (General) Regulations 2006 regs 3 to 7 (reg. 2(2): a " + "qualifying young person until the last of them ceases to be met) " + "and is not receiving a benefit in their own right (reg. 8). The " + "conditions modelled are: non-advanced education or approved " + "training, begun before 19 (reg. 3); the period to the 31 August " + "after the 16th birthday, whatever their education (reg. 4); an " + "extension period for a 16- or 17-year-old (reg. 5); and the period " + "to the terminal date after leaving education (reg. 7). Reg. 4 " + "reads is_before_first_september_following_16th_birthday: its 31 " + "August (1 September for a 16th birthday on 31 August) falls in the " + "same fiscal year as Universal Credit's 1 September, so a year that " + "starts before one starts before the other. Interruptions (reg. 6) " + "are not modelled. Reg. 8 is read from the own-right inputs for " + "Universal Credit, Employment and Support Allowance, Jobseeker's " + "Allowance and Income Support; the incapacity benefit and tax credits " + "it also lists are not." + ) definition_period = YEAR reference = [ "https://www.legislation.gov.uk/ukpga/1992/4/section/142", - "https://www.legislation.gov.uk/uksi/2006/223", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/4", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/5", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/7", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/8", ] def formula(person, period, parameters): + # Reg. 3: education or training, begun before 19. + in_education_or_training = person( + "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", + period, + ) & person( + "meets_qualifying_young_person_entry_condition_for_child_benefit", + period, + ) + # Reg. 4: until the 31 August after the 16th birthday. A person in + # that period is under 17, as reg. 4(1) requires. + before_31_august_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5: the extension period. + in_extension_period = person( + "meets_qualifying_young_person_extension_period_condition_for_child_benefit", + period, + ) + # Reg. 7: until the terminal date after leaving. + before_terminal_date = person("is_before_child_benefit_terminal_date", period) return ( person( "meets_qualifying_young_person_age_condition_for_child_benefit", period, ) - & person( - "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_child_benefit", - period, + & ( + in_education_or_training + | before_31_august_following_16th_birthday + | in_extension_period + | before_terminal_date ) + # Reg. 8. & ~person("receives_benefits_in_own_right", period) + & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py new file mode 100644 index 0000000000..592e0189f7 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class meets_qualifying_young_person_extension_period_condition_for_child_benefit( + Variable +): + value_type = bool + entity = Person + label = "Meets qualifying young person extension period condition for Child Benefit" + documentation = ( + "A person under 18 who has left education or training, is registered " + "with a qualifying body and is not in remunerative work, for the 20 " + "weeks of the extension period their parent or carer asked for." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/5" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.child_benefit.eligibility + return ( + (person("age", period) < p.extension_period_age_limit) + & person("is_in_child_benefit_extension_period", period) + & ~person("is_in_remunerative_work_for_child_benefit", period) + ) 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 index 55575e1987..7c64c59722 100644 --- 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 @@ -33,26 +33,22 @@ class council_tax_reduction_applicable_income(Variable): ] def formula(benunit, period, parameters): - # Members whose income counts: the claimant and partner and, as the model did - # before, the programme's own children or young persons. The regulations count - # only the claimant's and partner's (CTR (Prescribed Requirements) (England) - # Regs 2012 Sch 1 para 11); dropping dependants' own income is a follow-up. - # Anyone else in the benefit unit does not count. + # Members whose income counts: the applicant and partner (CTR + # (Prescribed Requirements) (England) Regs 2012 Sch 1 para 11; WSI + # 2013/3029 Sch 1 para 5; SSI 2012/319 reg 21; SSI 2021/249 reg 36(1)). + # "The income and capital of a child or young person is not to be + # treated as the income and capital of the applicant" (SSI 2021/249 reg + # 36(2)); the other schemes count the applicant's and partner's alone. person = benunit.members # A household head who applies alone - # (council_tax_reduction_head_applies_alone) is not the benefit unit's - # claimant, so this mask has reached them only as a Child Benefit young - # person. The applicant's own income always counts (SSI 2021/249 reg - # 36(1)), so they stay counted where Scotland's working-age scheme no - # longer reads them as a young person. - applicant_counted_as_young_person = person( + # (council_tax_reduction_head_applies_alone) is the applicant but not + # the benefit unit's claimant. The model has counted such a head only + # when they are a Child Benefit young person; that is kept, as their + # own income. Counting every such applicant is #2078's change. + head_applicant_counted = person( "is_council_tax_reduction_applicant_or_partner", period ) & person("is_child_or_young_person_for_legacy_benefits", period) - members = ( - person("is_claimant_or_partner", period) - | person("is_child_or_young_person_for_council_tax_reduction", period) - | applicant_counted_as_young_person - ) + members = person("is_claimant_or_partner", period) | head_applicant_counted # Only the claimant's and partner's income-related awards: the scheme # counts the applicant's and partner's income (Sch 1 para 11 above). A # member outside the family who has an award of their own claims in their diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py new file mode 100644 index 0000000000..22dc35fb9d --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_16th_birthday.py @@ -0,0 +1,48 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day + + +class is_before_first_september_following_16th_birthday(Variable): + value_type = bool + entity = Person + label = ( + "Before the 1 September following the 16th birthday at the start of the year" + ) + documentation = ( + "Whether the fiscal year starts (6 April) before the 1 September " + "following the person's 16th birthday, so that some of the year falls " + "in the qualifying young person route that has no education condition " + "(UC Regs 2013 reg. 5(1)(a); SPC Regs 2002 reg. 4A(1)(a)). The date " + "comes from date_of_birth, or from age and months_since_last_birthday. " + "A 16th birthday on 31 August ends the route that day, and one on 1 " + "September runs it to the 17th birthday. The route ends on a fixed 1 " + "September that annual periods cannot split, so a person in it for " + "part of the year counts for the year, as the benefit cap and LHA " + "family rules count a 16-year-old member. This departs from the 6 " + "October reading of annual status that is_SP_age uses. Everyone aged " + "16 on 6 October qualifies, as does a 17-year-old born from 1 " + "September to 6 October. With birthdays spread evenly, a 16-year-old " + "outside education is a child or in the route for 46% of the year on " + "average, and a 17-year-old for 4%. To place the route for one " + "person, set their date_of_birth. Setting this variable for some " + "people gives everyone else in the situation false, so set it for " + "every person or not at all." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", + ) + + def formula(person, period, parameters): + date_of_birth = birth_day(person, period) + # The 16th birthday falls in the birth year plus 16; the 1 September + # following it is in that year if the birthday is before 1 September, + # and otherwise in the next. + born_on_or_after_first_september = date_of_birth % 10_000 >= 901 + year_of_first_september = ( + date_of_birth // 10_000 + 16 + born_on_or_after_first_september + ) + # The year starts on 6 April, before 1 September of the same calendar + # year and after 1 September of the one before. + return year_of_first_september >= period.start.year diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py new file mode 100644 index 0000000000..6d30c8ad83 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py @@ -0,0 +1,52 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day + + +class is_before_first_september_following_19th_birthday(Variable): + value_type = bool + entity = Person + label = ( + "Before the 1 September following the 19th birthday at the start of the year" + ) + documentation = ( + "Whether the fiscal year starts (6 April) before the 1 September " + "following the person's 19th birthday, the date to which a young " + "person in non-advanced education or approved training stays a " + "qualifying young person (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 " + "reg. 4A(1)(b)). The date comes from date_of_birth, or from age and " + "months_since_last_birthday. A 19th birthday on 31 August ends the " + "period that day, and one on 1 September runs it to the 20th " + "birthday. The period ends on a fixed 1 September that annual " + "periods cannot split, so a person in it for part of the year counts " + "for the year, as is_before_first_september_following_16th_birthday " + "does. Everyone aged 19 on 6 October is before the date. So is a " + "20-year-old born from 1 September to 6 October, whom the qualifying " + "young person age limit, read on 6 October, already excludes. So the " + "formula gives true for everyone under 20 on 6 October, which is " + "everyone that limit lets be a qualifying young person, and the " + "default is true: a situation that sets this for some people gives " + "everyone else true. That is the formula's value for anyone under 20; " + "for someone older it is not, but every reader of this variable is " + "behind the qualifying young person age limit, which excludes them " + "first. Set it to false for a person whose 1 September has passed, or " + "set their date_of_birth." + ) + definition_period = YEAR + default_value = True + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", + ) + + def formula(person, period, parameters): + date_of_birth = birth_day(person, period) + # The 19th birthday falls in the birth year plus 19; the 1 September + # following it is in that year if the birthday is before 1 September, + # and otherwise in the next. + born_on_or_after_first_september = date_of_birth % 10_000 >= 901 + year_of_first_september = ( + date_of_birth // 10_000 + 19 + born_on_or_after_first_september + ) + # The year starts on 6 April, before 1 September of the same calendar + # year and after 1 September of the one before. + return year_of_first_september >= period.start.year diff --git a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py new file mode 100644 index 0000000000..648ba7221c --- /dev/null +++ b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class receives_income_support_in_own_right(Variable): + value_type = bool + entity = Person + label = "Receives Income Support in own right" + documentation = ( + "Whether this person receives Income Support on a claim of their own. " + "It ends qualifying young person status for Pension Credit (SPC Regs " + "2002 reg. 4A(5)), Child Benefit (Child Benefit (General) Regulations " + "2006 reg. 8(2)(a)), with it the Housing Benefit and legacy young " + "person, and Child Tax Credit (CTC Regs 2002 reg. 5(4)(c)). Universal " + "Credit's list (UC Regs 2013 reg. 5(5)) does not include it. By " + "default it is the person's own reported Income Support " + "(income_support_reported) while Income Support is in payment and no " + "reform removes it (neutralizes income_support), as is_on_income_support " + "reads another member's own claim; set it directly otherwise. It does " + "not read the benefit unit's calculated " + "award, which belongs to the claimant and depends on who the family's " + "young persons are." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/8", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5", + ) + + def formula(person, period, parameters): + active = parameters(period).gov.dwp.income_support.active + # A reform that removes Income Support leaves no award to receive. + removed = person.simulation.tax_benefit_system.get_variable( + "income_support" + ).is_neutralized + return active & (not removed) & (person("income_support_reported", period) > 0)