Functional Weave
Code in Rust

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.

pub fn isa_allowance(tax_year_date: &str, subscriptions: &[IsaSubscription], date_of_birth: Option<&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 is a string in Rust, one of: "cash", "stocks-and-shares", "innovative-finance", "lifetime", "junior".
// Parameters take it as &str and results hold it as String.

/// One payment into an ISA.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IsaSubscription {
    pub date: String,
    pub kind: String,
    /// greater than zero
    pub amount: Money,
}

/// One limit and how much of it is used.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IsaLimitUse {
    pub limit: Money,
    pub used: Money,
    /// what can still go in: never below zero, and never more than the overall allowance has left
    pub remaining: Money,
    /// true when used is over the limit
    pub exceeded: bool,
}

/// Each limit for the tax year, and whether any is broken.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IsaAllowance {
    /// HMRC's label, 2026/27
    pub tax_year: String,
    /// every adult ISA together
    pub overall: IsaLimitUse,
    /// Lifetime ISA, inside the overall limit
    pub lifetime: IsaLimitUse,
    /// cash ISA, inside the overall limit; null in a year or for a saver with no separate cash limit
    pub cash: Option<IsaLimitUse>,
    /// Junior ISA, a separate allowance for the child
    pub junior: IsaLimitUse,
    /// true when any limit is exceeded
    pub exceeded: bool,
}

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

fune!(invest.isa-allowance@^1);  // then call isa_allowance(…)
impl/rust.rs · 176 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::invest_isa_allowance_data::{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
use super::money_amount::{money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

const KINDS: [&str; 5] = ["cash", "stocks-and-shares", "innovative-finance", "lifetime", "junior"];

fn is_iso_date(value: &str) -> bool {
    let b = value.as_bytes();
    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
        return false;
    }
    if !b.iter().enumerate().all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit()) {
        return false;
    }
    let month = (b[5] - b'0') * 10 + (b[6] - b'0');
    let day = (b[8] - b'0') * 10 + (b[9] - b'0');
    (1..=12).contains(&month) && (1..=31).contains(&day)
}

fn gbp(minor: i64) -> Money {
    Money {
        minor,
        currency: "GBP".to_string(),
    }
}

fn limit_use(limit: i64, used: i64, room: i64) -> IsaLimitUse {
    IsaLimitUse {
        limit: gbp(limit),
        used: gbp(used),
        remaining: gbp((limit - used).min(room).max(0)),
        exceeded: used > limit,
    }
}

