diff --git a/changelog.d/isa-current-law.fixed.md b/changelog.d/isa-current-law.fixed.md new file mode 100644 index 0000000000..8247345237 --- /dev/null +++ b/changelog.d/isa-current-law.fixed.md @@ -0,0 +1 @@ +Correct ISA income and cash-ISA capital treatment, with a versioned adapter for legacy gross-interest datasets. Add qualifying Lifetime ISA bonuses and withdrawal charges, and the final charge on cash interest in non-cash ISAs from fiscal 2027–28, with separate capital-flow accounting. Carry ISA dividend and non-cash interest inputs into later household years using their ordinary-income counterparts' growth indices, and grow stored dataset values with their ordinary counterparts throughout the projection horizon. Name the Lifetime ISA medical-exemption input `lifetime_isa_terminally_ill`. diff --git a/docs/book/assumptions/isa-inputs-and-capital-flows.md b/docs/book/assumptions/isa-inputs-and-capital-flows.md new file mode 100644 index 0000000000..5d32927bb1 --- /dev/null +++ b/docs/book/assumptions/isa-inputs-and-capital-flows.md @@ -0,0 +1,192 @@ +# ISA income, capital and account flows + +## Income inputs + +Household situations use additive income inputs. `savings_interest_income` +means ordinary interest outside tax-free accounts, before tax. ISA interest +belongs in `individual_savings_account_interest_income`, and ISA dividends or +distributions belong in `individual_savings_account_dividend_income`. Ordinary +`dividend_income` excludes those ISA distributions. ISA gains do not belong in +`capital_gains`. + +`tax_free_savings_income` defaults to ISA interest. An explicitly supplied +aggregate, including zero, overrides that default. Market income, capital +income and HBAI income add this exempt-interest aggregate once. They do not +also add its ISA component. For example, £2,000 ordinary interest, £500 ISA +interest and £300 ISA dividends produce £2,800 market returns and £2,000 +taxable savings interest before allowances. The £500 does not offset interest +from an unrelated taxable account. [ISA Regulations regulation 22](https://www.legislation.gov.uk/uksi/1998/1870/regulation/22) +provides the income-tax exemption. + +The supported dataset loaders also accept older gross-interest datasets. +Their adapter subtracts effective exempt interest from gross savings interest +before calculation, preserves explicit exempt-interest overrides, and records +the input and projection conventions. See the dataset-source engineering +guide for the versioned metadata contract. A raw household situation has the +additive convention; it has no dataset provenance to identify older gross +amounts. Producers must mark newly additive datasets before switching their +exports. The model must not infer the convention from account amounts. Local +council-tax reduction non-dependant tests that use normal gross income add +ordinary and exempt returns together, preserving their gross-income bands. +Income-tax and Tax Credit income bases exclude the separately supplied ISA +returns; their input definitions do not turn a tax exemption into a general +exclusion from every means test. + +PolicyEngine-UK maintainers retain responsibility for this compatibility +adapter, including its integration with private PolicyEngine Core input and +formula APIs. Changes to Core require checking the ISA contract regressions +for legacy and additive datasets, explicit overrides, projections and resets. +Retiring the adapter requires an explicit maintainer decision supported by a +versioned producer and consumer migration. + +`non_cash_isa_cash_interest` is an **of-which** component of total ISA interest, +covering cash interest and corresponding alternative-finance returns in a +stocks-and-shares or innovative finance ISA. If only this detail is supplied, +it supplies the ISA-interest total; an explicit total takes priority. A +supplied total must include the detail. The detail is never added to market +income a second time. + +For household inputs carried into later years, ISA dividends use the same +nominal GDP-per-capita index as ordinary `dividend_income`. +`non_cash_isa_cash_interest` uses the same ONS household-interest-income index +as ordinary `savings_interest_income`. An explicit observation for a later +year takes priority and provides the starting amount for subsequent years. +Dataset projections grow stored ISA dividends and non-cash ISA cash interest +by nominal GDP per capita, matching the dataset rules for ordinary dividends +and savings interest. This also applies before 2031. The non-cash interest +detail therefore follows the existing dataset interest rule, while direct +household inputs use the ONS index above. Alignment of the interest indices +is tracked separately in [#2638](https://github.com/PolicyEngine/policyengine-uk/pull/2638). +The legacy and additive interest contracts retain their recorded conventions. + +From 6 April 2027, `isa_cash_interest_flat_rate_charge` applies the savings +basic rate to that detail, without personal allowances or the personal +savings allowance. The manager's final charge enters `gov_tax`, +`household_tax` and HBAI income deductions separately from `income_tax`. +The gross interest enters market income once. [SI 2026/1018 regulations 1 +and 15](https://www.legislation.gov.uk/uksi/2026/1018/made) establish this +charge; [Finance Act 2026 section 5](https://www.legislation.gov.uk/ukpga/2026/11/section/5) +sets the 2027–28 savings basic rate at 22%. + +## Winter Fuel Payment and projections + +From fiscal 2025, the model calculates gross Winter Fuel Payment entitlement +separately from the personal income-tax charge. The charge recovers the full +payment when the recipient's total taxable income exceeds £35,000, subject +to the relevant-benefit exemption. ISA interest and distributions are exempt +income and stay outside that total. The gross payment itself also stays +outside taxable income. These treatments follow [HMRC's Winter Fuel Payment +charge guidance](https://www.gov.uk/hmrc-internal-manuals/paye-manual/paye130030) +and [the ISA tax exemption](https://www.gov.uk/individual-savings-accounts/how-isas-work). + +The £35,000 threshold is fixed in nominal terms in the model. Growth in +taxable income can therefore move recipients across it. Dataset projections +extend through 2032; where a growth series ends in 2030, the extension repeats +its final annual growth rate in 2031 and 2032. The dataset-source and economic +assumptions documentation describe these conventions. This extension is an +extrapolation rather than a new published forecast. + +These mechanisms alone do not establish the cause or magnitude of a +population trend. A comparison must distinguish gross payments from the +income-tax charge and use the same dataset, policy version, year and income +convention. Earlier model versions represented recovery through a household +eligibility proxy, so results from that version require a separate comparison +with the current personal charge. No current population attribution is +established by the household tests. + +## Capital stocks + +`cash_isa` is a household stock separate from `savings`. Ordinary accessible +cash ISA balances enter the capital sources for Universal Credit, Housing +Benefit, Pension Credit, Income Support, income-based JSA and income-related +ESA. There is no general ISA-wrapper disregard and no generic 10% sale-cost +deduction for cash. Existing source-specific disregards, ownership allocation +assumptions and reported-capital overrides still apply. A complete reported +capital amount already includes relevant ISA holdings; it replaces covered +components rather than adding them again. + +For example, £5,000 savings and a £3,000 ordinary cash ISA give £8,000 UC +capital. [UC regulation 72](https://www.legislation.gov.uk/uksi/2013/376/regulation/72) +then gives eight £4.35 monthly tariff units, or £34.80 a month. Stocks-and-shares +ISA holdings remain within `share_like_capital`. The separate existing +`lifetime_isa_countable_capital` valuation is unchanged. It does not gain +transaction-level first-home or terminal-illness valuation from the new flow +inputs. + +Population estimates depend on the input balances, reported-capital +overrides, benefit take-up and calibrated weights as well as the capital +rules. [Microcosm issue 1360](https://github.com/PolicyEngine/microcosm/issues/1360) +tracks coordinated producer migration and calibration work. A released +producer dataset recalibrated for this expanded capital definition has not +been verified here. The numerical gap between a consistent population +estimate and the relevant DWP outturn also remains unverified. Household +consumer tests establish the routing into benefit calculations; they do not +establish a population magnitude or fit to outturn. + +## Lifetime ISA payments and withdrawals + +`lifetime_isa_payments` contains qualifying additions in the year, including +qualifying transfers from other ISA types. Exclude growth, government bonuses, +transfers between Lifetime ISAs, returned failed-purchase withdrawals, +defaulted payments and rebated fees. The bonus is 25% of qualifying payments +up to £4,000, from age 18 until before age 50. Payments at ages 40–49 assume a +valid account first funded before age 40. The model does not decide whether +an account may be opened. [ISA Regulations regulations 4ZA and 10A and +Schedule paragraph 1](https://www.legislation.gov.uk/uksi/1998/1870) +define these conditions. + +`lifetime_isa_withdrawals` is the gross withdrawal, including any charge, +excluding qualifying transfers between Lifetime ISAs and withdrawals on +death. All such withdrawals are exempt at the charge-free age of 60 or when +`lifetime_isa_terminally_ill` is true. That flag means the investor has a +registered medical practitioner's written evidence of life expectancy below one year; +it is not a generic benefit-eligibility flag. + +`lifetime_isa_withdrawals_towards_first_home` is the part paid directly to an +eligible conveyancer towards the purchase price, with required declarations +and completion or return procedures satisfied. It excludes fees and payments +through the investor. The model separately requires: + +- the investor's `lifetime_isa_first_time_buyer` flag: no previous residential + ownership anywhere, irrespective of another joint purchaser's history; +- `lifetime_isa_months_since_first_payment` of at least 12, including qualifying + transfer continuity; +- a positive whole-property price no greater than £450,000 in + `main_residential_property_purchased`; +- a UK main-home purchase, represented by + `main_residential_property_purchased_is_in_uk`; +- qualifying secured finance, represented by + `main_residential_property_purchased_with_mortgage`. This means an eligible + legal mortgage or regulated home-purchase plan satisfying the applicable + connected-lender and buy-to-let restrictions. The qualifying-condition + input includes the prescribed exceptions for overseas Crown employees + and their spouses or civil partners, and permitted delayed main-home + occupation. + +Only the bounded qualifying home-payment portion is exempt. The residual +gross withdrawal is charged at 25%, or 20% during the temporary coronavirus +period. These rules come from [Schedule paragraphs 4–7 and 5A](https://www.legislation.gov.uk/uksi/1998/1870/schedule). + +The model records `lifetime_isa_bonus` in government spending and +`lifetime_isa_withdrawal_charge` in government revenue. Both are capital +flows, excluded from household current benefits, taxes and net income. The +flows do not automatically update a supplied account balance. This is an +accounting convention for the model's current-income measures. + +## Annual resolution and coverage + +A bare year Y represents the fiscal year from 6 April Y to 5 April Y+1. +Parameters retain their legal effective dates; the annual model samples +rates at 30 April. Fiscal 2020 therefore uses the temporary 20% withdrawal +charge and fiscal 2021 uses 25%. The annual model cannot distinguish a +withdrawal in late March 2020 within fiscal 2019. Annual ages and purchase +conditions similarly represent the relevant transactions at one annual +status. Multiple transactions with different conditions require externally +prepared qualifying amounts. + +Help to Buy ISA bonuses are not modelled here. Subscription-limit compliance, +Junior ISAs, Child Trust Funds and a proposed First Time Buyer ISA are also +outside this implementation. Population data without payment, withdrawal or +non-cash-interest detail inputs retain zero for those new flows. Household +tests establish the rules; population effects require a separate certified +dataset measurement. diff --git a/docs/engineering/skills/dataset-sources.md b/docs/engineering/skills/dataset-sources.md index 08ab4e1b5b..933e0c1625 100644 --- a/docs/engineering/skills/dataset-sources.md +++ b/docs/engineering/skills/dataset-sources.md @@ -43,6 +43,63 @@ When debugging dataset failures, distinguish these stages: Errors at those stages require different fixes. +## ISA income input contracts + +Calculator situations use additive savings inputs: `savings_interest_income` +is ordinary interest, while `tax_free_savings_income` is a separate cash-income +component. Its default is `individual_savings_account_interest_income`; an +explicit aggregate override, including zero, remains authoritative. + +Existing unmarked datasets use the legacy contract: their savings-interest +column already includes the tax-free component. UK loads those records as +signed ordinary interest `N = G - T`, preserving explicit ISA and aggregate +inputs. There is no floor: `G < T` remains an override whose taxable result is +`max(0, N + other_investment_income)`, exactly as before. The adapter does not +infer an ISA amount from a tax-free aggregate. + +`UKSingleYearDataset` accepts an optional `isa_income_contract` argument and +exposes a detached dictionary with exactly these fields: + +```json +{"version": 1, "representation": "additive", "projection": "additive"} +``` + +The other valid pairs are `legacy_gross`/`legacy_gross` for raw legacy data +and `additive`/`legacy_gross` for normalized legacy data. Representation and +projection origin are separate: legacy projection grows the original gross +amount before subtracting the effective exemption. It must not grow the +already-normalized ordinary amount instead. New additive dataset projections +grow ISA interest alongside ordinary interest by the dataset's GDP-per-capita +rule; outside the stored horizon new inputs use their variable's own index. +Legacy-origin fallback retains the old gross-index calculation and unindexed +ISA carry-over, while explicit inputs and applicable ISA detail formulas take +priority. Legacy files containing only an aggregate exemption still have that +old exemption treatment; no ISA detail is invented during migration. + +DataFrames carry this declaration in +`frame.attrs["policyengine_uk.isa_income_contract"]`. H5 files store UTF-8 JSON +under the same namespaced **root attribute**, never as an extra table or model +variable. Multi-year datasets and input-frame exports use per-year envelopes: + +```json +{"version": 1, "years": {"2025": {"version": 1, "representation": "additive", "projection": "legacy_gross"}}} +``` + +A uniform single contract is also accepted when importing multiple years. +Every materialized year must be covered. Unknown versions, malformed metadata +and conflicting explicit object/file declarations fail with an `ISA income +contract` error. Omitted metadata means legacy data; the presence or relative +size of financial columns never chooses the representation. + +UK copy, clone, household extraction, `from_simulation`, input-frame export and +single-/multi-year H5 round trips retain the projection origin and mark +normalized values as additive so they cannot be subtracted twice. Core Dataset +objects can carry the same `isa_income_contract` attribute; UK reads H5 root +metadata before Core's value loader discards it. External writers which +discard H5 attributes must explicitly preserve this declaration before their +output can be reloaded safely. Test routing and migration on synthetic frames +and small H5 fixtures, without downloading private datasets. + ## Defaults `policyengine_uk` does not silently choose a default dataset when no situation is diff --git a/policyengine_uk/data/dataset_schema.py b/policyengine_uk/data/dataset_schema.py index b1f35ce85b..23561e0317 100644 --- a/policyengine_uk/data/dataset_schema.py +++ b/policyengine_uk/data/dataset_schema.py @@ -8,6 +8,8 @@ from pathlib import Path import h5py +import json + from policyengine_uk.data.energy_price_basis import ( EnergyPriceBasis, merge_energy_price_basis, @@ -15,6 +17,16 @@ read_energy_price_basis, write_energy_price_basis, ) +from policyengine_uk.data.isa_income import ( + ISA_INCOME_CONTRACT_KEY, + MISSING_CONTRACT, + contract_envelope, + contracts_for_years, + read_h5_contract, + resolve_contracts, + simulation_contract, + validate_contract, +) class UKSingleYearDataset: @@ -65,6 +77,7 @@ def __init__( household: pd.DataFrame = None, fiscal_year: int = None, energy_price_basis: dict[str, str | int] | EnergyPriceBasis | None = None, + isa_income_contract: dict | None = None, ): basis = parse_energy_price_basis(energy_price_basis) file_path = str(file_path) if file_path else None @@ -108,6 +121,11 @@ def __init__( self.time_period = str(fiscal_year) self._energy_price_basis = basis + self.isa_income_contract = resolve_contracts( + MISSING_CONTRACT if isa_income_contract is None else isa_income_contract, + read_h5_contract(file_path), + [int(self.time_period[:4])], + )[int(self.time_period[:4])] self.data_format = "arrays" # tables tuple points at the raw (int16) DataFrames used by the # simulation engine internals - do not decode these. @@ -171,12 +189,15 @@ def household(self, df: pd.DataFrame): self.tables = (self._person, self._benunit, self._household) def save(self, file_path: str): + contract = validate_contract(self.isa_income_contract) with pd.HDFStore(file_path) as f: f.put("person", self.person, format="table", data_columns=True) f.put("benunit", self.benunit, format="table", data_columns=True) f.put("household", self.household, format="table", data_columns=True) f.put("time_period", pd.Series([self.time_period]), format="table") write_energy_price_basis(file_path, self._energy_price_basis) + with h5py.File(file_path, "a") as f: + f.attrs[ISA_INCOME_CONTRACT_KEY] = json.dumps(contract) def load(self): data = {} @@ -193,6 +214,7 @@ def copy(self): household=self.household.copy(), fiscal_year=self.time_period, energy_price_basis=self._energy_price_basis, + isa_income_contract=validate_contract(self.isa_income_contract), ) def validate(self): @@ -230,6 +252,7 @@ def from_simulation( household=entity_dfs["household"], fiscal_year=fiscal_year, energy_price_basis=energy_price_basis, + isa_income_contract=simulation_contract(simulation, fiscal_year), ) @@ -252,8 +275,15 @@ def __init__( if file_path is not None: UKMultiYearDataset.validate_file_path(file_path) + stored_contract = read_h5_contract(file_path) with pd.HDFStore(file_path, mode="r") as f: self.datasets = {} + years = [ + int(key.split("/")[2]) + for key in f.keys() + if key.startswith("/person/") + ] + contracts = contracts_for_years(stored_contract, years) for year in f.keys(): if year.startswith("/person/"): fiscal_year = int(year.split("/")[2]) @@ -265,6 +295,7 @@ def __init__( benunit=benunit_df, household=household_df, fiscal_year=fiscal_year, + isa_income_contract=contracts[fiscal_year], ) for year, dataset in self.datasets.items(): dataset.energy_price_basis = read_energy_price_basis( @@ -284,10 +315,20 @@ def get_year(self, fiscal_year: int) -> UKSingleYearDataset: def years(self): return list(self.datasets.keys()) + @property + def isa_income_contract(self): + return contract_envelope( + { + year: dataset.isa_income_contract + for year, dataset in self.datasets.items() + } + ) + def __getitem__(self, fiscal_year: int): return self.get_year(fiscal_year) def save(self, file_path: str): + contracts = self.isa_income_contract Path(file_path).unlink(missing_ok=True) # Remove existing file if it exists with pd.HDFStore(file_path) as f: for year, dataset in self.datasets.items(): @@ -297,6 +338,7 @@ def save(self, file_path: str): format="table", data_columns=True, ) + f.put( f"benunit/{year}", dataset.benunit, @@ -320,6 +362,9 @@ def save(self, file_path: str): file_path, dataset.energy_price_basis, f"/time_period/{year}" ) + with h5py.File(file_path, "a") as f: + f.attrs[ISA_INCOME_CONTRACT_KEY] = json.dumps(contracts) + def copy(self): new_datasets = {year: dataset.copy() for year, dataset in self.datasets.items()} return UKMultiYearDataset(datasets=list(new_datasets.values())) diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 6af1d33aaa..03029c69c1 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -3,6 +3,14 @@ CGT_DATASET_INPUTS, CGT_INDEX_PATH, ) +from policyengine_uk.data.isa_income import ( + ISA_INTEREST, + SAVINGS_INTEREST, + effective_tax_free, + gross_interest, + legacy_projection_source, + ordinary_interest, +) import hashlib @@ -241,6 +249,8 @@ def extend_single_year_dataset( end_year: int | None = None, ) -> UKMultiYearDataset: """Project a supplied year to the shared default within any finite CGT span.""" + # A normalized legacy export still projects the original gross amount. + dataset = legacy_projection_source(dataset) # Extend years and uprate start_year = int(dataset.time_period) if end_year is None: @@ -359,6 +369,31 @@ def apply_single_year_uprating( ) getattr(current_year, table_name)[variable] = current_year_value + if ( + current_year.isa_income_contract["projection"] == "legacy_gross" + and SAVINGS_INTEREST in current_year.person + ): + growth = parameters.get_child( + "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" + )(current_year.time_period) + values = gross_interest(previous_year) * (1 + growth) + if current_year.isa_income_contract["representation"] == "additive": + values = values - effective_tax_free(current_year.person) + current_year.person[SAVINGS_INTEREST] = values + + # New additive ISA receipts use the same dataset GDP rule as ordinary + # interest. Legacy receipts retain their former unindexed treatment. + if ( + current_year.isa_income_contract["projection"] == "additive" + and ISA_INTEREST in current_year.person + ): + growth = parameters.get_child( + "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" + )(current_year.time_period) + current_year.person[ISA_INTEREST] = previous_year.person[ISA_INTEREST] * ( + 1 + growth + ) + # Next, apply custom uprating. current_year = uprate_energy(current_year, previous_year, parameters) @@ -389,6 +424,10 @@ def apply_single_year_uprating( # inputs missing from the projection map (for example salary sacrifice). if int(current_year.time_period) > LEGACY_PROJECTION_END_YEAR: for variable, index_name in _unmapped_uprating_indices(uprating).items(): + # Handled above for new additive data; deliberately unchanged for + # legacy gross data, whose exemption never had this new index. + if variable == ISA_INTEREST: + continue for table_name in current_year.table_names: table = getattr(current_year, table_name) if variable in table.columns: @@ -824,7 +863,7 @@ def reset_growthfactor_uprating( growthfactor_uprated_variables = { variable for variables in uprating.values() for variable in variables } - growthfactor_uprated_variables.update({"council_tax", "rent"}) + growthfactor_uprated_variables.update({"council_tax", "rent", ISA_INTEREST}) growthfactor_uprated_variables.update(COUNTRY_UPRATED_INPUTS) growthfactor_uprated_variables.update(_unmapped_uprating_indices(uprating)) growthfactor_uprated_variables.difference_update(DATASET_UPRATING_EXCLUSIONS) @@ -863,7 +902,30 @@ def reset_growthfactor_uprating( if variable == "bsp_reported" and preserve_bsp: continue if variable in current_table.columns and variable in base_table.columns: - current_table[variable] = base_table[variable].values + if ( + variable == ISA_INTEREST + and current_year_dataset.isa_income_contract["projection"] + == "legacy_gross" + ): + continue + if variable == SAVINGS_INTEREST: + if ( + current_year_dataset.isa_income_contract["projection"] + == "legacy_gross" + ): + values = gross_interest(base_year_dataset) + if ( + current_year_dataset.isa_income_contract[ + "representation" + ] + == "additive" + ): + values = values - effective_tax_free(current_table) + else: + values = ordinary_interest(base_year_dataset) + current_table[variable] = values + else: + current_table[variable] = base_table[variable].values if reset_energy: for variable in shared_energy: current_year_dataset.household[variable] = base_year_dataset.household[ diff --git a/policyengine_uk/data/filter_dataset.py b/policyengine_uk/data/filter_dataset.py index 8515c03688..9d80dcc189 100644 --- a/policyengine_uk/data/filter_dataset.py +++ b/policyengine_uk/data/filter_dataset.py @@ -70,4 +70,5 @@ def filter_dataset( benunit=new_dataset.benunit, fiscal_year=year, energy_price_basis=new_dataset.energy_price_basis, + isa_income_contract=new_dataset.isa_income_contract, ) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 30c01ae850..8d27ae1056 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -143,7 +143,8 @@ totals: label: Government spending in respect of the household description: >- Cash benefits, childcare and student support, and public services in - kind (NHS, education, transport subsidies). + kind (NHS, education, transport subsidies), and Lifetime ISA bonuses + paid into capital accounts. dropped_when: gov.contrib.abolish_council_tax: - council_tax_benefit @@ -151,13 +152,38 @@ totals: label: Benefits received by the household description: >- The benefit side of household net income. It is gov_spending without the - contributed budget-change line, which is not paid to households. + contributed budget-change line and Lifetime ISA capital bonuses; + neither is part of household current benefit income. dropped_when: gov.contrib.abolish_council_tax: - council_tax_benefit tax_heads: # Income and earnings + - id: isa_cash_interest_charge + label: Final charge on cash interest in non-cash ISAs + variable: isa_cash_interest_flat_rate_charge + entity: person + obr_line: Income tax + obr_table_3_1: Income tax + report_group: income_tax + payer: household + totals: [gov_tax, household_tax] + sign: nonnegative + - id: lifetime_isa_withdrawal_charge + label: Lifetime ISA withdrawal charge + variable: lifetime_isa_withdrawal_charge + entity: person + obr_line: not_an_obr_current_receipt + obr_table_3_1: null + obr_line_note: >- + The model records this payment out of account capital as government + revenue, outside current household income taxes. No placement in an + OBR current-receipts row is asserted here. + report_group: lifetime_isa_capital_transfers + payer: household + totals: [gov_tax] + sign: nonnegative - id: income_tax label: Income tax variable: income_tax @@ -812,6 +838,13 @@ uncovered_receipts: - {obr_line: Other taxes} spending_heads: + - id: lifetime_isa_bonus + label: Lifetime ISA bonus + variable: lifetime_isa_bonus + entity: person + report_group: lifetime_isa_capital_transfers + totals: [gov_spending] + sign: nonnegative # Pensioners - id: state_pension label: State Pension diff --git a/policyengine_uk/data/input_dataframe.py b/policyengine_uk/data/input_dataframe.py index fae8c23a74..8248503fb1 100644 --- a/policyengine_uk/data/input_dataframe.py +++ b/policyengine_uk/data/input_dataframe.py @@ -38,6 +38,11 @@ UKMultiYearDataset, UKSingleYearDataset, ) +from policyengine_uk.data.isa_income import ( + ISA_INCOME_CONTRACT_KEY, + MISSING_CONTRACT, + contracts_for_years, +) if TYPE_CHECKING: from policyengine_core.taxbenefitsystems import TaxBenefitSystem @@ -428,6 +433,9 @@ def no_tables() -> Dict[str, Dict[str, np.ndarray]]: for year in sorted(identifier_years - set(years)) for variable, values in {**person_identifiers, **group_identifiers}.items() ] + contracts = contracts_for_years( + df.attrs.get(ISA_INCOME_CONTRACT_KEY, MISSING_CONTRACT), years + ) datasets = [ UKSingleYearDataset( person=pd.DataFrame({**person_identifiers, **tables["person"]}), @@ -438,6 +446,7 @@ def no_tables() -> Dict[str, Dict[str, np.ndarray]]: {"household_id": group_ids["household"], **tables["household"]} ), fiscal_year=fiscal_year, + isa_income_contract=contracts[fiscal_year], ) for fiscal_year, tables in sorted(years.items()) ] diff --git a/policyengine_uk/data/isa_income.py b/policyengine_uk/data/isa_income.py new file mode 100644 index 0000000000..474894cc39 --- /dev/null +++ b/policyengine_uk/data/isa_income.py @@ -0,0 +1,322 @@ +"""Versioned savings-interest input semantics at UK dataset boundaries. + +Legacy data holds ISA interest inside gross savings interest. Calculator +inputs and normalized datasets hold ordinary interest plus a separate tax-free +aggregate. Projection origin is independent of that representation. +""" + +import copy +import json +from pathlib import Path + +import h5py +import numpy as np +import pandas as pd +from policyengine_core.periods import period as period_ +from policyengine_core.simulations.simulation import ( + _latest_input_key, + _uprating_index_value, +) + + +ISA_INCOME_CONTRACT_KEY = "policyengine_uk.isa_income_contract" +SAVINGS_INTEREST = "savings_interest_income" +ISA_INTEREST = "individual_savings_account_interest_income" +TAX_FREE_INTEREST = "tax_free_savings_income" +LEGACY_CONTRACT = { + "version": 1, + "representation": "legacy_gross", + "projection": "legacy_gross", +} +ADDITIVE_CONTRACT = { + "version": 1, + "representation": "additive", + "projection": "additive", +} +MISSING_CONTRACT = object() + + +def latest_input(simulation, name, period, *, earlier=False): + """Core's branch-readable input selection, excluding calculated caches.""" + period = period_(period) + holder = simulation.get_holder(name) + definition_period = simulation.tax_benefit_system.variables[name].definition_period + candidates = [ + source + for source in holder.get_input_periods(simulation.branch_name) + if (source.start < period.start if earlier else source.start <= period.start) + and holder.get_array(source, simulation.branch_name) is not None + ] + return max( + candidates, + key=lambda source: _latest_input_key(source, definition_period), + default=None, + ) + + +def has_isa_interest_detail_input(simulation, period): + """Work on generic Core simulations as well as UK dataset simulations.""" + return latest_input(simulation, "non_cash_isa_cash_interest", period) is not None + + +def isa_interest_income_default(simulation, period): + """Formula-only ISA detail/input fallback, valid on generic Core too. + + Core gives a supplied total for this period priority before calling the + formula. A newer detail can supply a new observation, but a same-period + or older of-which amount cannot replace a supplied total. Earlier totals + carry under the legacy contract or grow by the actual ONS index under + the additive contract. Calculated caches are never sources. + """ + period = period_(period) + source = latest_input(simulation, ISA_INTEREST, period) + detail = latest_input(simulation, "non_cash_isa_cash_interest", period) + definition_period = simulation.tax_benefit_system.variables[ + ISA_INTEREST + ].definition_period + if detail is not None and ( + source is None + or _latest_input_key(detail, definition_period) + > _latest_input_key(source, definition_period) + ): + return simulation.calculate("non_cash_isa_cash_interest", period) + if source is None: + return None + values = simulation.get_holder(ISA_INTEREST).get_array( + source, simulation.branch_name + ) + if ( + simulation_contract(simulation, period.start.year)["projection"] + == "legacy_gross" + ): + return values + index = simulation.tax_benefit_system.parameters.get_child( + "gov.economic_assumptions.indices.ons.household_interest_income" + ) + source_index = _uprating_index_value(index, source.start) + target_index = _uprating_index_value(index, period.start) + factor = ( + target_index / source_index + if source_index not in (None, 0) and target_index is not None + else 1 + ) + return values * factor + + +def _error(message): + return ValueError("ISA income contract: " + message) + + +def validate_contract(contract): + if not isinstance(contract, dict) or set(contract) != { + "version", + "representation", + "projection", + }: + raise _error("expected version, representation and projection.") + if type(contract["version"]) is not int or contract["version"] != 1: + raise _error("unsupported version.") + if not all( + isinstance(contract[key], str) for key in ("representation", "projection") + ): + raise _error("representation and projection must be strings.") + pair = (contract["representation"], contract["projection"]) + if pair not in { + ("legacy_gross", "legacy_gross"), + ("additive", "legacy_gross"), + ("additive", "additive"), + }: + raise _error("unknown or contradictory representation/projection.") + return copy.deepcopy(contract) + + +def contracts_for_years(marker, years): + """Validate a uniform contract or an envelope covering the required years.""" + years = [str(int(year)) for year in years] + if marker is MISSING_CONTRACT: + marker = LEGACY_CONTRACT + if isinstance(marker, dict) and set(marker) == {"version", "years"}: + if type(marker["version"]) is not int or marker["version"] != 1: + raise _error("unsupported envelope version.") + entries = marker["years"] + if not isinstance(entries, dict): + raise _error("years must be a mapping.") + validated = {} + for year, contract in entries.items(): + if ( + not isinstance(year, str) + or not year.isdigit() + or str(int(year)) != year + ): + raise _error("year keys must be fiscal-year strings.") + validated[year] = validate_contract(contract) + if set(years) - set(validated): + raise _error("the envelope omits a dataset year.") + return {int(year): copy.deepcopy(validated[year]) for year in years} + contract = validate_contract(marker) + return {int(year): copy.deepcopy(contract) for year in years} + + +def contract_envelope(contracts): + return { + "version": 1, + "years": { + str(int(year)): validate_contract(contract) + for year, contract in sorted(contracts.items()) + }, + } + + +def read_h5_contract(path): + """Read metadata without exposing it as a Core dataset variable.""" + if path is None or not Path(path).is_file() or Path(path).suffix != ".h5": + return MISSING_CONTRACT + with h5py.File(path, "r") as file: + if ISA_INCOME_CONTRACT_KEY not in file.attrs: + return MISSING_CONTRACT + encoded = file.attrs[ISA_INCOME_CONTRACT_KEY] + try: + if isinstance(encoded, bytes): + encoded = encoded.decode("utf-8") + return json.loads(encoded) + except (TypeError, ValueError, UnicodeError) as error: + raise _error("malformed H5 metadata.") from error + + +def read_object_contract(dataset): + """Read declared metadata without Core's dynamic dataset-variable lookup.""" + try: + return object.__getattribute__(dataset, "isa_income_contract") + except AttributeError: + return MISSING_CONTRACT + + +def resolve_contracts(explicit, stored, years): + """An absent declaration is a default, not a contradictory explicit one.""" + explicit_map = ( + None if explicit is MISSING_CONTRACT else contracts_for_years(explicit, years) + ) + stored_map = ( + None if stored is MISSING_CONTRACT else contracts_for_years(stored, years) + ) + if ( + explicit_map is not None + and stored_map is not None + and explicit_map != stored_map + ): + raise _error("explicit object and file metadata disagree.") + return explicit_map or stored_map or contracts_for_years(MISSING_CONTRACT, years) + + +def interest_values(table, name): + if name not in table: + return np.zeros(len(table), dtype=float) + try: + values = pd.to_numeric(table[name], errors="raise").to_numpy(dtype=float) + except (TypeError, ValueError) as error: + raise _error(f"{name} must be numeric.") from error + if not np.isfinite(values).all(): + raise _error(f"{name} must be finite.") + return values + + +def effective_tax_free(table, previous_isa=None): + # Validate both supplied inputs, even when the aggregate overrides I. + isa = interest_values(table, ISA_INTEREST) + if TAX_FREE_INTEREST in table: + return interest_values(table, TAX_FREE_INTEREST) + return isa if ISA_INTEREST in table or previous_isa is None else previous_isa + + +def ordinary_interest(dataset): + contract = validate_contract(dataset.isa_income_contract) + gross_or_ordinary = interest_values(dataset.person, SAVINGS_INTEREST) + exempt = effective_tax_free(dataset.person) + if ( + SAVINGS_INTEREST not in dataset.person + and contract["projection"] == "legacy_gross" + ): + return -exempt + return ( + gross_or_ordinary - exempt + if contract["representation"] == "legacy_gross" + else gross_or_ordinary + ) + + +def gross_interest(dataset): + contract = validate_contract(dataset.isa_income_contract) + values = interest_values(dataset.person, SAVINGS_INTEREST) + exempt = effective_tax_free(dataset.person) + if ( + SAVINGS_INTEREST not in dataset.person + and contract["projection"] == "legacy_gross" + ): + return np.zeros(len(dataset.person), dtype=float) + return values if contract["representation"] == "legacy_gross" else values + exempt + + +def normalized_single_year(dataset, previous_isa=None): + """Detach before conversion; signed ordinary amounts preserve overrides.""" + out = dataset.copy() + table = out.person + exempt = effective_tax_free(table, previous_isa) + if SAVINGS_INTEREST in table: + values = interest_values(table, SAVINGS_INTEREST) + if out.isa_income_contract["representation"] == "legacy_gross": + values = values - exempt + out.person = table.assign(**{SAVINGS_INTEREST: values}) + out.isa_income_contract = { + **validate_contract(out.isa_income_contract), + "representation": "additive", + } + return out + + +def normalized_dataset(dataset): + from policyengine_uk.data.dataset_schema import UKMultiYearDataset + + # A cache stores the canonical object once. Revalidate declarations and + # participating amounts, but retain that object on subsequent loads. + if all( + validate_contract(dataset[year].isa_income_contract)["representation"] + == "additive" + for year in dataset.years + ): + for year in dataset.years: + effective_tax_free(dataset[year].person) + interest_values(dataset[year].person, SAVINGS_INTEREST) + return dataset + + singles = [] + previous_isa = None + for year in sorted(dataset.years): + single = dataset[year] + singles.append(normalized_single_year(single, previous_isa)) + if ISA_INTEREST in single.person: + previous_isa = interest_values(single.person, ISA_INTEREST) + out = UKMultiYearDataset(datasets=singles) + if hasattr(dataset, "_cgt_extension_provenance"): + out._cgt_extension_provenance = copy.deepcopy(dataset._cgt_extension_provenance) + return out + + +def legacy_projection_source(dataset): + """Undo only representation conversion before the old gross projection.""" + contract = validate_contract(dataset.isa_income_contract) + if contract != {**LEGACY_CONTRACT, "representation": "additive"}: + return dataset + out = dataset.copy() + if SAVINGS_INTEREST in out.person: + out.person = out.person.assign(**{SAVINGS_INTEREST: gross_interest(out)}) + out.isa_income_contract = copy.deepcopy(LEGACY_CONTRACT) + return out + + +def simulation_contract(simulation, year): + contracts = getattr(simulation, "_isa_income_contracts", {}) + year = int(str(year)[:4]) + if year in contracts: + return validate_contract(contracts[year]) + earlier = [stored for stored in contracts if stored <= year] + return validate_contract(contracts[max(earlier)] if earlier else ADDITIVE_CONTRACT) diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index b76f305761..fb1afcac2a 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -109,6 +109,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - corporate_wealth - directly_held_shares - dividend_income +- individual_savings_account_dividend_income - gross_financial_wealth - household_lifetime_isa_balance - lifetime_isa_balance @@ -134,6 +135,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_rental_income - savings - savings_interest_income +- non_cash_isa_cash_interest - spousal_maintenance_income - stocks_and_shares_isa - sublet_income diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index c9fd7d6e3f..5892c52c74 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -1,4 +1,6 @@ description: > + Ordinary cash ISA balances are separate from savings and are included at + their accessible cash value, without a generic cash sale-cost deduction. Model variables currently mapped to countable income-related Employment and Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. @@ -37,6 +39,7 @@ metadata: values: 2008-10-27: - savings + - cash_isa - owned_land - share_like_capital - other_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index ab257cc8ce..163e3aa1a9 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -1,4 +1,6 @@ description: > + Ordinary cash ISA balances are separate from savings and are included at + their accessible cash value, without a generic cash sale-cost deduction. Model variables currently mapped to countable income-based Jobseeker's Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current @@ -37,6 +39,7 @@ metadata: values: 1996-10-07: - savings + - cash_isa - owned_land - share_like_capital - other_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index 3bc1850dce..eddb604c4b 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -1,4 +1,6 @@ description: > + Ordinary cash ISA balances are separate from savings and are included at + their accessible cash value, without a generic cash sale-cost deduction. Model variables currently mapped to countable Housing Benefit capital. The Housing Benefit Regulations 2006 treat the whole of a claimant's capital as assessable unless it is disregarded under Schedule 6. The current mappings @@ -45,6 +47,7 @@ metadata: values: 2006-04-10: - savings + - cash_isa - owned_land - share_like_capital - other_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index f2e8658444..4db2982412 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -1,4 +1,6 @@ description: > + Ordinary cash ISA balances are separate from savings and are included at + their accessible cash value, without a generic cash sale-cost deduction. Capital sources included in the Income Support capital test. `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal @@ -26,6 +28,7 @@ metadata: values: 2010-01-01: - savings + - cash_isa - owned_land - share_like_capital - other_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index 93343e1586..60c43a3138 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -1,4 +1,6 @@ description: > + Ordinary cash ISA balances are separate from savings and are included at + their accessible cash value, without a generic cash sale-cost deduction. PolicyEngine approximation of Pension Credit capital sources that count toward deemed income from capital, excluding the claimant's main home. `private_pension_wealth` is excluded because Schedule V paragraphs 22 and 23 @@ -26,6 +28,7 @@ metadata: values: 2003-10-06: - savings + - cash_isa - owned_land - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index e94238bb32..9e3e5dbcbc 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -1,4 +1,6 @@ description: > + Ordinary cash ISA balances are separate from savings and are included at + their accessible cash value, without a generic cash sale-cost deduction. Household financial proxies for Universal Credit: savings and share-like financial wealth. `share_like_capital` is `corporate_wealth` as supplied, or the sum of its itemised components (`directly_held_shares`, @@ -45,4 +47,5 @@ metadata: values: 2013-04-29: - savings + - cash_isa - share_like_capital diff --git a/policyengine_uk/parameters/gov/hmrc/isa/cash_interest_charge/in_effect.yaml b/policyengine_uk/parameters/gov/hmrc/isa/cash_interest_charge/in_effect.yaml new file mode 100644 index 0000000000..eecca54c91 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/isa/cash_interest_charge/in_effect.yaml @@ -0,0 +1,11 @@ +description: Whether the final manager charge applies to cash interest in non-cash ISAs. +values: + 2000-01-01: false + 2027-04-06: true +metadata: + label: Non-cash ISA cash-interest charge in effect + period: year + unit: bool + reference: + - title: Individual Savings Account (Amendment) Regulations 2026 regulations 1 and 15 + href: https://www.legislation.gov.uk/uksi/2026/1018/made diff --git a/policyengine_uk/parameters/gov/hmrc/isa/lifetime/bonus_rate.yaml b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/bonus_rate.yaml new file mode 100644 index 0000000000..02d76c6d7f --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/bonus_rate.yaml @@ -0,0 +1,10 @@ +description: Government bonus as a share of qualifying Lifetime ISA payments. +values: + 2017-04-06: 0.25 +metadata: + label: Lifetime ISA bonus rate + period: year + unit: /1 + reference: + - title: Individual Savings Account Regulations 1998 Schedule paragraph 1 + href: https://www.legislation.gov.uk/uksi/1998/1870/schedule/paragraph/1 diff --git a/policyengine_uk/parameters/gov/hmrc/isa/lifetime/in_effect.yaml b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/in_effect.yaml new file mode 100644 index 0000000000..5fb8aedb84 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/in_effect.yaml @@ -0,0 +1,11 @@ +description: Whether the Lifetime ISA bonus and withdrawal charge operate. +values: + 2000-01-01: false + 2017-04-06: true +metadata: + label: Lifetime ISA in effect + period: year + unit: bool + reference: + - title: ISA (Amendment No. 2) Regulations 2017 regulation 1 + href: https://www.legislation.gov.uk/uksi/2017/466/regulation/1/made diff --git a/policyengine_uk/parameters/gov/hmrc/isa/lifetime/minimum_age.yaml b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/minimum_age.yaml new file mode 100644 index 0000000000..ba48561837 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/minimum_age.yaml @@ -0,0 +1,10 @@ +description: Minimum age for qualifying Lifetime ISA payments. +values: + 2017-04-06: 18 +metadata: + label: Lifetime ISA minimum payment age + period: year + unit: year + reference: + - title: Individual Savings Account Regulations 1998 regulation 10A + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/10A diff --git a/policyengine_uk/parameters/gov/hmrc/isa/lifetime/minimum_holding_period.yaml b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/minimum_holding_period.yaml new file mode 100644 index 0000000000..786e7b8258 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/minimum_holding_period.yaml @@ -0,0 +1,10 @@ +description: Minimum completed months since the first Lifetime ISA payment for an exempt first-home withdrawal, including qualifying transfer continuity. +values: + 2017-04-06: 12 +metadata: + label: Lifetime ISA minimum first-home holding period + period: year + unit: month + reference: + - title: Individual Savings Account Regulations 1998 Schedule paragraph 6 + href: https://www.legislation.gov.uk/uksi/1998/1870/schedule/paragraph/6 diff --git a/policyengine_uk/parameters/gov/hmrc/isa/lifetime/payment_age_limit.yaml b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/payment_age_limit.yaml new file mode 100644 index 0000000000..4248273b5c --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/payment_age_limit.yaml @@ -0,0 +1,10 @@ +description: Age from which Lifetime ISA payments no longer qualify for a bonus. +values: + 2017-04-06: 50 +metadata: + label: Lifetime ISA exclusive payment age limit + period: year + unit: year + reference: + - title: Individual Savings Account Regulations 1998 regulation 10A + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/10A diff --git a/policyengine_uk/parameters/gov/hmrc/isa/lifetime/payment_limit.yaml b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/payment_limit.yaml new file mode 100644 index 0000000000..9c93e7bc9c --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/payment_limit.yaml @@ -0,0 +1,10 @@ +description: Maximum qualifying Lifetime ISA payments in the tax year. +values: + 2017-04-06: 4000 +metadata: + label: Lifetime ISA annual qualifying payment limit + period: year + unit: currency-GBP + reference: + - title: Individual Savings Account Regulations 1998 regulation 4ZA(1A) + href: https://www.legislation.gov.uk/uksi/1998/1870/regulation/4ZA diff --git a/policyengine_uk/parameters/gov/hmrc/isa/lifetime/property_price_cap.yaml b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/property_price_cap.yaml new file mode 100644 index 0000000000..09a0b2e0f5 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/isa/lifetime/property_price_cap.yaml @@ -0,0 +1,10 @@ +description: Maximum whole-property price for an exempt first-home Lifetime ISA withdrawal. +values: + 2017-04-06: 450000 +metadata: + label: Lifetime ISA first-home property price cap + period: year + unit: currency-GBP + reference: + - title: Individual Savings Account Regulations 1998 Schedule paragraph 6 + href: https://www.legislation.gov.uk/uksi/1998/1870/schedule/paragraph/6 diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index b9bc473d4e..0ec056c528 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -5,6 +5,30 @@ programs: # --- HMRC programs --- + - id: lifetime_isa + name: Lifetime ISA + full_name: Lifetime Individual Savings Account bonus and withdrawal charge + category: Savings + agency: HMRC + status: partial + coverage: UK + variable: lifetime_isa_bonus + parameter_prefix: gov.hmrc.isa.lifetime + verified_start_year: 2017 + notes: Annual qualifying-payment bonus and gross-withdrawal charge, including individual first-home conditions. Assumes valid account opening and transaction qualification; does not update balances. Late-March 2020 withdrawals are outside the annual timing resolution. + + - id: isa_cash_interest_charge + name: Non-cash ISA cash-interest charge + full_name: Final charge on cash interest in stocks-and-shares and innovative finance ISAs + category: Taxes + agency: HMRC + status: partial + coverage: UK + variable: isa_cash_interest_flat_rate_charge + parameter_prefix: gov.hmrc.isa.cash_interest_charge + verified_start_year: 2027 + notes: Separate final manager charge at the savings basic rate from 6 April 2027. Requires the cash-interest component as an input; subscription-limit compliance is not modelled. + - id: income_tax name: Income tax full_name: Income Tax diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index f1a359bde9..ee69888fdf 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -16,6 +16,9 @@ from policyengine_core.parameters import Parameter from policyengine_core.reforms import Reform from policyengine_core.simulations import Simulation as CoreSimulation +from policyengine_core.simulations.simulation import ( + _uprating_index_value, +) from policyengine_core.tools.hugging_face import download_huggingface_dataset from policyengine_core.tracers import FullTracer, SimpleTracer @@ -45,6 +48,22 @@ hold_identifiers_in, with_identifiers_in_each_year, ) +from policyengine_uk.data.isa_income import ( + ISA_INCOME_CONTRACT_KEY, + MISSING_CONTRACT, + ISA_INTEREST, + SAVINGS_INTEREST, + TAX_FREE_INTEREST, + contract_envelope, + contracts_for_years, + has_isa_interest_detail_input, + latest_input, + normalized_dataset, + read_h5_contract, + read_object_contract, + resolve_contracts, + simulation_contract, +) from policyengine_uk.data.capital_gains_projection import ( CapitalGainsProjection, CGT_INPUTS, @@ -594,8 +613,79 @@ def clone(self, *args, **kwargs) -> "Simulation": clone._cgt_calculation_depth = 0 clone._cgt_operation_inputs = set() clone._cgt_defaulted_periods = set(getattr(self, "_cgt_defaulted_periods", ())) + clone._isa_income_contracts = copy.deepcopy( + getattr(self, "_isa_income_contracts", {}) + ) return clone + def _isa_latest_input(self, name, period, *, earlier=False): + return latest_input(self, name, period, earlier=earlier) + + def has_isa_interest_detail_input(self, period) -> bool: + """Whether this branch supplies an applicable ISA cash-interest detail. + + Computed caches are excluded, as in Core's carry-over input rule. A + future detail cannot change an earlier year's aggregate. + """ + return has_isa_interest_detail_input(self, period) + + def _isa_stored_exemption(self, period): + # Core can store a rolling year or a longer YEAR input as given. The + # latter carries its whole amount into a queried year; it is not a + # sum of calendar-year flows. Use the same first definition-year + # anchor when translating that stored amount in either direction. + return self.calculate(TAX_FREE_INTEREST, period_(period).start.period("year")) + + def _run_formula(self, variable, population, period): + result = super()._run_formula(variable, population, period) + if ( + result is not None + or variable.name != SAVINGS_INTEREST + or period.unit != "year" + or simulation_contract(self, period.start.year)["projection"] + != "legacy_gross" + ): + return result + + # This is fallback, after Core's explicit input/abolition checks and + # policy formulas. Preserve the old gross history even past the last + # materialized dataset year; new additive records use Core normally. + history = getattr(self, "_get_store_history", None) + if callable(history): + # Store-history tracking is available in newer Core releases; + # the declared minimum already caches formula results without it. + from policyengine_core.data_storage.store_history import ( + next_sequence_number, + ) + + history().record_derived(variable.name, next_sequence_number()) + source = self._isa_latest_input(variable.name, period, earlier=True) + if source is None: + # Core first uprates strictly earlier annual inputs, then carries + # an input starting on the query date if there was none earlier. + source = self._isa_latest_input(variable.name, period) + holder = self.get_holder(variable.name) + if source is None: + gross = holder.default_array() + else: + gross = holder.get_array(source, self.branch_name) + if ( + simulation_contract(self, source.start.year)["representation"] + == "additive" + ): + gross = gross + self._isa_stored_exemption(source) + if variable.uprating is not None: + index = self.tax_benefit_system.parameters.get_child(variable.uprating) + source_index = _uprating_index_value(index, source.start) + target_index = _uprating_index_value(index, period.start) + factor = ( + target_index / source_index + if source_index not in (None, 0) and target_index is not None + else 1 + ) + gross = gross * factor + return gross - self.calculate(TAX_FREE_INTEREST, period) + @staticmethod def _cgt_supplied_record() -> dict: return { @@ -973,6 +1063,9 @@ def build_from_file( self.build_from_dataset(dataset) return + # Convert a detached source once, then cache only the canonical object. + # Callers holding an old gross dataset retain their original values. + multi_year_dataset = normalized_dataset(multi_year_dataset) # Pre-encode string enum columns to int16 once before caching so # subsequent loads skip the expensive astype(str) + searchsorted path. _pre_encode_enum_columns(multi_year_dataset, self.tax_benefit_system) @@ -1053,8 +1146,43 @@ def build_from_dataframe(self, df: pd.DataFrame) -> None: for variable in dict.fromkeys(name for name, _, _ in replayed): self.delete_arrays(variable) for variable, time_period, values in replayed: + if variable == SAVINGS_INTEREST: + continue self.set_input(variable, time_period, values) + # Replay savings in the incoming entry's representation. Core decides + # which annual slots a month/span stores and what remains after prior + # entries. Never subtract an exemption from the requested month or + # mix a prior ordinary input into a gross residual calculation. + def translate_stored_savings(to_gross): + holder = self.get_holder(SAVINGS_INTEREST) + translated = [ + ( + stored, + np.array(holder.get_array(stored, self.branch_name)) + + (1 if to_gross else -1) * self._isa_stored_exemption(stored), + ) + for stored in holder.get_input_periods(self.branch_name) + ] + for stored, amount in translated: + self.set_input(SAVINGS_INTEREST, stored, amount) + + gross_mode = None + for variable, time_period, values in replayed: + if variable != SAVINGS_INTEREST: + continue + year = period_(time_period).start.year + contract = contracts_for_years( + df.attrs.get(ISA_INCOME_CONTRACT_KEY, MISSING_CONTRACT), [year] + )[year] + incoming_gross = contract["representation"] == "legacy_gross" + if gross_mode is not None and gross_mode != incoming_gross: + translate_stored_savings(to_gross=incoming_gross) + self.set_input(variable, time_period, values) + gross_mode = incoming_gross + if gross_mode: + translate_stored_savings(to_gross=False) + def to_input_dataframe( self, include_computed_variables: bool = False ) -> pd.DataFrame: @@ -1079,7 +1207,16 @@ def to_input_dataframe( if include_computed_variables else input_dataframe(self) ) - return with_identifiers_in_each_year(frame, self.tax_benefit_system) + frame = with_identifiers_in_each_year(frame, self.tax_benefit_system) + years = { + period_(str(column).split("__", 1)[1]).start.year + for column in frame.columns + if "__" in str(column) + } + frame.attrs[ISA_INCOME_CONTRACT_KEY] = contract_envelope( + {year: simulation_contract(self, year) for year in years} + ) + return frame def build_from_dataset(self, dataset: Dataset) -> None: """Build simulation from a Dataset object. @@ -1112,6 +1249,12 @@ def get_first_array(variable_name: str) -> np.ndarray: ) ) + self._isa_income_contracts = resolve_contracts( + read_object_contract(dataset), + read_h5_contract(getattr(dataset, "file_path", None)), + [period_(first_time_period).start.year], + ) + # Populations use sorted group IDs; arrays in a Core dataset follow # the order of that period's supplied identifier array instead. group_orders = { @@ -1180,6 +1323,10 @@ def build_from_multi_year_dataset(self, dataset: UKMultiYearDataset) -> None: setattr(dataset[year], entity, table.iloc[order]) reset_growthfactor_uprating(dataset) + # The legacy gross projection/reset must finish before conversion. + # Work on detached tables: a cached or caller-owned source is reusable. + dataset = normalized_dataset(dataset) + # Ensure enum columns are encoded and _enum_columns is populated so # that .person/.benunit/.household properties can decode back to strings. if not any(dataset[y]._enum_columns for y in dataset.years): @@ -1193,6 +1340,10 @@ def build_from_multi_year_dataset(self, dataset: UKMultiYearDataset) -> None: first_year.benunit.benunit_id, first_year.household.household_id, ) + self._isa_income_contracts = { + year: copy.deepcopy(dataset[year].isa_income_contract) + for year in dataset.years + } # Load variable values for all years for year in dataset.years: @@ -1240,6 +1391,7 @@ def _start_new_population(self, built_from_dataset: bool) -> None: self._cgt_defaulted_periods = set() self._cgt_operation_inputs = set() self._cgt_warned_unused = False + self._isa_income_contracts = {} def build_from_ids( self, diff --git a/policyengine_uk/tests/code_health/test_household_income_lists.py b/policyengine_uk/tests/code_health/test_household_income_lists.py index 6a419eb8cc..1c1302eb1b 100644 --- a/policyengine_uk/tests/code_health/test_household_income_lists.py +++ b/policyengine_uk/tests/code_health/test_household_income_lists.py @@ -1,4 +1,4 @@ -"""Government spending on households is household income. +"""Current government transfers to households reach household income. gov_spending counted the Pension Age Winter Heating Payment (pawhp), but household_benefits and the HBAI income lists did not: when the Winter Fuel @@ -18,8 +18,12 @@ SYSTEM = CountryTaxBenefitSystem() -# Spending in gov_spending that is not a benefit received by the household. +# Spending in gov_spending that is not current household benefit income. NOT_HOUSEHOLD_BENEFITS = { + "lifetime_isa_bonus": ( + "a capital transfer into a Lifetime ISA, outside household current " + "income; supplied account balances remain separate stocks" + ), "other_public_spending_budget_change": ( "the contrib lever gov.contrib.policyengine.budget.other_public_spending, " "spread across households by income decile; zero unless set" diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py index 0317076069..890b140ba4 100644 --- a/policyengine_uk/tests/code_health/test_uprating_declarations.py +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -50,6 +50,10 @@ # Declares ons.household_interest_income; the YAML applies per_capita.gdp. # Flagged in #1862 for someone with the context to say which is intended. "savings_interest_income", + # Like ordinary savings interest, noncash ISA interest declares the ONS + # household index but follows the existing GDP dataset rule. Broader + # alignment of those two surfaces remains separate work in #2638. + "non_cash_isa_cash_interest", } diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml new file mode 100644 index 0000000000..4e73d9a830 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/cash_isa_capital.yaml @@ -0,0 +1,101 @@ +# Ordinary cash ISAs have no wrapper-specific capital disregard. +# Source oracle: issue-2584-evidence/source-brief.md section2; UC reg72. +# Reported capital remains a total, including explicit zero, not an add-on. + +- name: Ordinary savings and cash ISA balances enter working-age capital pools once + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + would_claim_uc: false + savings: 5_000 + cash_isa: 3_000 + output: + uc_assessable_capital: 8_000 + housing_benefit_assessable_capital: 8_000 + income_support_assessable_capital: 8_000 + jsa_income_assessable_capital: 8_000 + esa_income_assessable_capital: 8_000 + +- name: Pension Credit counts ordinary cash ISA capital with ordinary savings + period: 2026 + input: + age: 70 + savings: 5_000 + cash_isa: 3_000 + output: + pension_credit_assessable_capital: 8_000 + +- name: Cash ISA capital reaches the actual Universal Credit award through tariff income + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + savings: 5_000 + cash_isa: 3_000 + output: + uc_assessable_capital: 8_000 + # Eight GBP250 steps above6000, times GBP4.35/month and12 months. + uc_tariff_income: 417.60 + uc_unearned_income: 417.60 + universal_credit: 4_681.20 + +- name: An observed zero financial report replaces cash ISA and personal LISA but preserves property + period: 2026 + input: + age: 30 + savings: 5_000 + cash_isa: 3_000 + lifetime_isa_balance: 4_000 + other_residential_property_value: 10_000 + benunit_reported_capital: 0 + output: + uc_assessable_capital: 9_000 + +- name: An observed zero complete UC override replaces cash ISA and property and LISA + period: 2026 + input: + age: 30 + savings: 5_000 + cash_isa: 3_000 + lifetime_isa_balance: 4_000 + other_residential_property_value: 10_000 + uc_reported_capital: 0 + output: + uc_assessable_capital: 0 + +- name: A reported financial total consumes the ISA pool once and leaves the other unit its residual + period: 2026 + input: + people: + reported_person: + age: 30 + is_benunit_head: true + lifetime_isa_balance: 4_000 + unreported_person: + age: 35 + is_benunit_head: true + benunits: + reported: + members: [reported_person] + benunit_reported_capital: 3_000 + unreported: + members: [unreported_person] + households: + household: + members: [reported_person, unreported_person] + savings: 5_000 + cash_isa: 3_000 + output: + uc_assessable_capital: [3_000, 5_000] + +- name: Pension Credit reports replace cash ISA and personal LISA including observed zero + period: 2026 + input: + age: [70, 70] + savings: [5_000, 5_000] + cash_isa: [3_000, 3_000] + lifetime_isa_balance: [4_000, 4_000] + pension_credit_reported_capital: [0, 5_000] + output: + pension_credit_assessable_capital: [0, 5_000] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/savings_income/taxable_savings_interest_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/savings_income/taxable_savings_interest_income.yaml index 5bdf56a235..4d4ec4b9ae 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/savings_income/taxable_savings_interest_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/savings_income/taxable_savings_interest_income.yaml @@ -1,3 +1,7 @@ +# Canonicalized legacy examples: new non-ISA interest N=old gross G−T. +# T is an exempt-interest aggregate, not the personal savings allowance. +# Taxable outputs are unchanged from the gross-input examples. + - name: No savings interest income period: 2024 input: @@ -6,31 +10,31 @@ output: taxable_savings_interest_income: 0 -- name: Savings interest income fully covered by tax-free allowance +- name: Canonicalized entirely exempt legacy interest has no non-ISA amount period: 2024 input: - savings_interest_income: 1000 + savings_interest_income: 0 tax_free_savings_income: 1000 output: taxable_savings_interest_income: 0 -- name: Savings interest income partially taxable +- name: Canonicalized legacy1500 gross and1000 exempt leaves500 taxable period: 2024 input: - savings_interest_income: 1500 + savings_interest_income: 500 tax_free_savings_income: 1000 output: taxable_savings_interest_income: 500 -- name: High savings interest income, standard tax-free allowance +- name: Canonicalized legacy10000 gross and1000 exempt leaves9000 taxable period: 2024 input: - savings_interest_income: 10000 + savings_interest_income: 9000 tax_free_savings_income: 1000 output: taxable_savings_interest_income: 9000 -- name: Savings interest income, no tax-free allowance +- name: Ordinary savings interest with no exempt-interest aggregate period: 2024 input: savings_interest_income: 5000 @@ -38,7 +42,7 @@ output: taxable_savings_interest_income: 5000 -- name: Very low positive savings interest income +- name: Very low positive ordinary savings interest period: 2024 input: savings_interest_income: 1 @@ -46,26 +50,27 @@ output: taxable_savings_interest_income: 1 -- name: Tax-free allowance exceeds savings interest income +- name: Signed canonicalized legacy interest is floored only at the taxable base period: 2024 input: - savings_interest_income: 500 + savings_interest_income: -500 tax_free_savings_income: 1000 output: taxable_savings_interest_income: 0 -- name: High values for both inputs +- name: Canonicalized legacy100000 gross and50000 exempt leaves50000 taxable period: 2024 input: - savings_interest_income: 100000 + savings_interest_income: 50000 tax_free_savings_income: 50000 output: taxable_savings_interest_income: 50000 -- name: Decimal values +- name: Canonicalized legacy decimal amounts preserve the taxable amount period: 2024 + absolute_error_margin: 0.01 input: - savings_interest_income: 1234.56 + savings_interest_income: 234.56 tax_free_savings_income: 1000.00 output: - taxable_savings_interest_income: 234.56 \ No newline at end of file + taxable_savings_interest_income: 234.56 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 27ed75a6e2..e1a3cfe812 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -654,7 +654,9 @@ pensioner: age: 70 private_pension_income: 34_000 - savings_interest_income: 2_000 + savings_interest_income: 500 + individual_savings_account_interest_income: 1_500 + # An explicit exemption equal to total ISA interest is consistent. tax_free_savings_income: 1_500 benunits: unit: @@ -665,6 +667,10 @@ members: [pensioner] country: ENGLAND output: + taxable_savings_interest_income: [500] + tax_free_savings_income: [1_500] + capital_income: [2_000] + market_income: [36_000] total_income_charged_to_tax: [34_500] winter_fuel_payment: [200] winter_fuel_payment_charge: [0] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml new file mode 100644 index 0000000000..f7448862b2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/isa_income.yaml @@ -0,0 +1,93 @@ +# ISA Regulations1998 reg22; additive current household-input convention. +# SI2026/1018 regs1/15 and FinanceAct2026 s5 for the separate final charge. + +- name: Additive ISA income is market income without cancelling unrelated taxable interest + period: 2026 + input: + age: 35 + employment_income: 30_000 + savings_interest_income: 2_000 + individual_savings_account_interest_income: 500 + individual_savings_account_dividend_income: 300 + output: + taxable_savings_interest_income: 2_000 + taxable_dividend_income: 0 + capital_income: 2_800 + market_income: 32_800 + household_market_income: 32_800 + +- name: An explicit exempt-interest aggregate remains authoritative even when it differs from ISA interest + period: 2026 + input: + age: 35 + employment_income: 30_000 + savings_interest_income: 2_000 + individual_savings_account_interest_income: 500 + tax_free_savings_income: 700 + output: + taxable_savings_interest_income: 2_000 + capital_income: 2_700 + market_income: 32_700 + +- name: An explicit zero exempt-interest aggregate is not replaced by an ISA default + period: 2026 + input: + age: 35 + savings_interest_income: 2_000 + individual_savings_account_interest_income: 500 + tax_free_savings_income: 0 + output: + taxable_savings_interest_income: 2_000 + capital_income: 2_000 + +- name: Noncash ISA cash-interest detail defaults the ISA total but has no charge before fiscal2027 + period: 2026 + input: + age: 35 + non_cash_isa_cash_interest: 100 + output: + individual_savings_account_interest_income: 100 + taxable_savings_interest_income: 0 + capital_income: 100 + market_income: 100 + isa_cash_interest_flat_rate_charge: 0 + +- name: The2027 manager charge applies despite unused income-tax allowances + period: 2027 + input: + age: 35 + non_cash_isa_cash_interest: 100 + output: + individual_savings_account_interest_income: 100 + taxable_savings_interest_income: 0 + income_tax: 0 + capital_income: 100 + market_income: 100 + isa_cash_interest_flat_rate_charge: 22 + +- name: Noncash cash interest is an of-which detail of an explicitly supplied ISA total + period: 2027 + input: + age: 35 + individual_savings_account_interest_income: 500 + non_cash_isa_cash_interest: 100 + output: + taxable_savings_interest_income: 0 + capital_income: 500 + market_income: 500 + isa_cash_interest_flat_rate_charge: 22 + + +- name: Tax Credits and total taxable income exclude ISA returns without losing ordinary interest or dividends + period: 2025 + input: + age: 35 + employment_income: 30_000 + savings_interest_income: 2_000 + dividend_income: 1_000 + individual_savings_account_interest_income: 500 + individual_savings_account_dividend_income: 300 + output: + total_income: 33_000 + # Step1 ordinary investment income3000, less the300 disregard, plus wages. + tax_credits_current_year_income: 32_700 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/lifetime_isa_bonus.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/lifetime_isa_bonus.yaml new file mode 100644 index 0000000000..d0b53dc11c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/lifetime_isa_bonus.yaml @@ -0,0 +1,35 @@ +# ISA Regulations1998 Schedule para1, regs4ZA/10A; SI2017/466. +# These are qualifying current-year additions, not existing balances, bonuses +# or LISA-to-LISA transfers. Ages40-49 assume first funding before age40. + +- name: Qualifying LISA additions earn 25 percent up to the annual payment limit + period: 2026 + input: + age: [30, 30, 30, 30, 30] + lifetime_isa_payments: [-100, 0, 2_400, 4_000, 5_000] + output: + lifetime_isa_bonus: [0, 0, 600, 1_000, 1_000] + +- name: Payments qualify from age18 until just before age50 + period: 2026 + input: + age: [17, 18, 49, 50] + lifetime_isa_payments: [4_000, 4_000, 4_000, 4_000] + output: + lifetime_isa_bonus: [0, 1_000, 1_000, 0] + +- name: No LISA bonus exists in fiscal2016-17 + period: 2016 + input: + age: 30 + lifetime_isa_payments: 4_000 + output: + lifetime_isa_bonus: 0 + +- name: LISA bonus begins in fiscal2017-18 + period: 2017 + input: + age: 30 + lifetime_isa_payments: 4_000 + output: + lifetime_isa_bonus: 1_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/lifetime_isa_withdrawal_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/lifetime_isa_withdrawal_charge.yaml new file mode 100644 index 0000000000..284ace1c85 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/isa/lifetime_isa_withdrawal_charge.yaml @@ -0,0 +1,94 @@ +# ISA Regulations1998 Schedule paras4-7 and5A. +# Withdrawals are GROSS, excluding exempt inter-LISA transfers and death. +# Annual2020/2021 use the existing30April fiscal-year parameter convention. + +- name: Ordinary withdrawal charge is on the gross withdrawal with age and medical exemptions + period: 2026 + input: + age: [35, 59, 60, 35] + lifetime_isa_terminally_ill: [false, false, false, true] + lifetime_isa_withdrawals: [2_000, 2_000, 2_000, 2_000] + output: + lifetime_isa_withdrawal_charge: [500, 500, 0, 0] + +- name: Fiscal2020-21 samples the temporary20 percent charge + period: 2020 + input: + age: 35 + lifetime_isa_withdrawals: 2_000 + output: + lifetime_isa_withdrawal_charge: 400 + +- name: Fiscal2021-22 restores the25 percent charge + period: 2021 + input: + age: 35 + lifetime_isa_withdrawals: 2_000 + output: + lifetime_isa_withdrawal_charge: 500 + +- name: No LISA withdrawal charge exists before the scheme starts + period: 2016 + input: + age: 35 + lifetime_isa_withdrawals: 2_000 + output: + lifetime_isa_withdrawal_charge: 0 + +- name: First-home exemption requires the investor and each qualifying-purchase condition + period: 2026 + input: + age: [35, 35, 35, 35, 35, 35, 35, 35, 35] + lifetime_isa_withdrawals: [10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000] + # A zero home component means none was paid directly to a conveyancer. + lifetime_isa_withdrawals_towards_first_home: [10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 0, 10_000] + lifetime_isa_first_time_buyer: [true, true, true, true, true, true, false, true, true] + lifetime_isa_months_since_first_payment: [12, 12, 12, 12, 12, 11, 12, 12, 12] + main_residential_property_purchased: [400_000, 450_000, 450_001, 400_000, 400_000, 400_000, 400_000, 400_000, 0] + main_residential_property_purchased_with_mortgage: [true, true, true, false, true, true, true, true, true] + main_residential_property_purchased_is_in_uk: [true, true, true, true, false, true, true, true, true] + output: + #450001 is representably above the450000 cap in float32 Money storage. + lifetime_isa_withdrawal_charge: [0, 0, 2_500, 2_500, 2_500, 2_500, 2_500, 2_500, 2_500] + +- name: A first-time investor may qualify when their joint buyer previously owned a home + period: 2026 + input: + people: + first_time_investor: + age: 35 + lifetime_isa_withdrawals: 10_000 + lifetime_isa_withdrawals_towards_first_home: 10_000 + lifetime_isa_first_time_buyer: true + lifetime_isa_months_since_first_payment: 12 + previous_owner: + age: 36 + lifetime_isa_withdrawals: 10_000 + lifetime_isa_withdrawals_towards_first_home: 10_000 + lifetime_isa_first_time_buyer: false + lifetime_isa_months_since_first_payment: 12 + benunits: + couple: + members: [first_time_investor, previous_owner] + households: + household: + members: [first_time_investor, previous_owner] + main_residential_property_purchased: 400_000 + main_residential_property_purchased_with_mortgage: true + main_residential_property_purchased_is_in_uk: true + output: + lifetime_isa_withdrawal_charge: [0, 2_500] + +- name: Only the bounded qualifying home portion avoids the withdrawal charge + period: 2026 + input: + age: [35, 35, 35, 35, 35] + lifetime_isa_withdrawals: [12_000, 10_000, 12_000, -100, 2_000] + lifetime_isa_withdrawals_towards_first_home: [10_000, 20_000, 12_000, 100, -100] + lifetime_isa_first_time_buyer: [true, true, true, true, true] + lifetime_isa_months_since_first_payment: [12, 12, 12, 12, 12] + main_residential_property_purchased: [400_000, 400_000, 8_000, 400_000, 400_000] + main_residential_property_purchased_with_mortgage: [true, true, true, true, true] + main_residential_property_purchased_is_in_uk: [true, true, true, true, true] + output: + lifetime_isa_withdrawal_charge: [500, 0, 1_000, 0, 500] diff --git a/policyengine_uk/tests/test_income_concepts_properties.py b/policyengine_uk/tests/test_income_concepts_properties.py index 8f07a3bef2..9e682eaf7d 100644 --- a/policyengine_uk/tests/test_income_concepts_properties.py +++ b/policyengine_uk/tests/test_income_concepts_properties.py @@ -200,6 +200,36 @@ def test_statutory_pay_is_counted_once_in_each_programme_concept(template, peopl @PROPERTY_SETTINGS @given(people=st.lists(taxpayer, min_size=1, max_size=12)) +# A separate ISA receipt cannot absorb taxable other investment income. +@example( + people=[ + { + "age": 18, + "employment_income": 0, + "employment_expenses": 0, + "statutory_sick_pay": 0, + "statutory_maternity_pay": 0, + "statutory_paternity_pay": 0, + "private_pension_income": 0, + "social_security_income": 0, + "self_employment_income": 0, + "capital_allowances": 0, + "trading_allowance_deduction": 0, + "loss_relief": 0, + "trading_loss": 0, + "property_income": 0, + "property_allowance_deduction": 0, + "savings_interest_income": 0, + "other_investment_income": 1, + "individual_savings_account_interest_income": 1, + "dividend_income": 0, + "miscellaneous_income": 0, + "employee_pension_contributions": 0, + "personal_pension_contributions": 0, + "gift_aid": 0, + } + ] +) @example( people=[ { @@ -248,9 +278,7 @@ def get(name): + max(0, get("property_income") - get("property_allowance_deduction")) + max( 0, - get("savings_interest_income") - + get("other_investment_income") - - get("individual_savings_account_interest_income"), + get("savings_interest_income") + get("other_investment_income"), ) + get("dividend_income") + max(0, get("miscellaneous_income")) diff --git a/policyengine_uk/tests/test_isa_accounting.py b/policyengine_uk/tests/test_isa_accounting.py new file mode 100644 index 0000000000..7f733371f9 --- /dev/null +++ b/policyengine_uk/tests/test_isa_accounting.py @@ -0,0 +1,228 @@ +"""LISA capital transfers reach fiscal totals without becoming current income.""" + +from copy import deepcopy + +import pytest + +from policyengine_uk import Simulation + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def test_lisa_bonus_and_charge_reach_only_their_declared_fiscal_totals(): + """Source amounts: 2400*25%=600 bonus; gross2000*25%=500 charge. + + Routing capital transfers outside household current income is the explicit + accounting contract for2584, not an additional statutory income definition. + Supplied balances are separate stocks and must not be updated by the flows. + """ + situation = { + "people": { + "person": { + "age": {2026: 35}, + "employment_income": {2026: 30_000}, + "lifetime_isa_balance": {2026: 10_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + "country": {2026: "ENGLAND"}, + "savings": {2026: 1_000}, + } + }, + } + with_flows = deepcopy(situation) + with_flows["people"]["person"].update( + lifetime_isa_payments={2026: 2_400}, + lifetime_isa_withdrawals={2026: 2_000}, + ) + baseline = Simulation(situation=situation) + changed = Simulation(situation=with_flows) + + assert changed.calculate("lifetime_isa_bonus", 2026).tolist() == [600] + assert changed.calculate("lifetime_isa_withdrawal_charge", 2026).tolist() == [500] + for variable, expected in (("gov_spending", 600), ("gov_tax", 500)): + difference = changed.calculate(variable, 2026) - baseline.calculate( + variable, 2026 + ) + assert difference.tolist() == pytest.approx([expected], abs=0.01), variable + for variable in ( + "household_market_income", + "household_benefits", + "household_tax", + "household_net_income", + "hbai_household_net_income", + "lifetime_isa_balance", + "lifetime_isa_countable_capital", + "uc_assessable_capital", + ): + assert changed.calculate(variable, 2026).tolist() == pytest.approx( + baseline.calculate(variable, 2026).tolist(), abs=0.01 + ), variable + + +def test_noncash_isa_interest_charge_reaches_tax_and_final_current_income_once(): + """From fiscal2027,100 of ISA interest gives22 tax and78 net income.""" + situation = { + "people": { + "person": { + "age": {2027: 35}, + "employment_income": {2027: 30_000}, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": {"members": ["person"], "country": {2027: "ENGLAND"}} + }, + } + with_return = deepcopy(situation) + with_return["people"]["person"]["non_cash_isa_cash_interest"] = {2027: 100} + baseline = Simulation(situation=situation) + changed = Simulation(situation=with_return) + assert changed.calculate("isa_cash_interest_flat_rate_charge", 2027).tolist() == [ + 22 + ] + for variable, expected in ( + ("market_income", 100), + ("capital_income", 100), + ("household_market_income", 100), + ("gov_tax", 22), + ("household_tax", 22), + ("household_net_income", 78), + ("hbai_household_net_income", 78), + ("income_tax", 0), + ): + difference = changed.calculate(variable, 2027) - baseline.calculate( + variable, 2027 + ) + assert difference.tolist() == pytest.approx([expected], abs=0.01), variable + + +GDP_INDEX = "gov.economic_assumptions.indices.obr.per_capita.gdp" +INTEREST_INDEX = "gov.economic_assumptions.indices.ons.household_interest_income" +ISA_INCOME_PAIRS = [ + ("individual_savings_account_dividend_income", "dividend_income", [100, 125, 150]), + ("non_cash_isa_cash_interest", "savings_interest_income", [100, 150, 200]), +] + + +def income_household(observations): + return { + "people": { + "person": { + "age": {2026: 35, 2027: 35, 2028: 35}, + **observations, + } + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + + +def controlled_income_growth(): + from policyengine_uk.model_api import Scenario + + return Scenario( + parameter_changes={ + GDP_INDEX: {"2026": 100, "2027": 125, "2028": 150}, + INTEREST_INDEX: {"2026": 100, "2027": 150, "2028": 200}, + }, + applied_before_data_load=True, + ) + + +@pytest.mark.parametrize("isa, ordinary, levels", ISA_INCOME_PAIRS) +@pytest.mark.parametrize("observations", ["base", "later", "zero", "absent"]) +def test_new_isa_income_uses_the_ordinary_counterpart_index( + isa, ordinary, levels, observations +): + if observations == "absent": + inputs = {} + else: + amounts = {2026: 0 if observations == "zero" else 100} + if observations == "later": + amounts[2027] = 300 + inputs = {isa: deepcopy(amounts), ordinary: deepcopy(amounts)} + simulation = Simulation( + situation=income_household(inputs), scenario=controlled_income_growth() + ) + for year in (2028, 2027, 2026): + if observations in ("zero", "absent"): + expected = 0 + elif observations == "later" and year >= 2027: + expected = 300 * levels[year - 2026] / levels[1] + else: + expected = 100 * levels[year - 2026] / levels[0] + actual = simulation.calculate(isa, year).tolist() + assert actual == pytest.approx([expected], abs=0.01) + assert actual == pytest.approx( + simulation.calculate(ordinary, year).tolist(), abs=0.01 + ) + if observations in ("base", "later"): + assert simulation.calculate(isa, 2028)[0] > 100 + assert simulation.calculate(isa, 2026).tolist() == [100] + if observations == "later": + assert simulation.calculate(isa, 2027).tolist() == [300] + + +def test_uprated_isa_components_reach_income_once(): + inputs = { + name: {2026: 100} + for name in ( + "dividend_income", + "individual_savings_account_dividend_income", + "savings_interest_income", + "non_cash_isa_cash_interest", + ) + } + simulation = Simulation( + situation=income_household(inputs), scenario=controlled_income_growth() + ) + for variable in ("market_income", "capital_income"): + assert simulation.calculate(variable, 2027).tolist() == pytest.approx( + [550], abs=0.01 + ) + assert simulation.calculate(variable, 2026).tolist() == pytest.approx( + [400], abs=0.01 + ) + for variable in ( + "non_cash_isa_cash_interest", + "individual_savings_account_interest_income", + "tax_free_savings_income", + "taxable_savings_interest_income", + ): + assert simulation.calculate(variable, 2027).tolist() == pytest.approx( + [150], abs=0.01 + ) + assert simulation.calculate("dividend_income", 2027).tolist() == pytest.approx( + [125], abs=0.01 + ) + + +def test_lisa_medical_input_is_specific_to_withdrawal_charge(): + people = {} + for number, (age, flag) in enumerate( + ((35, None), (35, False), (35, True), (60, False)) + ): + person = {"age": {2026: age}, "lifetime_isa_withdrawals": {2026: 2_000}} + if flag is not None: + person["lifetime_isa_terminally_ill"] = {2026: flag} + people[f"person{number}"] = person + simulation = Simulation( + situation={ + "people": people, + "benunits": {name: {"members": [name]} for name in people}, + "households": {name: {"members": [name]} for name in people}, + } + ) + assert simulation.calculate("lifetime_isa_withdrawal_charge", 2026).tolist() == [ + 500, + 500, + 0, + 0, + ] + assert "lifetime_isa_terminally_ill" in simulation.tax_benefit_system.variables + assert "is_terminally_ill" not in simulation.tax_benefit_system.variables diff --git a/policyengine_uk/tests/test_isa_income_consumers.py b/policyengine_uk/tests/test_isa_income_consumers.py new file mode 100644 index 0000000000..c3386a54bb --- /dev/null +++ b/policyengine_uk/tests/test_isa_income_consumers.py @@ -0,0 +1,133 @@ +"""ISA income keeps consumer-specific gross and taxable income definitions.""" + +import pandas as pd +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.data.dataset_schema import UKMultiYearDataset, UKSingleYearDataset + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def test_legacy_and_additive_isa_income_reach_the_same_local_ctr_gross_band(): + """Westminster2026:14800/52 exceeds279, so10.60*52=551.20. + + Employment5200 + legacy gross interest9000 + new ISA dividends600. + The explicit exemption1000 differs from ISA interest500. Normalization + changes9000 to8000, but cannot remove1000 from the gross CTR band. + """ + dataset = UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1, 2], + "person_benunit_id": [1, 2], + "person_household_id": [1, 1], + "age": [35, 25], + "is_household_head": [True, False], + "employment_income": [0, 5_200], + "weekly_hours": [0, 16], + "savings_interest_income": [0, 9_000], + "individual_savings_account_interest_income": [0, 500], + "tax_free_savings_income": [0, 1_000], + "individual_savings_account_dividend_income": [0, 600], + } + ), + benunit=pd.DataFrame({"benunit_id": [1, 2]}), + household=pd.DataFrame( + { + "household_id": [1], + "household_weight": [1.0], + "country": ["ENGLAND"], + "region": ["LONDON"], + "local_authority": ["WESTMINSTER"], + "council_tax": [1_800], + } + ), + fiscal_year=2026, + ) + situation = { + "people": { + "claimant": {"age": {2026: 35}, "is_household_head": {2026: True}}, + "non_dep": { + "age": {2026: 25}, + "is_household_head": {2026: False}, + "employment_income": {2026: 5_200}, + "weekly_hours": {2026: 16}, + "savings_interest_income": {2026: 8_000}, + "individual_savings_account_interest_income": {2026: 500}, + "tax_free_savings_income": {2026: 1_000}, + "individual_savings_account_dividend_income": {2026: 600}, + }, + }, + "benunits": { + "claimant_benunit": {"members": ["claimant"]}, + "non_dep_benunit": {"members": ["non_dep"]}, + }, + "households": { + "household": { + "members": ["claimant", "non_dep"], + "country": {2026: "ENGLAND"}, + "local_authority": {2026: "WESTMINSTER"}, + "council_tax": {2026: 1_800}, + } + }, + } + legacy = Simulation(dataset=UKMultiYearDataset(datasets=[dataset])) + additive = Simulation(situation=situation) + for simulation in (legacy, additive): + assert simulation.calculate( + "council_tax_reduction_individual_non_dep_deduction_eligible", 2026 + ).tolist() == [False, True] + assert simulation.calculate( + "westminster_council_tax_reduction_individual_non_dep_deduction", 2026 + ).tolist() == pytest.approx([0, 551.20], abs=0.01) + + +@pytest.mark.parametrize("benefit", ["housing_benefit", "esa_income"]) +def test_cash_isa_reaches_legacy_awards_like_ordinary_savings(benefit): + """Cash ISAs count like equal ordinary cash, including the final award. + + These are ongoing public synthetic claims in 2025. ESA is a reported + award passed through the existing bounded screen, not full entitlement. + GBP8000 gives eight GBP250 tariff steps above6000, or GBP416 a year. + """ + + def build(savings, cash_isa): + person = {"age": {2025: 40}} + household = { + "members": ["person"], + "savings": {2025: savings}, + "cash_isa": {2025: cash_isa}, + } + if benefit == "housing_benefit": + person.update( + employment_income={2025: 10_000}, housing_benefit_reported={2025: 1_000} + ) + household.update( + tenure_type={2025: "RENT_FROM_COUNCIL"}, rent={2025: 6_000} + ) + else: + person.update(esa_income_reported={2025: 1_000}, weekly_hours={2025: 0}) + return Simulation( + situation={ + "people": {"person": person}, + "benunits": { + "benunit": {"members": ["person"], "would_claim_uc": {2025: False}} + }, + "households": {"household": household}, + } + ) + + lower = build(5_000, 0) + wrapped = build(5_000, 3_000) + ordinary = build(8_000, 0) + awards = [ + float(sim.calculate(benefit, 2025)[0]) for sim in (lower, wrapped, ordinary) + ] + assert all(award > 0 for award in awards) + assert awards[1] == pytest.approx(awards[2], abs=0.01) + assert awards[1] < awards[0] + for sim in (wrapped, ordinary): + assert sim.calculate(f"{benefit}_assessable_capital", 2025).tolist() == [8_000] + assert sim.calculate(f"{benefit}_tariff_income", 2025).tolist() == [416] diff --git a/policyengine_uk/tests/test_isa_income_contract.py b/policyengine_uk/tests/test_isa_income_contract.py new file mode 100644 index 0000000000..81f5b62353 --- /dev/null +++ b/policyengine_uk/tests/test_isa_income_contract.py @@ -0,0 +1,1001 @@ +"""Synthetic UK data preserve legacy ISA cash and exemption accounting. + +The public v1 adapter contract is specified independently of its implementation +in issue-2584-evidence/data-contract/interface.json. No private data is used. +""" + +from copy import deepcopy +import json + +import h5py +import numpy as np +import pandas as pd +import pytest +from policyengine_core.data import Dataset +from policyengine_core.simulations import SimulationBuilder + +import policyengine_uk.simulation as simulation_module +from policyengine_uk import Simulation +from policyengine_uk.data.dataset_schema import UKMultiYearDataset, UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import extend_single_year_dataset +from policyengine_uk.data.filter_dataset import filter_dataset +from policyengine_uk.scenarios import no_economic_assumptions +from policyengine_uk.system import system +from policyengine_uk.utils.scenario import Scenario + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +KEY = "policyengine_uk.isa_income_contract" +LEGACY = {"version": 1, "representation": "legacy_gross", "projection": "legacy_gross"} +NORMALIZED = {"version": 1, "representation": "additive", "projection": "legacy_gross"} +ADDITIVE = {"version": 1, "representation": "additive", "projection": "additive"} +GDP_GROWTH = "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" +MACRO = Scenario( + parameter_changes={ + GDP_GROWTH: {"year:2026-01-01:1": 0.1, "year:2027-01-01:1": 0.2} + }, + applied_before_data_load=True, +) + + +def single(year=2025, contract=None, **income): + person = pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [35], + "employment_income": [30_000.0], + **{name: [value] for name, value in income.items()}, + } + ) + kwargs = {} if contract is None else {"isa_income_contract": deepcopy(contract)} + return UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "region": ["LONDON"], + "tenure_type": ["RENT_PRIVATELY"], + "council_tax": [1_500.0], + "rent": [12_000.0], + "household_weight": [1.0], + } + ), + fiscal_year=year, + **kwargs, + ) + + +def input_frame(dataset): + year = dataset.time_period + return pd.DataFrame( + { + f"{name}__{year}": table[name].values + for table in dataset.tables + for name in table.columns + } + ) + + +def core_file(dataset, path, marker=None): + with h5py.File(path, "w") as output: + for name, values in dataset.load().items(): + values = np.asarray(values) + possible_values = getattr(system.variables[name], "possible_values", None) + if possible_values is not None: + values = np.asarray(possible_values.encode(values)) + output.create_dataset(f"{name}/{dataset.time_period}", data=values) + if marker is not None: + output.attrs[KEY] = json.dumps(marker) + return Dataset.from_file(str(path)) + + +def value(simulation, name, year=2025): + return float(simulation.calculate(name, year)[0]) + + +@pytest.mark.parametrize( + "income, expected_n, expected_t, expected_taxable", + [ + ( + { + "savings_interest_income": 3_000, + "individual_savings_account_interest_income": 500, + }, + 2_500, + 500, + 2_500, + ), + ( + { + "savings_interest_income": 3_000, + "individual_savings_account_interest_income": 500, + "tax_free_savings_income": 0, + }, + 3_000, + 0, + 3_000, + ), + ( + { + "savings_interest_income": 100, + "individual_savings_account_interest_income": 500, + "tax_free_savings_income": 300, + "other_investment_income": 500, + }, + -200, + 300, + 300, + ), + ( + {"savings_interest_income": 3_000, "tax_free_savings_income": 700}, + 2_300, + 700, + 2_300, + ), + ], + ids=["default-from-I", "explicit-zero-T", "signed-and-T-differs-from-I", "T-only"], +) +def test_unmarked_legacy_conversion_preserves_cash_and_supplied_exemptions( + income, expected_n, expected_t, expected_taxable +): + dataset = single(**income) + original = dataset.person.copy(deep=True) + simulation = Simulation(dataset=dataset) + + assert value(simulation, "savings_interest_income") == expected_n + assert value(simulation, "tax_free_savings_income") == expected_t + assert value( + simulation, "individual_savings_account_interest_income" + ) == income.get("individual_savings_account_interest_income", 0) + assert value(simulation, "capital_income") == income["savings_interest_income"] + assert value(simulation, "taxable_savings_interest_income") == expected_taxable + pd.testing.assert_frame_equal(dataset.person, original) + + +@pytest.mark.parametrize( + "route", ["single", "multi", "frame", "uk-h5", "core-h5", "core-object"] +) +def test_unmarked_supported_sources_share_the_same_legacy_interpretation( + route, tmp_path +): + dataset = single( + savings_interest_income=3_000, individual_savings_account_interest_income=500 + ) + if route == "single": + source = dataset + elif route == "multi": + source = UKMultiYearDataset(datasets=[dataset]) + elif route == "frame": + source = input_frame(dataset) + elif route == "uk-h5": + source = tmp_path / "legacy-uk.h5" + dataset.save(str(source)) + # Old files had no marker. Remove any default written by a new writer. + with h5py.File(source, "a") as output: + if KEY in output.attrs: + del output.attrs[KEY] + else: + core = core_file(dataset, tmp_path / "legacy-core.h5") + source = core if route == "core-object" else core.file_path + simulation = Simulation(dataset=source) + assert value(simulation, "savings_interest_income") == 2_500 + assert value(simulation, "capital_income") == 3_000 + assert value(simulation, "taxable_savings_interest_income") == 2_500 + + +def test_marked_additive_data_and_direct_households_keep_nonisa_interest(): + income = { + "savings_interest_income": 2_000, + "individual_savings_account_interest_income": 500, + "tax_free_savings_income": 700, + "individual_savings_account_dividend_income": 300, + } + dataset = single(contract=ADDITIVE, **income) + source_frame = input_frame(dataset) + source_frame.attrs[KEY] = deepcopy(ADDITIVE) + situation = { + "people": { + "person": {"age": {2025: 35}, **{k: {2025: v} for k, v in income.items()}} + }, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + for source in (dataset, source_frame): + simulation = Simulation(dataset=source) + assert value(simulation, "savings_interest_income") == 2_000 + assert value(simulation, "taxable_savings_interest_income") == 2_000 + assert value(simulation, "capital_income") == 3_000 + direct = Simulation(situation=situation) + assert value(direct, "taxable_savings_interest_income") == 2_000 + assert value(direct, "capital_income") == 3_000 + + +def test_normalized_legacy_survives_export_reload_copy_clone_and_reprojection(tmp_path): + original = single( + savings_interest_income=3_000, individual_savings_account_interest_income=500 + ) + simulation = Simulation(dataset=original, scenario=MACRO) + assert value(simulation, "savings_interest_income", 2026) == pytest.approx( + 2_800, abs=0.01 + ) + exported = simulation.to_input_dataframe() + assert exported.attrs[KEY]["years"]["2026"] == NORMALIZED + reloaded = Simulation(dataset=exported, scenario=MACRO) + for copied in (simulation.clone(), simulation.get_branch("isa-contract"), reloaded): + assert value(copied, "savings_interest_income", 2026) == pytest.approx( + 2_800, abs=0.01 + ) + assert value(copied, "capital_income", 2026) == pytest.approx(3_300, abs=0.01) + observed_2026 = UKSingleYearDataset.from_simulation(simulation, fiscal_year=2026) + assert observed_2026.isa_income_contract == NORMALIZED + copied = observed_2026.copy() + assert copied.isa_income_contract == NORMALIZED + copied.isa_income_contract["projection"] = "additive" + assert observed_2026.isa_income_contract == NORMALIZED + path = tmp_path / "normalized.h5" + observed_2026.save(str(path)) + with h5py.File(path, "r") as stored: + assert json.loads(stored.attrs[KEY]) == NORMALIZED + assert KEY not in stored + saved = UKSingleYearDataset(path) + projected_again = Simulation(dataset=saved, scenario=MACRO) + # Gross3300*1.2, then subtract unchanged legacy exemption500. + assert value(projected_again, "savings_interest_income", 2027) == pytest.approx( + 3_460, abs=0.01 + ) + + +def test_mixed_year_contracts_survive_multi_h5_and_dataframe_round_trips(tmp_path): + legacy = single( + savings_interest_income=3_000, individual_savings_account_interest_income=500 + ) + additive = single( + 2026, + contract=ADDITIVE, + savings_interest_income=4_000, + individual_savings_account_interest_income=600, + ) + dataset = UKMultiYearDataset(datasets=[legacy, additive]) + expected = {"version": 1, "years": {"2025": LEGACY, "2026": ADDITIVE}} + assert dataset.isa_income_contract == expected + path = tmp_path / "mixed.h5" + dataset.save(str(path)) + with h5py.File(path, "r") as stored: + assert json.loads(stored.attrs[KEY]) == expected + simulation = Simulation(dataset=path) + frame = simulation.to_input_dataframe() + contracts = frame.attrs[KEY] + assert set(contracts) == {"version", "years"} + assert contracts["version"] == 1 + assert contracts["years"]["2025"] == NORMALIZED + assert contracts["years"]["2026"] == ADDITIVE + # Existing UC rebalancing records inputs beyond the two supplied years. + # Every exported annual input year needs its own contract, including + # those added years; later ones inherit the latest additive origin. + exported_years = { + column.rsplit("__", 1)[1] + for column in frame.columns + if column.rsplit("__", 1)[1].isdigit() + } + assert set(contracts["years"]) == exported_years + for year in exported_years - {"2025", "2026"}: + assert int(year) > 2026 + assert contracts["years"][year] == ADDITIVE + reloaded = Simulation(dataset=frame) + assert value(reloaded, "savings_interest_income", 2025) == 2_500 + assert value(reloaded, "savings_interest_income", 2026) == 4_000 + assert value(reloaded, "capital_income", 2026) == 4_600 + + +def test_no_economic_assumptions_resets_legacy_gross_before_each_year_exemption(): + dataset = UKMultiYearDataset( + datasets=[ + single( + savings_interest_income=3_000, + individual_savings_account_interest_income=500, + tax_free_savings_income=700, + ), + single( + 2026, + savings_interest_income=6_000, + individual_savings_account_interest_income=900, + tax_free_savings_income=1_200, + ), + ] + ) + simulation = Simulation(dataset=dataset, scenario=no_economic_assumptions) + # The old reset froze mapped gross interest but retained the explicit, + # unindexed exemption for each supplied year:3000-1200=1800. + assert value(simulation, "savings_interest_income", 2026) == 1_800 + assert value(simulation, "tax_free_savings_income", 2026) == 1_200 + assert value(simulation, "individual_savings_account_interest_income", 2026) == 900 + assert value(simulation, "capital_income", 2026) == 3_000 + assert dataset[2026].person["savings_interest_income"].iloc[0] == 6_000 + + +@pytest.mark.parametrize( + "contract", + [ + {"version": 2, "representation": "additive", "projection": "additive"}, + {"version": True, "representation": "additive", "projection": "additive"}, + {"version": 1, "representation": "legacy_gross", "projection": "additive"}, + {"version": 1, "projection": "additive"}, + {**ADDITIVE, "extra": "unrecognized"}, + ], +) +def test_unknown_or_ambiguous_contracts_fail_closed(contract): + with pytest.raises(ValueError, match="ISA income contract"): + single(contract=contract, savings_interest_income=2_000) + + +@pytest.mark.parametrize( + "marker", ["not JSON", "null", json.dumps({"version": 1, "years": {}})] +) +def test_present_invalid_h5_marker_is_not_treated_as_absent(marker, tmp_path): + dataset = single( + savings_interest_income=3_000, individual_savings_account_interest_income=500 + ) + path = tmp_path / "invalid.h5" + dataset.save(str(path)) + with h5py.File(path, "a") as output: + output.attrs[KEY] = marker + with pytest.raises(ValueError, match="ISA income contract"): + Simulation(dataset=path) + + +def test_explicit_object_and_file_markers_must_agree(tmp_path): + dataset = single( + savings_interest_income=2_000, individual_savings_account_interest_income=500 + ) + core = core_file(dataset, tmp_path / "conflict.h5", marker=ADDITIVE) + core.isa_income_contract = deepcopy(LEGACY) + with pytest.raises(ValueError, match="ISA income contract"): + Simulation(dataset=core) + + +@pytest.mark.parametrize( + "name", + [ + "savings_interest_income", + "individual_savings_account_interest_income", + "tax_free_savings_income", + ], +) +def test_nonfinite_legacy_conversion_inputs_are_rejected(name): + income = { + "savings_interest_income": 3_000, + "individual_savings_account_interest_income": 500, + } + income[name] = np.inf + with pytest.raises(ValueError, match="ISA income contract"): + Simulation(dataset=single(**income)) + + +@pytest.mark.parametrize( + "explicit_t, expected_t, expected_cash", [(None, 550, 2_750), (700, 700, 2_900)] +) +def test_new_additive_projection_grows_nonisa_and_isa_interest_without_changing_explicit_T( + explicit_t, expected_t, expected_cash +): + income = { + "savings_interest_income": 2_000, + "individual_savings_account_interest_income": 500, + } + if explicit_t is not None: + income["tax_free_savings_income"] = explicit_t + simulation = Simulation(dataset=single(contract=ADDITIVE, **income), scenario=MACRO) + assert value(simulation, "savings_interest_income", 2026) == pytest.approx( + 2_200, abs=0.01 + ) + assert value( + simulation, "individual_savings_account_interest_income", 2026 + ) == pytest.approx(550, abs=0.01) + assert value(simulation, "tax_free_savings_income", 2026) == pytest.approx( + expected_t, abs=0.01 + ) + assert value(simulation, "capital_income", 2026) == pytest.approx( + expected_cash, abs=0.01 + ) + + +@pytest.mark.parametrize( + "future_inputs, expected_n, expected_i, expected_t, expected_cash", + [ + ({}, 5_500, 500, 500, 6_000), + ({"individual_savings_account_interest_income": 700}, 5_300, 700, 700, 6_000), + ( + { + "individual_savings_account_interest_income": 700, + "tax_free_savings_income": 800, + }, + 5_200, + 700, + 800, + 6_000, + ), + ({"savings_interest_income": 900}, 900, 500, 500, 1_400), + ({"non_cash_isa_cash_interest": 700}, 5_300, 700, 700, 6_000), + ], + ids=[ + "old-default", + "future-I", + "explicit-T-over-I", + "canonical-N-override", + "future-detail", + ], +) +def test_legacy_origin_core_fallback_after_projection_horizon_preserves_gross_and_overrides( + future_inputs, expected_n, expected_i, expected_t, expected_cash +): + # Dataset projection ends in2032. A supplied multi-year container avoids + # extending the observation, so2033 exercises actual Core fallback. + # Old gross G=2500+500=3000 doubles under the chosen ONS index ratio. + # I had no old automatic index; future supplied I/T/detail still take priority. + dataset = UKMultiYearDataset( + datasets=[ + single( + 2032, + contract=NORMALIZED, + savings_interest_income=2_500, + individual_savings_account_interest_income=500, + ) + ] + ) + index = "gov.economic_assumptions.indices.ons.household_interest_income" + macro = Scenario( + parameter_changes={index: {"year:2032-01-01:1": 100, "year:2033-01-01:1": 200}}, + applied_before_data_load=True, + ) + simulation = Simulation(dataset=dataset, scenario=macro) + for name, amount in future_inputs.items(): + simulation.set_input(name, 2033, [amount]) + for name, expected in ( + ("savings_interest_income", expected_n), + ("individual_savings_account_interest_income", expected_i), + ("tax_free_savings_income", expected_t), + ("capital_income", expected_cash), + ): + assert value(simulation, name, 2033) == pytest.approx(expected, abs=0.01), name + + +@pytest.mark.parametrize( + "contract, expected_i", + [(NORMALIZED, 500), (ADDITIVE, 1_000)], + ids=["legacy-origin-carry", "new-additive-ons-growth"], +) +def test_existing_isa_total_keeps_priority_over_its_same_period_of_which_detail( + contract, expected_i +): + # The explicit500 total already includes the100 detail. An earlier + # component cannot replace that authoritative total in a later year. + # Legacy I had no automatic index; new additive I follows the ONS ratio. + dataset = UKMultiYearDataset( + datasets=[ + single( + 2032, + contract=contract, + savings_interest_income=2_500, + individual_savings_account_interest_income=500, + non_cash_isa_cash_interest=100, + ) + ] + ) + index = "gov.economic_assumptions.indices.ons.household_interest_income" + macro = Scenario( + parameter_changes={index: {"year:2032-01-01:1": 100, "year:2033-01-01:1": 200}}, + applied_before_data_load=True, + ) + simulation = Simulation(dataset=dataset, scenario=macro) + assert value(simulation, "individual_savings_account_interest_income", 2032) == 500 + assert value( + simulation, "individual_savings_account_interest_income", 2033 + ) == pytest.approx(expected_i, abs=0.01) + assert value(simulation, "tax_free_savings_income", 2033) == pytest.approx( + expected_i, abs=0.01 + ) + + +# The reference below exercises Core's existing flow input protocol directly. +# It deliberately bypasses the UK dataset/ISA adapter and reads gross savings, +# rather than deriving expectations from the adapter being tested. +def gross_core_reference(savings_entries, exemptions, isa_interest): + reference_system = system.clone() + reference_system.parameters = deepcopy(system.parameters) + reference_system.parameters.get_child( + "gov.economic_assumptions.indices.ons.household_interest_income" + ).update(period="year:2025:5", value=100) + person = { + "age": {2026: 35}, + "individual_savings_account_interest_income": dict(isa_interest), + } + if exemptions: + person["tax_free_savings_income"] = dict(exemptions) + reference = SimulationBuilder().build_from_dict( + reference_system, + { + "people": {"person": person}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + }, + ) + # The dataset producer replays columns in declared order. Builder's + # buffered input sorting would change Core's gross residual calculation. + for period, amount in savings_entries: + reference.set_input("savings_interest_income", period, [amount]) + return reference + + +def nonannual_frame(savings_entries, exemptions, isa_interest, contract=None): + # Keep every financial column in the producer's declared order. A YEAR + # variable may accept a MONTH input through Core's actual input handler. + frame = pd.DataFrame( + { + "person_id__2026": [1], + "person_benunit_id__2026": [1], + "person_household_id__2026": [1], + "age__2026": [35], + **{ + f"individual_savings_account_interest_income__{period}": [amount] + for period, amount in isa_interest + }, + **{ + f"tax_free_savings_income__{period}": [amount] + for period, amount in exemptions + }, + **{ + f"savings_interest_income__{period}": [amount] + for period, amount in savings_entries + }, + } + ) + if contract is not None: + frame.attrs[KEY] = deepcopy(contract) + return frame + + +@pytest.mark.parametrize( + "contract, rolling_t, expected_n, expected_cash", + [ + (None, None, 100, 600), + (ADDITIVE, None, 600, 1_100), + (None, 0, 600, 600), + (None, 700, -100, 600), + ], + ids=["legacy", "additive-control", "explicit-zero-T", "explicit-T-differs-from-I"], +) +def test_nonannual_month_replay_normalizes_the_annual_period_core_stores( + contract, rolling_t, expected_n, expected_cash +): + # Core stores the July declaration as YEAR July2026--June2027 with value + # 600. This is the accepted input protocol, not a new annualisation rule. + rolling_year = "year:2026-07:1" + entries = [("2026", 3_000), ("2026-07", 600)] + exemptions = [] if rolling_t is None else [(rolling_year, rolling_t)] + interest = [("2026", 500), ("2027", 500)] + reference = gross_core_reference(entries, exemptions, interest) + assert reference.get_array("savings_interest_income", rolling_year).tolist() == [ + 600 + ] + assert value(reference, "tax_free_savings_income", rolling_year) == ( + 500 if rolling_t is None else rolling_t + ) + simulation = Simulation( + dataset=nonannual_frame(entries, exemptions, interest, contract), + scenario=no_economic_assumptions, + ) + assert value(simulation, "savings_interest_income", rolling_year) == pytest.approx( + expected_n, abs=0.01 + ) + assert value(simulation, "capital_income", rolling_year) == pytest.approx( + expected_cash, abs=0.01 + ) + assert value( + simulation, "taxable_savings_interest_income", rolling_year + ) == pytest.approx(max(expected_n, 0), abs=0.01) + # Carry to a later query must preserve the original gross cash amount too. + expected_later = value(reference, "savings_interest_income", 2027) + if contract == ADDITIVE: + expected_later += value(reference, "tax_free_savings_income", 2027) + assert value(simulation, "capital_income", 2027) == pytest.approx( + expected_later, abs=0.01 + ) + + +@pytest.mark.parametrize( + "entries, contract, expected_gross", + [ + ([("month:2026-01:24", 6_000)], None, [3_000, 3_000]), + ([("2026", 1_000), ("month:2026-01:24", 6_000)], None, [1_000, 5_000]), + ([("month:2026-01:24", 6_000), ("2026", 3_000)], None, [3_000, 3_000]), + ([("month:2026-01:24", 6_000)], ADDITIVE, [3_000, 3_000]), + ], + ids=["span-only", "prior-year-residual", "span-then-year", "additive-span-control"], +) +def test_long_month_span_replay_preserves_core_gross_order_before_year_specific_exemptions( + entries, contract, expected_gross +): + # MONTH24 invokes Core's YEAR-flow handler: it keeps existing annual + # inputs and divides the gross residual over the remaining annual slots. + # A YEAR(size2) input has different direct-storage semantics; tested below. + exemptions = [("2026", 500), ("2027", 700)] + interest = [("2026", 500), ("2027", 500)] + reference = gross_core_reference(entries, exemptions, interest) + assert [ + value(reference, "savings_interest_income", y) for y in (2026, 2027) + ] == expected_gross + simulation = Simulation( + dataset=nonannual_frame(entries, exemptions, interest, contract), + scenario=no_economic_assumptions, + ) + for year, gross in zip((2026, 2027), expected_gross): + exemption = value(reference, "tax_free_savings_income", year) + expected_n = gross if contract == ADDITIVE else gross - exemption + expected_cash = gross + exemption if contract == ADDITIVE else gross + assert value(simulation, "savings_interest_income", year) == pytest.approx( + expected_n, abs=0.01 + ) + assert value(simulation, "capital_income", year) == pytest.approx( + expected_cash, abs=0.01 + ) + assert value( + simulation, "taxable_savings_interest_income", year + ) == pytest.approx(max(expected_n, 0), abs=0.01) + + +def test_mixed_annual_additive_input_is_gross_equivalent_during_a_legacy_span_replay(): + exemptions = [("2026", 500), ("2027", 700)] + interest = [("2026", 500), ("2027", 500)] + entries = [("2027", 2_000), ("month:2026-01:24", 6_000)] + contract = {"version": 1, "years": {"2026": LEGACY, "2027": ADDITIVE}} + # The known2027 ordinary2000 plus exemption700 consumes2700 of the + # 6000 gross span, leaving3300 gross for2026. Core computes the residual. + reference = gross_core_reference( + [("2027", 2_700), ("month:2026-01:24", 6_000)], exemptions, interest + ) + assert [value(reference, "savings_interest_income", y) for y in (2026, 2027)] == [ + 3_300, + 2_700, + ] + simulation = Simulation( + dataset=nonannual_frame(entries, exemptions, interest, contract), + scenario=no_economic_assumptions, + ) + for year, expected_n, gross in ((2026, 2_800, 3_300), (2027, 2_000, 2_700)): + assert value(simulation, "savings_interest_income", year) == pytest.approx( + expected_n, abs=0.01 + ) + assert value(simulation, "capital_income", year) == pytest.approx( + gross, abs=0.01 + ) + + +@pytest.mark.parametrize( + "prior_gross", + [None, 1_000], + ids=["same-start-carry", "strict-earlier-uprating-first"], +) +def test_larger_year_input_preserves_core_same_start_fallback_and_earlier_priority( + prior_gross, +): + # Core stores YEAR(size2) directly, without dividing it into annual inputs. + # For a one-year query it first tries a strictly earlier uprating source; + # only absent that source does same-start carry apply. The gross control + # below executes that exact base protocol rather than inventing a split. + entries = [] if prior_gross is None else [("2025", prior_gross)] + entries += [("year:2026:2", 6_000)] + exemptions = [("2025", 400), ("2026", 500), ("2027", 700)] + interest = [("2025", 500), ("2026", 500), ("2027", 500)] + reference = gross_core_reference(entries, exemptions, interest) + assert reference.get_array("savings_interest_income", "year:2026:2").tolist() == [ + 6_000 + ] + expected_gross = [6_000 if prior_gross is None else 1_000, 6_000] + assert [ + value(reference, "savings_interest_income", y) for y in (2026, 2027) + ] == expected_gross + simulation = Simulation( + dataset=nonannual_frame(entries, exemptions, interest), + scenario=no_economic_assumptions, + ) + for year, gross in zip((2026, 2027), expected_gross): + exemption = value(reference, "tax_free_savings_income", year) + assert value(simulation, "capital_income", year) == pytest.approx( + gross, abs=0.01 + ) + assert value(simulation, "savings_interest_income", year) == pytest.approx( + gross - exemption, abs=0.01 + ) + + +@pytest.mark.parametrize( + "complete_base_energy, expected_energy", + [ + pytest.param(True, [150, 300, 450], id="complete-base-group"), + pytest.param(False, [840, 960, 1_080], id="partial-base-different-basis"), + ], +) +def test_isa_and_energy_contracts_compose_through_reset_export_and_projection( + tmp_path, complete_base_energy, expected_energy +): + """Both projection contracts survive the shared dataset boundaries. + + Resetting legacy interest gives gross3000 less the later exemption1200. + Reprojection gives gross3000*1.2=3600, then ordinary2400 plus exempt1200. + Energy grows by150/100 from the complete reset group's2025 basis, or by + 150/125 from the preserved later group's2026 basis. CPI levels and growth + are controlled test inputs, not forecasts. + """ + energy = ( + "domestic_energy_consumption", + "electricity_consumption", + "gas_consumption", + ) + base = single( + savings_interest_income=3_000, + individual_savings_account_interest_income=500, + tax_free_savings_income=700, + ) + later = single( + 2026, + savings_interest_income=6_000, + individual_savings_account_interest_income=900, + tax_free_savings_income=1_200, + ) + base.energy_price_basis = {"period_type": "calendar_year", "period_value": 2025} + later.energy_price_basis = {"period_type": "fiscal_year", "period_value": 2026} + for observation in (base, later): + observation.person["months_since_last_birthday"] = [0] + observation.person["attends_private_school"] = [False] + for variable, amount in zip( + energy if complete_base_energy else energy[:1], [100, 200, 300] + ): + base.household[variable] = [amount] + for variable, amount in zip(energy, [700, 800, 900]): + later.household[variable] = [amount] + original_base = base.copy() + original_later = later.copy() + + simulation = Simulation( + dataset=UKMultiYearDataset(datasets=[base, later]), + scenario=no_economic_assumptions, + ) + assert value(simulation, "savings_interest_income", 2026) == 1_800 + assert value(simulation, "tax_free_savings_income", 2026) == 1_200 + assert value(simulation, "individual_savings_account_interest_income", 2026) == 900 + assert value(simulation, "capital_income", 2026) == 3_000 + + observed = UKSingleYearDataset.from_simulation( + simulation, + fiscal_year=2026, + energy_price_basis=simulation.dataset[2026].energy_price_basis, + ) + exported = Simulation(dataset=UKMultiYearDataset(datasets=[observed])) + filtered = filter_dataset(exported, household_id=1, year=2026).copy() + path = tmp_path / "joint-contracts.h5" + filtered.save(path) + reloaded = UKSingleYearDataset(file_path=path) + assert reloaded.isa_income_contract == NORMALIZED + assert ( + reloaded.energy_price_basis + == (base if complete_base_energy else later).energy_price_basis + ) + + parameters = simulation.tax_benefit_system.parameters.clone() + cpi = parameters.get_child( + "gov.economic_assumptions.indices.obr.consumer_price_index" + ) + for year, level in {2025: 100, 2026: 125, 2027: 150}.items(): + cpi.update(period=str(year), value=level) + parameters.get_child(GDP_GROWTH).update(period="2027", value=0.2) + projected = extend_single_year_dataset(reloaded, parameters, end_year=2027) + projected_simulation = Simulation(dataset=projected) + assert value( + projected_simulation, "savings_interest_income", 2027 + ) == pytest.approx(2_400, abs=0.01) + assert value(projected_simulation, "tax_free_savings_income", 2027) == 1_200 + assert value(projected_simulation, "capital_income", 2027) == pytest.approx( + 3_600, abs=0.01 + ) + for variable, expected in zip(energy, expected_energy): + assert value(projected_simulation, variable, 2027) == pytest.approx( + expected, abs=0.01 + ) + for actual, original in ((base, original_base), (later, original_later)): + assert actual.energy_price_basis == original.energy_price_basis + assert actual.isa_income_contract == original.isa_income_contract + for name in ("person", "benunit", "household"): + pd.testing.assert_frame_equal( + getattr(actual, name), getattr(original, name), check_dtype=False + ) + + +def test_split_bsp_isa_and_energy_share_reset_and_projection_boundaries(): + """Reset preserves separate BSP, gross interest and the energy price basis. + + The later BSP observation stays777 while the separate CPI benefits reset + to70/60. Legacy gross interest resets to3000, with later exemption1200. + A controlled20% projection gives ordinary2400 plus exempt1200, while BSP + stays777, the other benefits become84/72, and energy uses150/100 prices. + """ + energy = ( + "domestic_energy_consumption", + "electricity_consumption", + "gas_consumption", + ) + base = single( + savings_interest_income=3_000, + individual_savings_account_interest_income=500, + tax_free_savings_income=700, + bsp_reported=1_000, + widowed_parents_allowance_reported=70, + war_widows_pension_reported=60, + ) + later = single( + 2026, + savings_interest_income=6_000, + individual_savings_account_interest_income=900, + tax_free_savings_income=1_200, + bsp_reported=777, + widowed_parents_allowance_reported=140, + war_widows_pension_reported=120, + ) + base.energy_price_basis = {"period_type": "calendar_year", "period_value": 2025} + later.energy_price_basis = {"period_type": "fiscal_year", "period_value": 2026} + for dataset in (base, later): + dataset.person["months_since_last_birthday"] = [0] + dataset.person["attends_private_school"] = [False] + for variable, first, second in zip(energy, [100, 200, 300], [700, 800, 900]): + base.household[variable] = [first] + later.household[variable] = [second] + originals = [dataset.copy() for dataset in (base, later)] + simulation = Simulation( + dataset=UKMultiYearDataset(datasets=[base, later]), + scenario=no_economic_assumptions, + ) + reset_expected = { + "bsp_reported": 777, + "widowed_parents_allowance_reported": 70, + "war_widows_pension_reported": 60, + "savings_interest_income": 1_800, + "tax_free_savings_income": 1_200, + "capital_income": 3_000, + } + for variable, expected in reset_expected.items(): + assert value(simulation, variable, 2026) == pytest.approx(expected, abs=0.01) + observed = UKSingleYearDataset.from_simulation( + simulation, + fiscal_year=2026, + energy_price_basis=simulation.dataset[2026].energy_price_basis, + ) + assert observed.isa_income_contract == NORMALIZED + assert observed.energy_price_basis == base.energy_price_basis + changes = { + "gov.economic_assumptions.indices.obr.consumer_price_index": { + str(year): level + for year, level in {2025: 100, 2026: 125, 2027: 150, 2035: 300}.items() + }, + "gov.economic_assumptions.yoy_growth.obr.consumer_price_index": {"2027": 0.2}, + GDP_GROWTH: {"2027": 0.2}, + } + parameters = simulation.tax_benefit_system.parameters.clone() + for name, periods in changes.items(): + for year, amount in periods.items(): + parameters.get_child(name).update(period=year, value=amount) + projected = extend_single_year_dataset(observed, parameters, end_year=2027) + result = Simulation( + dataset=projected, + scenario=Scenario(parameter_changes=changes, applied_before_data_load=True), + ) + expected_2027 = { + "bsp_reported": 777, + "widowed_parents_allowance_reported": 84, + "war_widows_pension_reported": 72, + "savings_interest_income": 2_400, + "tax_free_savings_income": 1_200, + "capital_income": 3_600, + **dict(zip(energy, [150, 300, 450])), + } + for reset in (False, True): + if reset: + result.reset_calculations() + for variable, expected in expected_2027.items(): + assert value(result, variable, 2027) == pytest.approx(expected, abs=0.01) + assert value(result, "bsp_reported", 2035) == 777 + assert value( + result, "widowed_parents_allowance_reported", 2035 + ) == pytest.approx(168, abs=0.01) + assert value(result, "bsp_reported", "2035-07") == pytest.approx( + 777 / 12, abs=0.01 + ) + for dataset, original in zip((base, later), originals): + assert dataset.isa_income_contract == original.isa_income_contract + assert dataset.energy_price_basis == original.energy_price_basis + for table in ("person", "benunit", "household"): + pd.testing.assert_frame_equal( + getattr(dataset, table), getattr(original, table) + ) + + +@pytest.mark.parametrize( + "contract", [LEGACY, ADDITIVE], ids=["legacy-gross", "additive"] +) +@pytest.mark.parametrize( + "isa_column, ordinary_column, supplied", + [ + ("individual_savings_account_dividend_income", "dividend_income", 300), + ("non_cash_isa_cash_interest", "savings_interest_income", 100), + ], + ids=["isa-dividend", "noncash-interest"], +) +def test_new_isa_columns_follow_ordinary_dataset_projection_in_both_contracts( + contract, isa_column, ordinary_column, supplied +): + """Both sides of the 2030 map boundary use the ordinary dataset indices. + + Ordinary interest and ISA interest together start at 2000 on either + contract. Legacy projection grows gross interest before subtracting the + unchanged 500 exemption; additive projection grows ordinary 1500 and + exempt 500 separately. The noncash 100 is already included in that 500. + Ordinary dividends 200 and separate ISA dividends 300 complete the cash + total 2500. A benchmark with only the ordinary counterpart independently + checks the new columns without populating the projection map for them. + """ + projection = Scenario( + parameter_changes={ + GDP_GROWTH: {f"year:{year}-01-01:1": 0.1 for year in range(2026, 2033)} + }, + applied_before_data_load=True, + ) + source = single( + contract=contract, + savings_interest_income=2_000 if contract == LEGACY else 1_500, + individual_savings_account_interest_income=500, + dividend_income=200, + individual_savings_account_dividend_income=300, + non_cash_isa_cash_interest=100, + ) + original = source.copy() + benchmark_source = single(contract=ADDITIVE, **{ordinary_column: supplied}) + benchmark_original = benchmark_source.copy() + simulation = Simulation(dataset=source, scenario=projection) + benchmark = Simulation(dataset=benchmark_source, scenario=projection) + + for year in (2025, 2028, 2030, 2031, 2032): + factor = 1.1 ** (year - 2025) + ordinary = value(benchmark, ordinary_column, year) + assert ordinary == pytest.approx(supplied * factor, abs=0.01) + assert value(simulation, isa_column, year) == pytest.approx(ordinary, abs=0.01) + if year > 2025: + assert ordinary > supplied + exempt = 500 if contract == LEGACY else 500 * factor + ordinary_interest = ( + 2_000 * factor - exempt if contract == LEGACY else 1_500 * factor + ) + assert value( + simulation, "individual_savings_account_interest_income", year + ) == (pytest.approx(exempt, abs=0.01)) + assert value(simulation, "tax_free_savings_income", year) == pytest.approx( + exempt, abs=0.01 + ) + assert value(simulation, "savings_interest_income", year) == pytest.approx( + ordinary_interest, abs=0.01 + ) + assert value(simulation, "taxable_savings_interest_income", year) == ( + pytest.approx(ordinary_interest, abs=0.01) + ) + assert value(simulation, "taxable_dividend_income", year) == pytest.approx( + 200 * factor, abs=0.01 + ) + assert value(simulation, "capital_income", year) == pytest.approx( + 2_500 * factor, abs=0.01 + ) + + for actual, before in ((source, original), (benchmark_source, benchmark_original)): + assert actual.isa_income_contract == before.isa_income_contract + for table in ("person", "benunit", "household"): + pd.testing.assert_frame_equal( + getattr(actual, table), getattr(before, table) + ) diff --git a/policyengine_uk/tests/test_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index 83e4d1909b..b1cab3bbd4 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -423,6 +423,15 @@ def test_tax_free_savings_formula_takes_over_after_the_horizon(): """ dataset = _dataset() dataset.person["tax_free_savings_income"] = 1_000.0 + # The generic fixture includes indexed ISA-interest total and detail inputs. + # This legacy case omits both sources of the formula's ISA-interest default. + dataset.person = dataset.person.drop( + columns=[ + "individual_savings_account_interest_income", + "non_cash_isa_cash_interest", + ], + errors="ignore", + ) # Enhanced FRS 2024-25 does not store the formula's ISA-interest source. simulation = Microsimulation(dataset=dataset) assert simulation.calculate( diff --git a/policyengine_uk/variables/gov/gov_spending.py b/policyengine_uk/variables/gov/gov_spending.py index 287e7a1418..2ac8ed36ed 100644 --- a/policyengine_uk/variables/gov/gov_spending.py +++ b/policyengine_uk/variables/gov/gov_spending.py @@ -2,6 +2,7 @@ GOV_SPENDING_VARIABLES = [ + "lifetime_isa_bonus", "child_benefit", "council_tax_benefit", "esa_income", diff --git a/policyengine_uk/variables/gov/gov_tax.py b/policyengine_uk/variables/gov/gov_tax.py index ea1742a789..63effe8e67 100644 --- a/policyengine_uk/variables/gov/gov_tax.py +++ b/policyengine_uk/variables/gov/gov_tax.py @@ -2,6 +2,8 @@ GOV_TAX_VARIABLES = [ + "lifetime_isa_withdrawal_charge", + "isa_cash_interest_flat_rate_charge", "expected_sdlt", "expected_ltt", "expected_lbtt", diff --git a/policyengine_uk/variables/gov/hmrc/household_tax.py b/policyengine_uk/variables/gov/hmrc/household_tax.py index e730169381..6a625e5d09 100644 --- a/policyengine_uk/variables/gov/hmrc/household_tax.py +++ b/policyengine_uk/variables/gov/hmrc/household_tax.py @@ -2,6 +2,7 @@ HOUSEHOLD_TAX_VARIABLES = [ + "isa_cash_interest_flat_rate_charge", "expected_sdlt", "expected_ltt", "expected_lbtt", diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/ISA_interest_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/ISA_interest_income.py index 0d8a1d85af..9a8647c733 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/ISA_interest_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/ISA_interest_income.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.data.isa_income import isa_interest_income_default class individual_savings_account_interest_income(Variable): @@ -7,7 +8,18 @@ class individual_savings_account_interest_income(Variable): label = "Amount received in interest from Individual Savings Accounts" definition_period = YEAR unit = GBP + quantity_type = FLOW + documentation = ( + "ISA interest, additional to ordinary savings_interest_income. The " + "non-cash ISA cash-interest input is an of-which detail: it supplies " + "this total only when no total is provided." + ) reference = dict( - title="Income Tax (Trading and Other Income) Act 2005, Part VI, s. 3", + title="Income Tax (Trading and Other Income) Act 2005, Part 6, Chapter 3", href="https://www.legislation.gov.uk/ukpga/2005/5/part/6/chapter/3", ) + + def formula(person, period, parameters): + # Detail defaults and origin-aware uprating share one formula mode: + # Core does not allow a variable to combine formula and uprating. + return isa_interest_income_default(person.simulation, period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/tax_free_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/tax_free_savings_income.py index 51a27a3531..81c693d588 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/tax_free_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/tax_free_savings_income.py @@ -7,5 +7,10 @@ class tax_free_savings_income(Variable): label = "Income from savings in tax-free accounts" definition_period = YEAR unit = GBP + documentation = ( + "Exempt interest additional to ordinary savings interest. Defaults " + "to ISA interest; a supplied aggregate, including zero, overrides " + "that default. Market income adds this aggregate once." + ) adds = ["individual_savings_account_interest_income"] diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/taxable_savings_interest_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/taxable_savings_interest_income.py index 0e432661a0..a70649905c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/taxable_savings_interest_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/savings_income/taxable_savings_interest_income.py @@ -16,5 +16,6 @@ def formula(person, period, parameters): standard_interest = person("savings_interest_income", period) other_interest = person("other_investment_income", period) total_interest = standard_interest + other_interest - exempt_interest = person("tax_free_savings_income", period) - return max_(0, total_interest - exempt_interest) + # Exempt interest is a separate additive input, not an allowance + # against interest from unrelated taxable savings. + return max_(0, total_interest) diff --git a/policyengine_uk/variables/gov/hmrc/isa/individual_savings_account_dividend_income.py b/policyengine_uk/variables/gov/hmrc/isa/individual_savings_account_dividend_income.py new file mode 100644 index 0000000000..35309001ba --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/individual_savings_account_dividend_income.py @@ -0,0 +1,14 @@ +from policyengine_uk.model_api import * + + +class individual_savings_account_dividend_income(Variable): + value_type = float + entity = Person + label = "Dividends and distributions from ISAs" + documentation = "Annual dividends and distributions from Individual Savings Accounts, separate from ordinary dividend income and ISA interest. Exclude principal withdrawals, capital gains and unrealised appreciation. The model counts this amount once as market income and excludes it from taxable dividends." + definition_period = YEAR + unit = GBP + quantity_type = FLOW + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + reference = "https://www.legislation.gov.uk/uksi/1998/1870/regulation/22" diff --git a/policyengine_uk/variables/gov/hmrc/isa/isa_cash_interest_flat_rate_charge.py b/policyengine_uk/variables/gov/hmrc/isa/isa_cash_interest_flat_rate_charge.py new file mode 100644 index 0000000000..1e6dcae399 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/isa_cash_interest_flat_rate_charge.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class isa_cash_interest_flat_rate_charge(Variable): + value_type = float + entity = Person + label = "Final charge on cash interest in non-cash ISAs" + documentation = "Final manager-paid charge on the of-which cash-interest component of non-cash ISAs from fiscal 2027-28. This tax on an income return enters government and household taxes separately from ordinary income tax." + definition_period = YEAR + unit = GBP + quantity_type = FLOW + default_value = 0 + reference = "https://www.legislation.gov.uk/uksi/2026/1018/regulation/15/made" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc + if not p.isa.cash_interest_charge.in_effect: + return 0 + interest = max_(person("non_cash_isa_cash_interest", period), 0) + return interest * p.income_tax.rates.savings.basic diff --git a/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_bonus.py b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_bonus.py new file mode 100644 index 0000000000..6c4e4ddcae --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_bonus.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class lifetime_isa_bonus(Variable): + value_type = float + entity = Person + label = "Lifetime ISA government bonus" + documentation = "Government contribution to qualifying Lifetime ISA additions. This capital transfer is counted in government spending but not household current income; it does not automatically update a supplied account balance." + definition_period = YEAR + unit = GBP + quantity_type = FLOW + default_value = 0 + reference = "https://www.legislation.gov.uk/uksi/1998/1870/schedule/paragraph/1" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.isa.lifetime + if not p.in_effect: + return 0 + age = person("age", period) + eligible = (age >= p.minimum_age) & (age < p.payment_age_limit) + payments = max_(person("lifetime_isa_payments", period), 0) + return eligible * min_(payments, p.payment_limit) * p.bonus_rate diff --git a/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_first_time_buyer.py b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_first_time_buyer.py new file mode 100644 index 0000000000..3ea76f4b7e --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_first_time_buyer.py @@ -0,0 +1,12 @@ +from policyengine_uk.model_api import * + + +class lifetime_isa_first_time_buyer(Variable): + value_type = bool + entity = Person + label = "First-time buyer for Lifetime ISA purposes" + documentation = "Whether this investor has never owned a residential property or equivalent interest anywhere, under paragraph 7 of the Lifetime ISA rules. This is the investor's status; another joint buyer may previously have owned property." + definition_period = YEAR + quantity_type = STOCK + default_value = False + reference = "https://www.legislation.gov.uk/uksi/1998/1870/schedule/paragraph/7" diff --git a/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_months_since_first_payment.py b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_months_since_first_payment.py new file mode 100644 index 0000000000..dabe7880b1 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_months_since_first_payment.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class lifetime_isa_months_since_first_payment(Variable): + value_type = int + entity = Person + label = "Months since the first Lifetime ISA payment" + documentation = "Completed months between the first Lifetime ISA payment and the first-home withdrawal, including continuity through qualifying Lifetime ISA transfers. The value describes the recorded annual first-home withdrawal; separate transaction dates are not modelled." + definition_period = YEAR + unit = "month" + quantity_type = STOCK + default_value = 0 + reference = "https://www.legislation.gov.uk/uksi/1998/1870/schedule/paragraph/6" diff --git a/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_payments.py b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_payments.py new file mode 100644 index 0000000000..24d302ab06 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_payments.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class lifetime_isa_payments(Variable): + value_type = float + entity = Person + label = "Lifetime ISA qualifying payments" + documentation = "Current-year qualifying additions to a Lifetime ISA, including qualifying transfers from other ISA types. Exclude bonuses, interest and growth, Lifetime-ISA-to-Lifetime-ISA transfers, returned failed-purchase withdrawals, defaulted payments and rebated fees. Contributions at ages 40 to 49 assume an account first funded before age 40. Annual age and payment timing are approximations." + definition_period = YEAR + unit = GBP + quantity_type = FLOW + default_value = 0 + reference = "https://www.legislation.gov.uk/uksi/1998/1870/regulation/10A" diff --git a/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_terminally_ill.py b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_terminally_ill.py new file mode 100644 index 0000000000..3842789171 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_terminally_ill.py @@ -0,0 +1,12 @@ +from policyengine_uk.model_api import * + + +class lifetime_isa_terminally_ill(Variable): + value_type = bool + entity = Person + label = "Terminal illness certified for a Lifetime ISA withdrawal" + documentation = "Whether written evidence from a registered medical practitioner states that the investor is expected to live for less than one year, for the Lifetime ISA withdrawal exemption. This input does not derive eligibility for other health benefits." + definition_period = YEAR + quantity_type = STOCK + default_value = False + reference = "https://www.legislation.gov.uk/uksi/1998/1870/schedule/paragraph/4" diff --git a/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_withdrawal_charge.py b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_withdrawal_charge.py new file mode 100644 index 0000000000..f5c6311a4c --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_withdrawal_charge.py @@ -0,0 +1,42 @@ +from policyengine_uk.model_api import * + + +class lifetime_isa_withdrawal_charge(Variable): + value_type = float + entity = Person + label = "Lifetime ISA withdrawal charge" + documentation = "Charge deducted from non-exempt gross Lifetime ISA withdrawals. The model records this capital payment as government revenue, outside household current-income taxes; a supplied balance is not automatically updated. Annual rates use the model's fiscal-year parameter sampling." + definition_period = YEAR + unit = GBP + quantity_type = FLOW + default_value = 0 + reference = "https://www.legislation.gov.uk/uksi/1998/1870/schedule" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.isa.lifetime + if not p.in_effect: + return 0 + gross = max_(person("lifetime_isa_withdrawals", period), 0) + price = person.household("main_residential_property_purchased", period) + qualifying_home = ( + person("lifetime_isa_first_time_buyer", period) + & person.household("main_residential_property_purchased_is_in_uk", period) + & person.household( + "main_residential_property_purchased_with_mortgage", period + ) + & ( + person("lifetime_isa_months_since_first_payment", period) + >= p.minimum_holding_period + ) + & (price > 0) + & (price <= p.property_price_cap) + ) + home_payment = min_( + max_(person("lifetime_isa_withdrawals_towards_first_home", period), 0), + min_(gross, max_(price, 0)), + ) + exempt = (person("age", period) >= p.charge_free_age) | person( + "lifetime_isa_terminally_ill", period + ) + chargeable = max_(gross - where(qualifying_home, home_payment, 0), 0) + return where(exempt, 0, chargeable * p.withdrawal_charge) diff --git a/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_withdrawals.py b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_withdrawals.py new file mode 100644 index 0000000000..ab238cf363 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_withdrawals.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class lifetime_isa_withdrawals(Variable): + value_type = float + entity = Person + label = "Gross Lifetime ISA withdrawals" + documentation = "Gross annual removals from a Lifetime ISA, including any amount withheld for the withdrawal charge. Exclude qualifying transfers to another Lifetime ISA and withdrawals on death. This flow does not update the supplied Lifetime ISA balance. Annual age and transaction conditions are assumed to apply to the recorded withdrawals." + definition_period = YEAR + unit = GBP + quantity_type = FLOW + default_value = 0 + reference = "https://www.legislation.gov.uk/uksi/1998/1870/schedule" diff --git a/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_withdrawals_towards_first_home.py b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_withdrawals_towards_first_home.py new file mode 100644 index 0000000000..ac483c4eea --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/lifetime_isa_withdrawals_towards_first_home.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class lifetime_isa_withdrawals_towards_first_home(Variable): + value_type = float + entity = Person + label = "Lifetime ISA withdrawals paid for a first home" + documentation = "Part of gross Lifetime ISA withdrawals paid directly to an eligible conveyancer towards the purchase price of a first home, with the required declarations and completion or return procedures satisfied. Exclude fees and amounts paid through the investor. Ownership, UK location, financing, holding period and price conditions are checked separately." + definition_period = YEAR + unit = GBP + quantity_type = FLOW + default_value = 0 + reference = "https://www.legislation.gov.uk/uksi/1998/1870/schedule/paragraph/6" diff --git a/policyengine_uk/variables/gov/hmrc/isa/non_cash_isa_cash_interest.py b/policyengine_uk/variables/gov/hmrc/isa/non_cash_isa_cash_interest.py new file mode 100644 index 0000000000..f8ba29d55d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/isa/non_cash_isa_cash_interest.py @@ -0,0 +1,14 @@ +from policyengine_uk.model_api import * + + +class non_cash_isa_cash_interest(Variable): + value_type = float + entity = Person + label = "Of which ISA interest: cash held in non-cash ISAs" + documentation = "Annual interest or alternative-finance returns on cash held in stocks-and-shares or innovative-finance ISAs. This is a component of total ISA interest, not an additional market-income amount; supplied totals must include it. The final manager charge starts in fiscal 2027-28." + definition_period = YEAR + unit = GBP + quantity_type = FLOW + default_value = 0 + uprating = "gov.economic_assumptions.indices.ons.household_interest_income" + reference = "https://www.legislation.gov.uk/uksi/2026/1018/regulation/15/made" diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index ffd58ffd21..4383dba483 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -142,7 +142,11 @@ def normal_gross_income_non_dep_deduction( "property_income_after_finance_costs", "private_pension_income", "savings_interest_income", + # Normal gross income includes exempt returns. Dataset normalization + # separates these from ordinary interest without changing that gross. + "tax_free_savings_income", "dividend_income", + "individual_savings_account_dividend_income", "state_pension", # Normal gross income includes maintenance, although it is exempt # from income tax (Default Scheme Sch para 30(2), (9)). diff --git a/policyengine_uk/variables/household/consumption/main_residential_property_purchased_is_in_uk.py b/policyengine_uk/variables/household/consumption/main_residential_property_purchased_is_in_uk.py new file mode 100644 index 0000000000..e3f9a93085 --- /dev/null +++ b/policyengine_uk/variables/household/consumption/main_residential_property_purchased_is_in_uk.py @@ -0,0 +1,12 @@ +from policyengine_uk.model_api import * + + +class main_residential_property_purchased_is_in_uk(Variable): + value_type = bool + entity = Household + label = "Main home bought is in the UK" + documentation = "Whether the main residential property bought this year is land in the United Kingdom. The household's current address does not establish the purchased property's location." + definition_period = YEAR + quantity_type = STOCK + default_value = False + reference = "https://www.legislation.gov.uk/uksi/1998/1870/schedule/paragraph/6" diff --git a/policyengine_uk/variables/household/consumption/main_residential_property_purchased_with_mortgage.py b/policyengine_uk/variables/household/consumption/main_residential_property_purchased_with_mortgage.py new file mode 100644 index 0000000000..0cfa587d7e --- /dev/null +++ b/policyengine_uk/variables/household/consumption/main_residential_property_purchased_with_mortgage.py @@ -0,0 +1,12 @@ +from policyengine_uk.model_api import * + + +class main_residential_property_purchased_with_mortgage(Variable): + value_type = bool + entity = Household + label = "Main-home purchase uses qualifying Lifetime ISA financing" + documentation = "Whether this main-home purchase uses a secured legal mortgage or regulated home purchase plan qualifying for the Lifetime ISA exemption. Apply the statutory financing restrictions, including the connected-person lender exclusion from 6 April 2024 and restrictions on buy-to-let financing; an existing mortgage balance alone does not establish this fact." + definition_period = YEAR + quantity_type = STOCK + default_value = False + reference = "https://www.gov.uk/guidance/lifetime-isa-withdrawals-for-a-first-time-residential-purchase" diff --git a/policyengine_uk/variables/household/income/capital_income.py b/policyengine_uk/variables/household/income/capital_income.py index 91aa83efb2..e3c26b32df 100644 --- a/policyengine_uk/variables/household/income/capital_income.py +++ b/policyengine_uk/variables/household/income/capital_income.py @@ -10,4 +10,9 @@ class capital_income(Variable): definition_period = YEAR unit = GBP - adds = ["savings_interest_income", "dividend_income"] + adds = [ + "savings_interest_income", + "tax_free_savings_income", + "dividend_income", + "individual_savings_account_dividend_income", + ] diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index 09bfdb34af..604ec70078 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -7,6 +7,8 @@ "employment_income", "self_employment_income", "savings_interest_income", + "tax_free_savings_income", + "individual_savings_account_dividend_income", "dividend_income", "miscellaneous_income", # Landlords' profit after mortgage interest: HBAI uses FRS rent from @@ -68,6 +70,7 @@ "council_tax", "domestic_rates", "income_tax", + "isa_cash_interest_flat_rate_charge", "national_insurance", "student_loan_repayments", # Self-employment losses count as negative income in HBAI. diff --git a/policyengine_uk/variables/household/income/household_market_income.py b/policyengine_uk/variables/household/income/household_market_income.py index 6e2b6e0ccb..1fd5503c79 100644 --- a/policyengine_uk/variables/household/income/household_market_income.py +++ b/policyengine_uk/variables/household/income/household_market_income.py @@ -7,6 +7,8 @@ "employment_income", "self_employment_income", "savings_interest_income", + "tax_free_savings_income", + "individual_savings_account_dividend_income", "dividend_income", "miscellaneous_income", # Landlords' profit after mortgage interest, as in HBAI income. diff --git a/policyengine_uk/variables/household/income/market_income.py b/policyengine_uk/variables/household/income/market_income.py index 3fa0860879..f759e64f76 100644 --- a/policyengine_uk/variables/household/income/market_income.py +++ b/policyengine_uk/variables/household/income/market_income.py @@ -16,6 +16,8 @@ def formula(person, period, parameters): "employment_income", "self_employment_income", "savings_interest_income", + "tax_free_savings_income", + "individual_savings_account_dividend_income", "dividend_income", "miscellaneous_income", # Landlords' profit after mortgage interest, as in HBAI income. diff --git a/policyengine_uk/variables/input/savings_interest_income.py b/policyengine_uk/variables/input/savings_interest_income.py index 959f73cba3..d5bd3e6442 100644 --- a/policyengine_uk/variables/input/savings_interest_income.py +++ b/policyengine_uk/variables/input/savings_interest_income.py @@ -4,8 +4,12 @@ class savings_interest_income(Variable): value_type = float entity = Person - label = "savings interest income" - documentation = "Income from interest on savings, gross of tax" + label = "Savings interest outside tax-free accounts" + documentation = ( + "Interest gross of tax, excluding ISA and other exempt interest. " + "Tax-free interest is additional. Supported dataset loaders convert " + "legacy gross savings inputs to this convention before calculation." + ) definition_period = YEAR reference = "Income Tax (Trading and Other Income) Act 2005 s. 365(1)(a)" unit = GBP