Functional Weave
Code in Rust

invest.isa-allowance@1.0.0

impl/rust.rs

6,771 bytes · the Rust 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.

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