use crate::report::{CapitalGainsReport, Term};
use chrono::Duration;
use rust_decimal::Decimal;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TaxBracket {
pub up_to: Option<Decimal>,
pub rate: Decimal,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TaxConfig {
pub jurisdiction: String,
pub long_term_threshold_days: i64,
pub short_term_rate: Decimal,
pub long_term_brackets: Vec<TaxBracket>,
}
impl Default for TaxConfig {
fn default() -> Self {
TaxConfig {
jurisdiction: "default".to_string(),
long_term_threshold_days: 365,
short_term_rate: Decimal::new(35, 2),
long_term_brackets: vec![
TaxBracket {
up_to: Some(Decimal::new(47025, 0)),
rate: Decimal::new(0, 0),
},
TaxBracket {
up_to: Some(Decimal::new(518900, 0)),
rate: Decimal::new(15, 2),
},
TaxBracket {
up_to: None,
rate: Decimal::new(20, 2),
},
],
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TaxEstimate {
pub short_term_gain: Decimal,
pub long_term_gain: Decimal,
pub short_term_tax: Decimal,
pub long_term_tax: Decimal,
pub total_tax: Decimal,
}
pub fn estimate(report: &CapitalGainsReport, config: &TaxConfig) -> TaxEstimate {
let mut short_gain = Decimal::ZERO;
let mut long_gain = Decimal::ZERO;
for r in &report.rows {
let is_long = match r.acquired_at {
Some(acq) => (r.disposed_at - acq) > Duration::days(config.long_term_threshold_days),
None => matches!(r.term, Some(Term::Long)),
};
if is_long {
long_gain += r.gain;
} else {
short_gain += r.gain;
}
}
let short_tax = if short_gain > Decimal::ZERO {
short_gain * config.short_term_rate
} else {
Decimal::ZERO
};
let long_tax = progressive_tax(long_gain.max(Decimal::ZERO), &config.long_term_brackets);
TaxEstimate {
short_term_gain: short_gain,
long_term_gain: long_gain,
short_term_tax: short_tax,
long_term_tax: long_tax,
total_tax: short_tax + long_tax,
}
}
fn progressive_tax(gain: Decimal, brackets: &[TaxBracket]) -> Decimal {
let mut tax = Decimal::ZERO;
let mut prev = Decimal::ZERO;
for b in brackets {
if prev >= gain {
break;
}
let ceiling = b.up_to.unwrap_or(gain);
let top = ceiling.min(gain);
let slice = top - prev;
if slice > Decimal::ZERO {
tax += slice * b.rate;
}
prev = ceiling;
if b.up_to.is_none() {
break;
}
}
tax
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::{CapitalGainsReport, RealizedGain, Term};
use chrono::{TimeZone, Utc};
use rust_decimal_macros::dec;
#[test]
fn default_config_is_us_preset() {
let c = TaxConfig::default();
assert_eq!(c.long_term_threshold_days, 365);
assert_eq!(c.short_term_rate, dec!(0.35));
assert_eq!(c.long_term_brackets.len(), 3);
assert_eq!(c.long_term_brackets[0].rate, dec!(0.0));
assert_eq!(c.long_term_brackets[2].up_to, None);
assert_eq!(c.long_term_brackets[2].rate, dec!(0.20));
}
fn row(acq_days_before: Option<i64>, term: Option<Term>, gain: Decimal) -> RealizedGain {
let disposed = Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap();
let acquired = acq_days_before.map(|d| disposed - chrono::Duration::days(d));
RealizedGain {
asset: "bitcoin".into(),
wallet: "w".into(),
disposed_at: disposed,
acquired_at: acquired,
quantity: dec!(1),
proceeds: dec!(0),
cost_basis: dec!(0),
gain,
term,
}
}
fn report(rows: Vec<RealizedGain>) -> CapitalGainsReport {
CapitalGainsReport {
tax_year: 2024,
rows,
short_term_gain: dec!(0),
long_term_gain: dec!(0),
total_gain: dec!(0),
}
}
#[test]
fn short_term_flat_rate_on_gains_only() {
let cfg = TaxConfig {
jurisdiction: "t".into(),
long_term_threshold_days: 365,
short_term_rate: dec!(0.30),
long_term_brackets: vec![],
};
let e = estimate(
&report(vec![row(Some(100), Some(Term::Short), dec!(1000))]),
&cfg,
);
assert_eq!(e.short_term_gain, dec!(1000));
assert_eq!(e.short_term_tax, dec!(300));
assert_eq!(e.long_term_tax, dec!(0));
}
#[test]
fn short_term_loss_no_tax() {
let cfg = TaxConfig {
jurisdiction: "t".into(),
long_term_threshold_days: 365,
short_term_rate: dec!(0.30),
long_term_brackets: vec![],
};
let e = estimate(
&report(vec![row(Some(10), Some(Term::Short), dec!(-500))]),
&cfg,
);
assert_eq!(e.short_term_tax, dec!(0));
}
#[test]
fn long_term_progressive_brackets() {
let cfg = TaxConfig {
jurisdiction: "t".into(),
long_term_threshold_days: 365,
short_term_rate: dec!(0.30),
long_term_brackets: vec![
TaxBracket {
up_to: Some(dec!(1000)),
rate: dec!(0.0),
},
TaxBracket {
up_to: Some(dec!(3000)),
rate: dec!(0.10),
},
TaxBracket {
up_to: None,
rate: dec!(0.20),
},
],
};
let e = estimate(
&report(vec![row(Some(400), Some(Term::Long), dec!(4000))]),
&cfg,
);
assert_eq!(e.long_term_gain, dec!(4000));
assert_eq!(e.long_term_tax, dec!(400)); }
#[test]
fn threshold_reclassifies_independent_of_row_term() {
let cfg = TaxConfig {
jurisdiction: "t".into(),
long_term_threshold_days: 500,
short_term_rate: dec!(0.30),
long_term_brackets: vec![TaxBracket {
up_to: None,
rate: dec!(0.10),
}],
};
let e = estimate(
&report(vec![row(Some(400), Some(Term::Long), dec!(1000))]),
&cfg,
);
assert_eq!(e.short_term_gain, dec!(1000)); assert_eq!(e.long_term_gain, dec!(0));
}
#[test]
fn average_method_no_acquired_falls_back_to_row_term() {
let cfg = TaxConfig {
jurisdiction: "t".into(),
long_term_threshold_days: 365,
short_term_rate: dec!(0.30),
long_term_brackets: vec![TaxBracket {
up_to: None,
rate: dec!(0.10),
}],
};
assert_eq!(
estimate(&report(vec![row(None, None, dec!(1000))]), &cfg).short_term_gain,
dec!(1000)
);
assert_eq!(
estimate(&report(vec![row(None, Some(Term::Long), dec!(1000))]), &cfg).long_term_tax,
dec!(100)
);
}
#[test]
fn empty_report_is_zero() {
assert_eq!(
estimate(&report(vec![]), &TaxConfig::default()).total_tax,
dec!(0)
);
}
}