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