invest.isa-allowance
ISA allowance used and remaining for a UK tax year: the overall limit, Lifetime ISA, cash ISA and Junior ISA.
1.0.1 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 30 tests, run in TypeScript, Python and Rust.
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,000isa_allowance(2025-10-01, subscriptions ×2, —)→ tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false a full Lifetime ISA counts inside the £20,000isa_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_date | date | any date in the tax year |
| subscriptions | IsaSubscription[] | every payment into an ISA in that tax year, in GBP; Junior ISA payments are for the child |
| date_of_birth | date? | the saver's; needed only in a year whose cash ISA limit depends on age, otherwise may be null |
| returns | IsaAllowance |
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(…)
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
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./invest.isa-allowance-1.0.1-rust.fune, or fetch it from a terminal with fune pull invest.isa-allowance@1.0.1:rust.
The whole function, every language, is one file too: invest.isa-allowance-1.0.1.fune, 54,072 bytes, sha256 58fad51ab456b08fb84ac13edb2a52cea5f53ba0c617b68d26867efee117b943. 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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.
Files
| Path | Bytes |
|---|---|
| README.md | 3,949 |
| data/isa-limits.json | 3,028 |
| impl/python.py | 4,562 |
| impl/rust.rs | 6,771 |
| impl/typescript.ts | 4,250 |
| vectors.json | 22,201 |