Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::invest_isa_allowance_data::{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
use super::money_amount::{money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
const KINDS: [&str; 5] = ["cash", "stocks-and-shares", "innovative-finance", "lifetime", "junior"];
fn is_iso_date(value: &str) -> bool {
let b = value.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return false;
}
if !b.iter().enumerate().all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit()) {
return false;
}
let month = (b[5] - b'0') * 10 + (b[6] - b'0');
let day = (b[8] - b'0') * 10 + (b[9] - b'0');
(1..=12).contains(&month) && (1..=31).contains(&day)
}
fn gbp(minor: i64) -> Money {
Money {
minor,
currency: "GBP".to_string(),
}
}
fn limit_use(limit: i64, used: i64, room: i64) -> IsaLimitUse {
IsaLimitUse {
limit: gbp(limit),
used: gbp(used),
remaining: gbp((limit - used).min(room).max(0)),
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.
///
/// # Panics
/// Panics on a malformed date, a tax year the data does not cover, a bad
/// subscription, or a missing date of birth in a year that needs one.
pub fn isa_allowance(tax_year_date: &str, subscriptions: &[IsaSubscription], date_of_birth: Option<&str>) -> IsaAllowance {
if !is_iso_date(tax_year_date) {
panic!("taxYearDate must be an ISO date (YYYY-MM-DD), received \"{}\"", tax_year_date);
}
let year = ISA_LIMITS
.iter()
.find(|row| row.valid_from <= tax_year_date && row.valid_to.map(|to| tax_year_date <= to).unwrap_or(true));
let year = match year {
Some(y) => y,
None => {
if let Some(horizon) = ISA_LIMITS_HORIZON {
if tax_year_date < horizon {
panic!(
"no ISA limits for {}: this build was installed with history={}, so it only carries limits from {}. Reinstall with history=full for earlier years.",
tax_year_date, ISA_LIMITS_HISTORY, horizon
);
}
}
panic!("no ISA limits for {}", tax_year_date);
}
};
let end = year.valid_to.unwrap_or("9999-12-31");
let mut used = [0i64; 5];
for s in subscriptions {
let index = match KINDS.iter().position(|k| *k == s.kind) {
Some(i) => i,
None => panic!(
"kind must be cash, stocks-and-shares, innovative-finance, lifetime or junior, received \"{}\"",
s.kind
),
};
if s.amount.currency != "GBP" {
panic!("subscriptions must be in GBP, received {}", s.amount.currency);
}
if s.amount.minor <= 0 {
panic!(
"a subscription must be a whole number of pence greater than zero, received {}",
s.amount.minor
);
}
if !is_iso_date(&s.date) || s.date.as_str() < year.valid_from || s.date.as_str() > end {
panic!("the subscription on {} is outside the {} tax year", s.date, year.tax_year);
}
used[index] += 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 mut cash_limit: Option<i64> = None;
if let Some(under65) = year.cash_under65 {
let dob = match date_of_birth {
Some(d) => d,
None => panic!(
"dateOfBirth is needed for {}, when the cash ISA limit depends on age",
year.tax_year
),
};
if !is_iso_date(dob) {
panic!("dateOfBirth must be an ISO date (YYYY-MM-DD), received \"{}\"", dob);
}
let birth_year: i64 = dob[..4].parse().unwrap();
let birthday65 = format!("{:04}{}", birth_year + 65, &dob[4..]);
if birthday65.as_str() > end {
cash_limit = Some(under65);
}
}
let adult = used[0] + used[1] + used[2] + used[3];
let overall = limit_use(year.overall, adult, year.overall - adult);
let room = overall.remaining.minor;
let lifetime = limit_use(year.lifetime, used[3], room);
let cash = cash_limit.map(|limit| limit_use(limit, used[0], room));
let junior = limit_use(year.junior, used[4], year.junior - used[4]);
let exceeded =
overall.exceeded || lifetime.exceeded || cash.as_ref().map(|c| c.exceeded).unwrap_or(false) || junior.exceeded;
IsaAllowance {
tax_year: year.tax_year.to_string(),
overall,
lifetime,
cash,
junior,
exceeded,
}
}
pub fn isa_limit_use_to_value(u: &IsaLimitUse) -> Value {
Value::obj(vec![
("limit", money_to_value(&u.limit)),
("used", money_to_value(&u.used)),
("remaining", money_to_value(&u.remaining)),
("exceeded", Value::Bool(u.exceeded)),
])
}
pub fn isa_allowance_to_value(a: &IsaAllowance) -> Value {
Value::obj(vec![
("taxYear", Value::str(&a.tax_year)),
("overall", isa_limit_use_to_value(&a.overall)),
("lifetime", isa_limit_use_to_value(&a.lifetime)),
(
"cash",
match &a.cash {
Some(c) => isa_limit_use_to_value(c),
None => Value::Null,
},
),
("junior", isa_limit_use_to_value(&a.junior)),
("exceeded", Value::Bool(a.exceeded)),
])
}
pub fn isa_subscription_from_value(v: &Value) -> IsaSubscription {
// Refuse a fractional number of pence with the wording TypeScript and Python use.
if let Value::Float(f) = v.get("amount").get("minor") {
panic!("a subscription must be a whole number of pence greater than zero, received {}", f);
}
IsaSubscription {
date: v.get("date").as_str().to_string(),
kind: v.get("kind").as_str().to_string(),
amount: money_from_value(v.get("amount")),
}
}
pub fn fune_vector(args: &[Value]) -> Value {
let subscriptions: Vec<IsaSubscription> = args[1].as_arr().iter().map(isa_subscription_from_value).collect();
let dob = if args[2].is_null() { None } else { Some(args[2].as_str()) };
isa_allowance_to_value(&isa_allowance(args[0].as_str(), &subscriptions, dob))
}