Functional Weave
Code in Python

invest.isa-allowance Unreviewed

ISA allowance used and remaining for a UK tax year: the overall limit, Lifetime ISA, cash ISA and Junior ISA.

1.0.2 · published 2026-10-03 by charlie · Anterra

Pinned by 30 tests, run in TypeScript, Python and Rust.

Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified tax adviser has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

Not professional advice. This capability calculates investment figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

How much of each ISA subscription limit has been used in a UK tax year and how much is left: the overall adult limit, the Lifetime ISA limit inside it, the cash ISA limit inside it (from 2027/28, for savers under 65), and the Junior ISA limit, which is the child's own allowance.

## Why it is shaped this way

For example

  • isa_allowance(2025-10-01, subscriptions ×2, —) → tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false 2025/26: £5,000 cash and £10,000 stocks and shares leaves £5,000
  • isa_allowance(2025-10-01, subscriptions ×2, —) → tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false a full Lifetime ISA counts inside the £20,000
  • isa_allowance(2025-10-01, subscriptions ×1, —) → tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false £18,000 elsewhere leaves £2,000 of Lifetime ISA room, not £4,000

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def isa_allowance(tax_year_date: str, subscriptions: Sequence[IsaSubscription], date_of_birth: Optional[str]) -> IsaAllowance
tax_year_datedateany date in the tax year
subscriptionsIsaSubscription[]every payment into an ISA in that tax year, in GBP; Junior ISA payments are for the child
date_of_birthdate?the saver's; needed only in a year whose cash ISA limit depends on age, otherwise may be null
returnsIsaAllowance

The types it declares, generated into your project

IsaKind = Literal["cash", "stocks-and-shares", "innovative-finance", "lifetime", "junior"]

@dataclass(frozen=True)
class IsaSubscription:
    """One payment into an ISA."""

    date: str
    kind: IsaKind
    #: greater than zero
    amount: Money

@dataclass(frozen=True)
class IsaLimitUse:
    """One limit and how much of it is used."""

    limit: Money
    used: Money
    #: what can still go in: never below zero, and never more than the overall allowance has left
    remaining: Money
    #: true when used is over the limit
    exceeded: bool

@dataclass(frozen=True)
class IsaAllowance:
    """Each limit for the tax year, and whether any is broken."""

    #: HMRC's label, 2026/27
    tax_year: str
    #: every adult ISA together
    overall: IsaLimitUse
    #: Lifetime ISA, inside the overall limit
    lifetime: IsaLimitUse
    #: cash ISA, inside the overall limit; null in a year or for a saver with no separate cash limit
    cash: Optional[IsaLimitUse]
    #: Junior ISA, a separate allowance for the child
    junior: IsaLimitUse
    #: true when any limit is exceeded
    exceeded: bool

Your code names it in one line, in the file that uses it

from fune.invest.isa_allowance import isa_allowance  # invest.isa-allowance@^1
impl/python.py · 103 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import re
from typing import Dict, Optional, Sequence

from .invest_isa_allowance_data import ISA_LIMITS, ISA_LIMITS_HISTORY, ISA_LIMITS_HORIZON  ← this capability’s own data, compiled from data/isa-limits.json into the same file by fune build
from .invest_isa_allowance_types import IsaAllowance, IsaLimitUse, IsaSubscription
from .money_amount import Money  ← from money.amount ^1.0.0 · built alongside by fune

_ISO_DATE = re.compile(r"[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])")
_KINDS = ("cash", "stocks-and-shares", "innovative-finance", "lifetime", "junior")


def _gbp(minor: int) -> Money:
    return Money(minor=minor, currency="GBP")


def _use(limit: int, used: int, room: int) -> IsaLimitUse:
    return IsaLimitUse(
        limit=_gbp(limit),
        used=_gbp(used),
        remaining=_gbp(max(0, min(limit - used, room))),
        exceeded=used > limit,
    )


def _is_date(value: object) -> bool:
    return isinstance(value, str) and _ISO_DATE.fullmatch(value) is not None


