Functional Weave
Code in TypeScript

invest.isa-allowance@1.0.0

impl/typescript.ts

4,250 bytes · the TypeScript 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 { 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,
  };
}