use super::funejson::Value; use super::invest_isa_allowance_data::{ISA_LIMITS, ISA_LIMITS_HISTORY, ISA_LIMITS_HORIZON}; use super::money_amount::{money_from_value, money_to_value, Money}; 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 = 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 = 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)) }