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
isaAllowance(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,000isaAllowance(2025-10-01, subscriptions ×2, —)→ tax year 2025/26, overall …, lifetime …, cash —, junior …, exceeded false a full Lifetime ISA counts inside the £20,000isaAllowance(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.
export function isaAllowance(taxYearDate: string, subscriptions: readonly IsaSubscription[], dateOfBirth: string | null): IsaAllowance
| taxYearDate | 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 |
| dateOfBirth | 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
export type IsaKind = "cash" | "stocks-and-shares" | "innovative-finance" | "lifetime" | "junior";
/** One payment into an ISA. */
export interface IsaSubscription {
readonly date: string;
readonly kind: IsaKind;
/** greater than zero */
readonly amount: Money;
}
/** One limit and how much of it is used. */
export interface IsaLimitUse {
readonly limit: Money;
readonly used: Money;
/** what can still go in: never below zero, and never more than the overall allowance has left */
readonly remaining: Money;
/** true when used is over the limit */
readonly exceeded: boolean;
}
/** Each limit for the tax year, and whether any is broken. */
export interface IsaAllowance {
/** HMRC's label, 2026/27 */
readonly taxYear: string;
/** every adult ISA together */
readonly overall: IsaLimitUse;
/** Lifetime ISA, inside the overall limit */
readonly lifetime: IsaLimitUse;
/** cash ISA, inside the overall limit; null in a year or for a saver with no separate cash limit */
readonly cash: IsaLimitUse | null;
/** Junior ISA, a separate allowance for the child */
readonly junior: IsaLimitUse;
/** true when any limit is exceeded */
readonly exceeded: boolean;
}
Your code names it in one line, in the file that uses it
import { isaAllowance } from "#fune/invest.isa-allowance@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { type Money } from "./money_amount.ts"; ← from money.amount ^1.0.0 · built alongside by fune
import { ISA_LIMITS, ISA_LIMITS_HISTORY, ISA_LIMITS_HORIZON } from "./invest_isa_allowance_data.ts"; ← this capability’s own data, compiled from data/isa-limits.json into the same file by fune build
import { type IsaAllowance, type IsaLimitUse, type IsaSubscription } from "./invest_isa_allowance_types.ts";
const ISO_DATE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
const KINDS = ["cash", "stocks-and-shares", "innovative-finance", "lifetime", "junior"];
function gbp(minor: number): Money {
return { minor, currency: "GBP" };
}
function use(limit: number, used: number, room: number): IsaLimitUse {
return {
limit: gbp(limit),
used: gbp(used),
remaining: gbp(Math.max(0, Math.min(limit - used, room))),
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.
*/
export function isaAllowance(
taxYearDate: string,
subscriptions: readonly IsaSubscription[],
dateOfBirth: string | null
): IsaAllowance {
if (!ISO_DATE.test(taxYearDate)) {
throw new RangeError(`taxYearDate must be an ISO date (YYYY-MM-DD), received "${taxYearDate}"`);
}
const year = ISA_LIMITS.find((row) => row.validFrom <= taxYearDate && (row.validTo === null || taxYearDate <= row.validTo));
if (year === undefined) {
if (ISA_LIMITS_HORIZON !== null && taxYearDate < ISA_LIMITS_HORIZON) {
throw new RangeError(
`no ISA limits for ${taxYearDate}: this build was installed with history=${ISA_LIMITS_HISTORY}, ` +
`so it only carries limits from ${ISA_LIMITS_HORIZON}. Reinstall with history=full for earlier years.`
);
}
throw new RangeError(`no ISA limits for ${taxYearDate}`);
}
const end = year.validTo as string;
const used: Record<string, number> = { cash: 0, "stocks-and-shares": 0, "innovative-finance": 0, lifetime: 0, junior: 0 };
for (const s of subscriptions) {
if (!KINDS.includes(s.kind)) {
throw new RangeError(`kind must be cash, stocks-and-shares, innovative-finance, lifetime or junior, received "${s.kind}"`);
}
if (s.amount.currency !== "GBP") throw new RangeError(`subscriptions must be in GBP, received ${s.amount.currency}`);
if (!Number.isInteger(s.amount.minor) || s.amount.minor <= 0) {
throw new RangeError(`a subscription must be a whole number of pence greater than zero, received ${s.amount.minor}`);
}
if (!ISO_DATE.test(s.date) || s.date < year.validFrom || s.date > end) {
throw new RangeError(`the subscription on ${s.date} is outside the ${year.taxYear} tax year`);
}
used[s.kind] += 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 cashLimit: number | null = null;
if (year.cashUnder65 !== null) {
if (dateOfBirth === null) {
throw new RangeError(`dateOfBirth is needed for ${year.taxYear}, when the cash ISA limit depends on age`);
}
if (!ISO_DATE.test(dateOfBirth)) {
throw new RangeError(`dateOfBirth must be an ISO date (YYYY-MM-DD), received "${dateOfBirth}"`);
}
const birthday65 = `${String(Number(dateOfBirth.slice(0, 4)) + 65).padStart(4, "0")}${dateOfBirth.slice(4)}`;
if (birthday65 > end) cashLimit = year.cashUnder65;
}
const adult = used.cash + used["stocks-and-shares"] + used["innovative-finance"] + used.lifetime;
const overall = use(year.overall, adult, year.overall - adult);
const room = overall.remaining.minor;
const lifetime = use(year.lifetime, used.lifetime, room);
const cash = cashLimit === null ? null : use(cashLimit, used.cash, room);
const junior = use(year.junior, used.junior, year.junior - used.junior);
return {
taxYear: year.taxYear,
overall,
lifetime,
cash,
junior,
exceeded: overall.exceeded || lifetime.exceeded || (cash !== null && cash.exceeded) || junior.exceeded,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript 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
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./invest.isa-allowance-1.0.2-typescript.fune, or fetch it from a terminal with fune pull invest.isa-allowance@1.0.2:typescript.
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.
| 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.
## 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
| Path | Bytes |
|---|---|
| NOTICE | 183 |
| README.md | 5,246 |
| 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 |