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,
};
}