def isa_allowance(
    tax_year_date: str, subscriptions: Sequence[IsaSubscription], date_of_birth: Optional[str]
) -> IsaAllowance:
    """How much of each ISA subscription limit a saver has used in a UK tax
    year and how much is left.

    The Lifetime ISA limit and (from 2027/28, for savers under 65) the cash
    ISA limit sit inside the overall limit, so what is left of them is also
    capped by what is left overall: 18,000 in a stocks and shares ISA leaves
    2,000 for a Lifetime ISA, not 4,000. The Junior ISA limit is the child's
    own and is counted on its own.
    """
    if not _is_date(tax_year_date):
        raise ValueError('taxYearDate must be an ISO date (YYYY-MM-DD), received "%s"' % (tax_year_date,))
    year = next(
        (
            row
            for row in ISA_LIMITS
            if row.valid_from <= tax_year_date and (row.valid_to is None or tax_year_date <= row.valid_to)
        ),
        None,
    )
    if year is None:
        if ISA_LIMITS_HORIZON is not None and tax_year_date < ISA_LIMITS_HORIZON:
            raise ValueError(
                "no ISA limits for %s: this build was installed with history=%s, so it only carries limits "
                "from %s. Reinstall with history=full for earlier years."
                % (tax_year_date, ISA_LIMITS_HISTORY, ISA_LIMITS_HORIZON)
            )
        raise ValueError("no ISA limits for %s" % (tax_year_date,))
    end = year.valid_to

    used: Dict[str, int] = {kind: 0 for kind in _KINDS}
    for s in subscriptions:
        if s.kind not in _KINDS:
            raise ValueError(
                'kind must be cash, stocks-and-shares, innovative-finance, lifetime or junior, received "%s"' % (s.kind,)
            )
        if s.amount.currency != "GBP":
            raise ValueError("subscriptions must be in GBP, received %s" % (s.amount.currency,))
        minor = s.amount.minor
        if isinstance(minor, bool) or not isinstance(minor, int) or minor <= 0:
            raise ValueError("a subscription must be a whole number of pence greater than zero, received %s" % (minor,))
        if not _is_date(s.date) or s.date < year.valid_from or s.date > end:
            raise ValueError("the subscription on %s is outside the %s tax year" % (s.date, year.tax_year))
        used[s.kind] += minor

    # Cash has its own limit only for a saver under 65 for the whole tax year:
    # the higher limit applies from the start of the year they turn 65.
    cash_limit: Optional[int] = None
    if year.cash_under65 is not None:
        if date_of_birth is None:
            raise ValueError(
                "dateOfBirth is needed for %s, when the cash ISA limit depends on age" % (year.tax_year,)
            )
        if not _is_date(date_of_birth):
            raise ValueError('dateOfBirth must be an ISO date (YYYY-MM-DD), received "%s"' % (date_of_birth,))
        birthday65 = "%04d%s" % (int(date_of_birth[:4]) + 65, date_of_birth[4:])
        if birthday65 > end:
            cash_limit = year.cash_under65

    adult = used["cash"] + used["stocks-and-shares"] + used["innovative-finance"] + used["lifetime"]
    overall = _use(year.overall, adult, year.overall - adult)
    room = overall.remaining.minor
    lifetime = _use(year.lifetime, used["lifetime"], room)
    cash = None if cash_limit is None else _use(cash_limit, used["cash"], room)
    junior = _use(year.junior, used["junior"], year.junior - used["junior"])
    return IsaAllowance(
        tax_year=year.tax_year,
        overall=overall,
        lifetime=lifetime,
        cash=cash,
        junior=junior,
        exceeded=overall.exceeded or lifetime.exceeded or (cash is not None and cash.exceeded) or junior.exceeded,
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add invest.isa-allowance
Download for Python invest.isa-allowance-1.0.2-python.fune · 44,178 bytes sha256 addf19f7434e3143d8b5af815412ed1bf132f4dc3e9a70c45782eb5d4e5ee3cd

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./invest.isa-allowance-1.0.2-python.fune, or fetch it from a terminal with fune pull invest.isa-allowance@1.0.2:python.

The whole function, every language, is one file too: invest.isa-allowance-1.0.2.fune, 55,617 bytes, sha256 06a5b36689a7bd8fd074675a34bec845d691e192be1e38c70b6acc5c3efad5a2. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before invest.isa-allowance

after — your function gets the result and the arguments, and returns the final result.

# fune: after invest.isa-allowance

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace money.amount in invest.isa-allowance

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show invest.isa-allowance --steps.

# fune: step invest.isa-allowance after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
2025/26: £5,000 cash and £10,000 stocks and shares leaves £5,000 2025-10-01, subscriptions ×2, — → tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false
a full Lifetime ISA counts inside the £20,000 2025-10-01, subscriptions ×2, — → tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false
£18,000 elsewhere leaves £2,000 of Lifetime ISA room, not £4,000 2025-10-01, subscriptions ×1, — → tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false
over the overall limit by £1,000 2024-10-01, subscriptions ×2, — → tax year 2024/25, overall …, lifetime …, cash —, junior …, exceeded true
over the Lifetime ISA limit but inside the overall one 2024-10-01, subscriptions ×1, — → tax year 2024/25, overall …, lifetime …, cash —, junior …, exceeded true
Junior ISA is a separate allowance: £9,000 on top of a full adult £20,000 2023-10-01, subscriptions ×2, — → tax year 2023/24, overall …, lifetime …, cash —, junior …, exceeded false
a penny over the Junior ISA limit is exceeded 2023-10-01, subscriptions ×1, — → tax year 2023/24, overall …, lifetime …, cash —, junior …, exceeded true
2019/20 Junior ISA limit was £4,368 2019-04-06, subscriptions ×1, — → tax year 2019/20, overall …, lifetime …, cash —, junior …, exceeded false
2017/18, the first year of £20,000 and the Lifetime ISA 2018-04-05, subscriptions ×2, — → tax year 2017/18, overall …, lifetime …, cash —, junior …, exceeded false
no subscriptions: every allowance is free 2026-06-01, , — → tax year 2026/27, overall …, lifetime …, cash —, junior …, exceeded false
Show the other 20 tests
CaseArgumentsExpected
2027/28 under 65: cash is capped at £12,000 inside the £20,000 2027-06-01, subscriptions ×2, 1970-01-01 → tax year 2027/28, overall …, lifetime …, cash …, junior …, exceeded false
2027/28 under 65: £13,000 in cash breaks the cash limit though the overall one is fine 2027-06-01, subscriptions ×1, 1990-12-31 → tax year 2027/28, overall …, lifetime …, cash …, junior …, exceeded true
2027/28 under 65 with little in cash: cash room is £12,000 less cash used 2027-06-01, subscriptions ×2, 1990-12-31 → tax year 2027/28, overall …, lifetime …, cash …, junior …, exceeded false
2027/28 turning 65 during the year (1 March 2028): the whole £20,000 may be cash 2027-06-01, subscriptions ×1, 1963-03-01 → tax year 2027/28, overall …, lifetime …, cash —, junior …, exceeded false
2027/28 turning 65 on 6 April 2028, the day after the year ends: still under 65 2027-06-01, subscriptions ×1, 1963-04-06 → tax year 2027/28, overall …, lifetime …, cash …, junior …, exceeded true
before 2027/28 the date of birth is not needed and cash may take the whole allowance 2026-06-01, subscriptions ×1, — → tax year 2026/27, overall …, lifetime …, cash —, junior …, exceeded false
2027/28 needs the date of birth 2027-06-01, , — → error: dateOfBirth is needed for 2027/28
a subscription outside the tax year is refused 2025-10-01, subscriptions ×1, — → error: the subscription on 2025-04-05 is outside the 2025/26 tax year
years before 2017/18 are not covered 2016-10-01, , — → error: no ISA limits for 2016-10-01
years after 2027/28 are not covered 2028-04-06, , — → error: no ISA limits for 2028-04-06
a zero subscription is refused 2025-10-01, subscriptions ×1, — → error: a subscription must be a whole number of pence greater than zero
fractional pence are refused 2025-10-01, subscriptions ×1, — → error: a subscription must be a whole number of pence greater than zero
amounts must be in sterling 2025-10-01, subscriptions ×1, — → error: subscriptions must be in GBP
an unknown kind is refused 2025-10-01, subscriptions ×1, — → error: kind must be cash, stocks-and-shares, innovative-finance, lifetime or junior
a malformed date is refused 2025/10/01, , — → error: taxYearDate must be an ISO date
a tax year date with a trailing newline is refused 2025-10-01 , , — → error: taxYearDate must be an ISO date
a tax year date in Arabic-Indic digits is refused ٢٠٢٥-10-01, , — → error: taxYearDate must be an ISO date
a subscription date with a trailing newline is outside the tax year 2025-10-01, subscriptions ×1, — → error: the subscription on 2025-04-06 is outside the 2025/26 tax year
a date of birth with a trailing newline is refused 2027-06-01, , 1990-01-01 → error: dateOfBirth must be an ISO date
a date of birth in Arabic-Indic digits is refused 2027-06-01, , ١٩٩٠-01-01 → error: dateOfBirth must be an ISO date

More from the author

- **Limits inside limits.** A Lifetime ISA's £4,000, and from 2027/28 an under-65's £12,000 of cash, count towards the £20,000. So what is left of them is also capped by what is left overall: £18,000 in a stocks and shares ISA leaves £2,000 of Lifetime ISA room, not £4,000. - **Junior ISA is separate.** It is the child's allowance, counted on its own and never against the adult's. Pass one child's subscriptions (or leave them out). - **`exceeded`, not an error.** An over-subscription is a fact to report (HMRC repairs or voids the excess), so the function answers with the flags set rather than throwing. - **Each tax year is its own row** in `data/isa-limits.json`, so the label and the 6 April to 5 April bounds come from the data and a year the data does not cover (before 2017/18, after 2027/28) is refused rather than guessed.

## The 2027/28 cash limit

From 6 April 2027 the government has announced a cash ISA limit of £12,000 for savers under 65, inside an unchanged £20,000, with savers 65 or over able to put the full £20,000 in cash "from the start of the tax year in which an individual turns 65". Here that means: a saver whose 65th birthday falls on or before 5 April at the end of the tax year has no separate cash limit (`cash` is null); anyone younger has the £12,000 limit, and `dateOfBirth` is then required. A 29 February birthday is compared as the string "YYYY-02-29", which sorts correctly.

At 2026-09-23 this is announced policy (policy paper published 17 September 2026) with the amending ISA Regulations still in draft. The row is marked so in its note; if the final regulations differ, publish a new version with the corrected row.

## Limits

| Tax years | Overall | Lifetime ISA | Junior ISA | Cash, under 65 | |---|---|---|---|---| | 2017/18 | £20,000 | £4,000 | £4,128 | none | | 2018/19 | £20,000 | £4,000 | £4,260 | none | | 2019/20 | £20,000 | £4,000 | £4,368 | none | | 2020/21 to 2026/27 | £20,000 | £4,000 | £9,000 | none | | 2027/28 | £20,000 | £4,000 | £9,000 | £12,000 |

## Out of scope

Lifetime ISA age rules (open under 40, pay in until 50) and the government bonus; whether a cash Lifetime ISA counts towards the cash limit (it is not counted as cash here); transfers between ISAs, which are not subscriptions; flexible ISA withdrawals and replacements; the 2027 anti-circumvention rules.

## Sources

Read on 2026-09-23:

- GOV.UK, "Individual Savings Accounts (ISAs): How ISAs work" (£20,000 a year; Lifetime ISA £4,000 a year, inside it): https://www.gov.uk/individual-savings-accounts/how-isas-work - GOV.UK, "Junior Individual Savings Accounts (ISA)" (£9,000 in 2026/27): https://www.gov.uk/junior-individual-savings-accounts - HMRC, "Annual savings statistics: background and methodology" (Tables of adult and Junior ISA subscription limits by tax year, 2008/09 to 2026/27): https://www.gov.uk/government/statistics/annual-savings-statistics-2026/annual-savings-statistics-background-and-methodology - HMRC policy paper, "Cash Individual Savings Account (ISA) limit reduction", published 17 September 2026: https://www.gov.uk/government/publications/reduction-in-the-cash-individual-savings-account-isa-limit/cash-individual-savings-account-isa-limit-reduction - "ISA reform 2027: anti-circumvention rules factsheet" (the age 65 test): https://www.gov.uk/government/publications/fiscal-events-2026-factsheets/isa-reform-2027-anti-circumvention-rules-factsheet

1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in taxYearDate, dateOfBirth and subscription dates; adds tests.

## Before you rely on this

**Not professional advice.** This capability calculates investment figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have a tax adviser review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.

**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified tax adviser has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

## Notices

Contains public sector information licensed under the Open Government Licence v3.0 (https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).

1.0.2 marks it unreviewed and adds its attribution notices (NOTICE). The code and the tests are unchanged.

Files

PathBytes
NOTICE183
README.md5,246
data/isa-limits.json3,028
impl/python.py4,562
impl/rust.rs6,771
impl/typescript.ts4,250
vectors.json22,201