/// 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.
///
/// # Panics
/// Panics on a malformed date, a tax year the data does not cover, a bad
/// subscription, or a missing date of birth in a year that needs one.
pub fn isa_allowance(tax_year_date: &str, subscriptions: &[IsaSubscription], date_of_birth: Option<&str>) -> IsaAllowance {
    if !is_iso_date(tax_year_date) {
        panic!("taxYearDate must be an ISO date (YYYY-MM-DD), received \"{}\"", tax_year_date);
    }
    let year = ISA_LIMITS
        .iter()
        .find(|row| row.valid_from <= tax_year_date && row.valid_to.map(|to| tax_year_date <= to).unwrap_or(true));
    let year = match year {
        Some(y) => y,
        None => {
            if let Some(horizon) = ISA_LIMITS_HORIZON {
                if tax_year_date < horizon {
                    panic!(
                        "no ISA limits for {}: this build was installed with history={}, so it only carries limits from {}. Reinstall with history=full for earlier years.",
                        tax_year_date, ISA_LIMITS_HISTORY, horizon
                    );
                }
            }
            panic!("no ISA limits for {}", tax_year_date);
        }
    };
    let end = year.valid_to.unwrap_or("9999-12-31");

    let mut used = [0i64; 5];
    for s in subscriptions {
        let index = match KINDS.iter().position(|k| *k == s.kind) {
            Some(i) => i,
            None => panic!(
                "kind must be cash, stocks-and-shares, innovative-finance, lifetime or junior, received \"{}\"",
                s.kind
            ),
        };
        if s.amount.currency != "GBP" {
            panic!("subscriptions must be in GBP, received {}", s.amount.currency);
        }
        if s.amount.minor <= 0 {
            panic!(
                "a subscription must be a whole number of pence greater than zero, received {}",
                s.amount.minor
            );
        }
        if !is_iso_date(&s.date) || s.date.as_str() < year.valid_from || s.date.as_str() > end {
            panic!("the subscription on {} is outside the {} tax year", s.date, year.tax_year);
        }
        used[index] += s.amount.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.
    let mut cash_limit: Option<i64> = None;
    if let Some(under65) = year.cash_under65 {
        let dob = match date_of_birth {
            Some(d) => d,
            None => panic!(
                "dateOfBirth is needed for {}, when the cash ISA limit depends on age",
                year.tax_year
            ),
        };
        if !is_iso_date(dob) {
            panic!("dateOfBirth must be an ISO date (YYYY-MM-DD), received \"{}\"", dob);
        }
        let birth_year: i64 = dob[..4].parse().unwrap();
        let birthday65 = format!("{:04}{}", birth_year + 65, &dob[4..]);
        if birthday65.as_str() > end {
            cash_limit = Some(under65);
        }
    }

    let adult = used[0] + used[1] + used[2] + used[3];
    let overall = limit_use(year.overall, adult, year.overall - adult);
    let room = overall.remaining.minor;
    let lifetime = limit_use(year.lifetime, used[3], room);
    let cash = cash_limit.map(|limit| limit_use(limit, used[0], room));
    let junior = limit_use(year.junior, used[4], year.junior - used[4]);
    let exceeded =
        overall.exceeded || lifetime.exceeded || cash.as_ref().map(|c| c.exceeded).unwrap_or(false) || junior.exceeded;
    IsaAllowance {
        tax_year: year.tax_year.to_string(),
        overall,
        lifetime,
        cash,
        junior,
        exceeded,
    }
}

pub fn isa_limit_use_to_value(u: &IsaLimitUse) -> Value {
    Value::obj(vec![
        ("limit", money_to_value(&u.limit)),
        ("used", money_to_value(&u.used)),
        ("remaining", money_to_value(&u.remaining)),
        ("exceeded", Value::Bool(u.exceeded)),
    ])
}

pub fn isa_allowance_to_value(a: &IsaAllowance) -> Value {
    Value::obj(vec![
        ("taxYear", Value::str(&a.tax_year)),
        ("overall", isa_limit_use_to_value(&a.overall)),
        ("lifetime", isa_limit_use_to_value(&a.lifetime)),
        (
            "cash",
            match &a.cash {
                Some(c) => isa_limit_use_to_value(c),
                None => Value::Null,
            },
        ),
        ("junior", isa_limit_use_to_value(&a.junior)),
        ("exceeded", Value::Bool(a.exceeded)),
    ])
}

pub fn isa_subscription_from_value(v: &Value) -> IsaSubscription {
    // Refuse a fractional number of pence with the wording TypeScript and Python use.
    if let Value::Float(f) = v.get("amount").get("minor") {
        panic!("a subscription must be a whole number of pence greater than zero, received {}", f);
    }
    IsaSubscription {
        date: v.get("date").as_str().to_string(),
        kind: v.get("kind").as_str().to_string(),
        amount: money_from_value(v.get("amount")),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    let subscriptions: Vec<IsaSubscription> = args[1].as_arr().iter().map(isa_subscription_from_value).collect();
    let dob = if args[2].is_null() { None } else { Some(args[2].as_str()) };
    isa_allowance_to_value(&isa_allowance(args[0].as_str(), &subscriptions, dob))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add invest.isa-allowance
Download for Rust invest.isa-allowance-1.0.2-rust.fune · 46,476 bytes sha256 4640ab9ad44e1932dc270eacc419ee8c6b70860de4bcb54f5961ea6512c3ecc2

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

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