import re from typing import Dict, Optional, Sequence from .invest_isa_allowance_data import ISA_LIMITS, ISA_LIMITS_HISTORY, ISA_LIMITS_HORIZON from .invest_isa_allowance_types import IsaAllowance, IsaLimitUse, IsaSubscription from .money_amount import Money _ISO_DATE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|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.match(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, )