Functional Weave
Code in Rust

invest.capital-gains-tax@1.0.1

impl/rust.rs

8,982 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::dates_uk_tax_year::uk_tax_year;  ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
use super::invest_capital_gains_tax_data::{
    CGT_RATES, CGT_RATES_HISTORY, CGT_RATES_HORIZON, CGT_YEARS, CGT_YEARS_HISTORY, CGT_YEARS_HORIZON,
};
use super::money_amount::{money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_apply_rate::apply_rate;  ← from money.apply-rate ^1.0.0 · built alongside by fune

/// Gains that share a pair of rates; they are interchangeable for every step below.
struct Bucket {
    lower: i64,
    higher: i64,
    amount: i64,
}

fn gbp(minor: i64) -> Money {
    Money {
        minor,
        currency: "GBP".to_string(),
    }
}

fn check_money(value: &Money, name: &str) {
    if value.currency != "GBP" {
        panic!("{} must be in GBP, received {}", name, value.currency);
    }
}

fn pruned(history: &str, horizon: Option<&str>, date: &str) -> String {
    match horizon {
        Some(h) if date < h => format!(
            ": this build was installed with history={}, so it only carries rules from {}. Reinstall with history=full for earlier years.",
            history, h
        ),
        _ => String::new(),
    }
}

fn rate_for(disposal: &CgtDisposal) -> (i64, i64) {
    for rule in CGT_RATES {
        if rule.kind != disposal.kind || disposal.date.as_str() < rule.valid_from {
            continue;
        }
        if let Some(to) = rule.valid_to {
            if disposal.date.as_str() > to {
                continue;
            }
        }
        return (rule.lower_basis_points, rule.higher_basis_points);
    }
    panic!(
        "no CGT rate for {} gains on {}{}",
        disposal.kind,
        disposal.date,
        pruned(CGT_RATES_HISTORY, CGT_RATES_HORIZON, &disposal.date)
    );
}

/// Capital Gains Tax for an individual for one UK tax year.
///
/// The year's losses come off its gains in full, even when that wastes the
/// annual exempt amount; losses brought forward only bring the net gains down
/// to the annual exempt amount. Losses and the exempt amount go against the
/// gains charged at the highest rate first, and what taxable income leaves of
/// the basic rate band goes to the gains whose two rates are furthest apart:
/// that is the "most beneficial" order TCGA 1992 s1F and s1K allow.
///
/// # Panics
/// Panics on a tax year or disposal date the rules do not cover, a disposal
/// outside the tax year, amounts not in GBP, or negative income or losses
/// brought forward.
pub fn capital_gains_tax(
    tax_year_date: &str,
    disposals: &[CgtDisposal],
    taxable_income: &Money,
    losses_brought_forward: &Money,
) -> CgtResult {
    let year = uk_tax_year(tax_year_date);
    let rules = CGT_YEARS
        .iter()
        .find(|row| row.valid_from <= year.start.as_str() && row.valid_to.map(|to| year.start.as_str() <= to).unwrap_or(true));
    let rules = match rules {
        Some(r) => r,
        None => panic!(
            "no CGT rules for the {} tax year{}",
            year.label,
            pruned(CGT_YEARS_HISTORY, CGT_YEARS_HORIZON, &year.start)
        ),
    };
    check_money(taxable_income, "taxableIncome");
    check_money(losses_brought_forward, "lossesBroughtForward");
    if taxable_income.minor < 0 {
        panic!("taxableIncome must not be negative, received {}", taxable_income.minor);
    }
    if losses_brought_forward.minor < 0 {
        panic!("lossesBroughtForward must not be negative, received {}", losses_brought_forward.minor);
    }

    let mut buckets: Vec<Bucket> = Vec::new();
    let mut gains: i64 = 0;
    let mut losses: i64 = 0;
    for disposal in disposals {
        check_money(&disposal.gain, "gain");
        if disposal.kind != "residential" && disposal.kind != "other" {
            panic!("kind must be residential or other, received \"{}\"", disposal.kind);
        }
        if disposal.date < year.start || disposal.date > year.end {
            panic!("the disposal on {} is outside the {} tax year", disposal.date, year.label);
        }
        let (lower, higher) = rate_for(disposal);
        if disposal.gain.minor <= 0 {
            losses -= disposal.gain.minor;
            continue;
        }
        gains += disposal.gain.minor;
        match buckets.iter_mut().find(|b| b.lower == lower && b.higher == higher) {
            Some(bucket) => bucket.amount += disposal.gain.minor,
            None => buckets.push(Bucket {
                lower,
                higher,
                amount: disposal.gain.minor,
            }),
        }
    }

    let net = gains - losses;
    let annual_exempt_amount = rules.annual_exempt_amount;
    let mut brought_forward_used = 0;
    let mut exempt_used = 0;
    let mut taxable = 0;
    let mut carried = losses_brought_forward.minor;
    if net > 0 {
        brought_forward_used = losses_brought_forward.minor.min((net - annual_exempt_amount).max(0));
        exempt_used = annual_exempt_amount.min(net - brought_forward_used);
        taxable = net - brought_forward_used - exempt_used;
        carried -= brought_forward_used;
    } else {
        carried += -net;
    }

    // Deductions against the gains taxed hardest first.
    let mut deductions = gains - taxable;
    buckets.sort_by(|a, b| b.higher.cmp(&a.higher).then(b.lower.cmp(&a.lower)));
    for bucket in buckets.iter_mut() {
        let used = bucket.amount.min(deductions);
        bucket.amount -= used;
        deductions -= used;
    }

    // The basic rate band where it saves the most: the widest gap between the two rates.
    let band_available = (rules.basic_rate_band - taxable_income.minor).max(0);
    let mut band = band_available;
    buckets.sort_by(|a, b| (b.higher - b.lower).cmp(&(a.higher - a.lower)).then(b.higher.cmp(&a.higher)));
    let mut charged: Vec<(i64, i64)> = Vec::new();
    let mut charge = |rate: i64, amount: i64| {
        if amount > 0 {
            match charged.iter_mut().find(|(r, _)| *r == rate) {
                Some(entry) => entry.1 += amount,
                None => charged.push((rate, amount)),
            }
        }
    };
    for bucket in &buckets {
        let lower = bucket.amount.min(band);
        band -= lower;
        charge(bucket.lower, lower);
        charge(bucket.higher, bucket.amount - lower);
    }
    charged.sort_by_key(|(rate, _)| *rate);

    let lines: Vec<CgtLine> = charged
        .iter()
        .map(|(rate, amount)| CgtLine {
            rate_basis_points: *rate,
            gains: gbp(*amount),
            tax: apply_rate(&gbp(*amount), *rate, "down"),
        })
        .collect();
    let tax: i64 = lines.iter().map(|line| line.tax.minor).sum();

    CgtResult {
        tax_year: year.label.clone(),
        gains: gbp(gains),
        losses: gbp(losses),
        losses_brought_forward_used: gbp(brought_forward_used),
        annual_exempt_amount: gbp(annual_exempt_amount),
        annual_exempt_amount_used: gbp(exempt_used),
        taxable_gains: gbp(taxable),
        basic_rate_band_available: gbp(band_available),
        lines,
        tax: gbp(tax),
        losses_carried_forward: gbp(carried),
    }
}

pub fn cgt_line_to_value(line: &CgtLine) -> Value {
    Value::obj(vec![
        ("rateBasisPoints", Value::Int(line.rate_basis_points)),
        ("gains", money_to_value(&line.gains)),
        ("tax", money_to_value(&line.tax)),
    ])
}

pub fn cgt_result_to_value(r: &CgtResult) -> Value {
    Value::obj(vec![
        ("taxYear", Value::str(&r.tax_year)),
        ("gains", money_to_value(&r.gains)),
        ("losses", money_to_value(&r.losses)),
        ("lossesBroughtForwardUsed", money_to_value(&r.losses_brought_forward_used)),
        ("annualExemptAmount", money_to_value(&r.annual_exempt_amount)),
        ("annualExemptAmountUsed", money_to_value(&r.annual_exempt_amount_used)),
        ("taxableGains", money_to_value(&r.taxable_gains)),
        ("basicRateBandAvailable", money_to_value(&r.basic_rate_band_available)),
        ("lines", Value::Arr(r.lines.iter().map(cgt_line_to_value).collect())),
        ("tax", money_to_value(&r.tax)),
        ("lossesCarriedForward", money_to_value(&r.losses_carried_forward)),
    ])
}

/// Refuse a fractional number of pence with the wording TypeScript and Python use.
fn whole_money(v: &Value, name: &str) -> Money {
    if let Value::Float(f) = v.get("minor") {
        if f.fract() != 0.0 {
            panic!("{} must be a whole number of pence, received {}", name, f);
        }
    }
    money_from_value(v)
}

pub fn cgt_disposal_from_value(v: &Value) -> CgtDisposal {
    CgtDisposal {
        date: v.get("date").as_str().to_string(),
        kind: v.get("kind").as_str().to_string(),
        gain: whole_money(v.get("gain"), "gain"),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    let disposals: Vec<CgtDisposal> = args[1].as_arr().iter().map(cgt_disposal_from_value).collect();
    cgt_result_to_value(&capital_gains_tax(
        args[0].as_str(),
        &disposals,
        &whole_money(&args[2], "taxableIncome"),
        &whole_money(&args[3], "lossesBroughtForward"),
    ))
}