Functional Weave
Code in TypeScript

invest.isa-allowance@1.0.2

impl/python.py

4,562 bytes · the Python implementation · view raw

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