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,
)