use crate::conventions::{round_cents, Usd};
use crate::event::{IncomeKind, LedgerEvent};
use crate::state::{Blocker, BlockerKind, LedgerState, Severity, Term};
use crate::tax::tables::{
loss_limit, niit_threshold, LtcgBreakpoints, OrdinarySchedule, TaxTables, NIIT_RATE,
};
use crate::tax::types::{Carryforward, MarginalRates, TaxOutcome, TaxProfile, TaxResult};
pub fn ordinary_tax_on(schedule: &OrdinarySchedule, taxable: Usd) -> Usd {
if taxable <= Usd::ZERO {
return Usd::ZERO;
}
let b = &schedule.brackets;
let mut tax = Usd::ZERO;
for (i, br) in b.iter().enumerate() {
if taxable <= br.lower {
break;
}
let upper = b.get(i + 1).map(|n| n.lower).unwrap_or(taxable); let span_top = if taxable < upper { taxable } else { upper };
tax += (span_top - br.lower) * br.rate;
}
round_cents(tax)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrefSplit {
pub at_0: Usd,
pub at_15: Usd,
pub at_20: Usd,
pub tax: Usd,
}
pub fn preferential_tax(bp: &LtcgBreakpoints, bottom: Usd, pref: Usd) -> PrefSplit {
let z = Usd::ZERO;
if pref <= z {
return PrefSplit {
at_0: z,
at_15: z,
at_20: z,
tax: z,
};
}
let bottom = if bottom < z { z } else { bottom };
let top = bottom + pref;
let clamp = |v: Usd| if v < z { z } else { v };
let at_0 = {
let room = clamp(bp.max_zero - bottom);
if room < pref {
room
} else {
pref
}
};
let lower15 = if bottom > bp.max_zero {
bottom
} else {
bp.max_zero
};
let upper15 = if top < bp.max_fifteen {
top
} else {
bp.max_fifteen
};
let at_15 = clamp(upper15 - lower15);
let at_20 = pref - at_0 - at_15; let tax = round_cents(at_15 * dec_15() + at_20 * dec_20());
PrefSplit {
at_0,
at_15,
at_20,
tax,
}
}
fn dec_15() -> Usd {
rust_decimal_macros::dec!(0.15)
}
fn dec_20() -> Usd {
rust_decimal_macros::dec!(0.20)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapNet {
pub st_net: Usd,
pub lt_net: Usd,
pub ordinary_gain: Usd,
pub preferential_gain: Usd,
pub loss_deduction: Usd,
pub st_carry: Usd,
pub lt_carry: Usd,
}
pub fn net_1222(
crypto_st: Usd,
crypto_lt: Usd,
other_lt: Usd,
cf_short: Usd,
cf_long: Usd,
loss_limit: Usd,
) -> CapNet {
let z = Usd::ZERO;
let st_net = crypto_st - cf_short;
let lt_net = crypto_lt + other_lt - cf_long;
let (st2, lt2) = match (st_net >= z, lt_net >= z) {
(true, true) | (false, false) => (st_net, lt_net), (true, false) => {
if -lt_net <= st_net {
(st_net + lt_net, z)
} else {
(z, st_net + lt_net)
}
}
(false, true) => {
if -st_net <= lt_net {
(z, lt_net + st_net)
} else {
(st_net + lt_net, z)
}
}
};
let ordinary_gain = if st2 > z { st2 } else { z };
let preferential_gain = if lt2 > z { lt2 } else { z };
let net_st_loss = if st2 < z { -st2 } else { z };
let net_lt_loss = if lt2 < z { -lt2 } else { z };
let net_loss = net_st_loss + net_lt_loss;
let loss_deduction = if net_loss < loss_limit {
net_loss
} else {
loss_limit
};
let absorbed_st = if net_st_loss < loss_deduction {
net_st_loss
} else {
loss_deduction
};
let absorbed_lt = loss_deduction - absorbed_st;
CapNet {
st_net,
lt_net,
ordinary_gain,
preferential_gain,
loss_deduction,
st_carry: net_st_loss - absorbed_st,
lt_carry: net_lt_loss - absorbed_lt,
}
}
pub fn compute_tax_year(
events: &[LedgerEvent],
state: &LedgerState,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> TaxOutcome {
let _ = events;
if let Some(b) = first_hard_blocker(state) {
let evt = b
.event
.as_ref()
.map(|e| e.canonical())
.unwrap_or_else(|| "-".into());
return TaxOutcome::NotComputable(Blocker {
kind: BlockerKind::TaxYearNotComputable,
event: b.event.clone(), detail: format!(
"year {year} not computable: unresolved Hard blocker [{:?}] {} :: {}",
b.kind, evt, b.detail
),
});
}
let Some(table) = tables.table_for(year) else {
return TaxOutcome::NotComputable(Blocker {
kind: BlockerKind::TaxTableMissing,
event: None,
detail: format!("no bundled tax table for {year}"),
});
};
let Some(profile) = profile else {
return TaxOutcome::NotComputable(Blocker {
kind: BlockerKind::TaxProfileMissing,
event: None,
detail: format!("no tax_profile set for {year}"),
});
};
let status = profile.filing_status;
let limit = loss_limit(status);
let sched = table.ordinary_for(status);
let bp = *table.ltcg_for(status);
let thr = niit_threshold(status);
let mut crypto_st = Usd::ZERO;
let mut crypto_lt = Usd::ZERO;
for d in state
.disposals
.iter()
.filter(|d| d.disposed_at.year() == year)
{
for leg in &d.legs {
match leg.term {
Term::ShortTerm => crypto_st += leg.gain,
Term::LongTerm => crypto_lt += leg.gain,
}
}
}
let crypto_ord: Usd = state
.income_recognized
.iter()
.filter(|i| i.recognized_at.year() == year)
.map(|i| i.usd_fmv)
.sum();
let interest_nii: Usd = state
.income_recognized
.iter()
.filter(|i| i.recognized_at.year() == year && i.kind == IncomeKind::Interest)
.map(|i| i.usd_fmv)
.sum();
let cf = profile.capital_loss_carryforward_in;
let with = net_1222(
crypto_st,
crypto_lt,
profile.other_net_capital_gain,
cf.short,
cf.long,
limit,
);
let without = net_1222(
Usd::ZERO,
Usd::ZERO,
profile.other_net_capital_gain,
cf.short,
cf.long,
limit,
);
let qd = profile.qualified_dividends_and_other_pref_income;
let bottom_with =
profile.ordinary_taxable_income + crypto_ord + with.ordinary_gain - with.loss_deduction;
let bottom_without =
profile.ordinary_taxable_income + without.ordinary_gain - without.loss_deduction;
let ord_with = ordinary_tax_on(sched, bottom_with);
let ord_without = ordinary_tax_on(sched, bottom_without);
let pref_with_split = preferential_tax(&bp, bottom_with, qd + with.preferential_gain);
let pref_with = pref_with_split.tax;
let pref_without = preferential_tax(&bp, bottom_without, qd + without.preferential_gain).tax;
let nii_with =
qd + with.ordinary_gain + with.preferential_gain - with.loss_deduction + interest_nii;
let nii_without =
qd + without.ordinary_gain + without.preferential_gain - without.loss_deduction;
let crypto_agi = (with.ordinary_gain + with.preferential_gain - with.loss_deduction)
- (without.ordinary_gain + without.preferential_gain - without.loss_deduction)
+ crypto_ord;
let magi_without = profile.magi_excluding_crypto;
let magi_with = magi_without + crypto_agi;
let niit = |nii: Usd, magi: Usd| -> Usd {
let over = if magi > thr { magi - thr } else { Usd::ZERO };
let capped = if nii < over { nii } else { over };
let base = if capped > Usd::ZERO {
capped
} else {
Usd::ZERO
};
round_cents(base * NIIT_RATE)
};
let niit_with = niit(nii_with, magi_with);
let niit_without = niit(nii_without, magi_without);
let total = (ord_with + pref_with + niit_with) - (ord_without + pref_without + niit_without);
let top = bottom_with + qd + with.preferential_gain;
let marginal_rates = MarginalRates {
ordinary: marginal_ordinary_rate(sched, bottom_with),
ltcg: if top <= bp.max_zero {
Usd::ZERO
} else if top <= bp.max_fifteen {
dec_15()
} else {
dec_20()
},
niit_applies: niit_with > niit_without,
};
TaxOutcome::Computed(TaxResult {
st_net: with.st_net,
lt_net: with.lt_net,
ordinary_from_crypto: crypto_ord,
ltcg_tax: pref_with - pref_without, niit: niit_with - niit_without, loss_deduction: with.loss_deduction, carryforward_out: Carryforward {
short: with.st_carry,
long: with.lt_carry,
},
total_federal_tax_attributable: total, marginal_rates,
pref_split: pref_with_split, bottom_with, })
}
fn marginal_ordinary_rate(sched: &OrdinarySchedule, taxable: Usd) -> Usd {
let mut r = Usd::ZERO;
for br in &sched.brackets {
if taxable > br.lower {
r = br.rate;
} else {
break;
}
}
r
}
pub fn carryforward_consistency(
prior_out: Option<&Carryforward>,
this_in: &Carryforward,
) -> Option<String> {
match prior_out {
Some(p) if p != this_in => Some(format!(
"carryforward_in (short {} / long {}) does not match prior-year carryforward_out \
(short {} / long {}) — verify your prior return",
this_in.short, this_in.long, p.short, p.long
)),
_ => None,
}
}
fn first_hard_blocker(state: &LedgerState) -> Option<&Blocker> {
state
.blockers
.iter()
.find(|b| b.kind.severity() == Severity::Hard)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tax::tables::{LtcgBreakpoints, OrdinaryBracket, OrdinarySchedule};
use rust_decimal_macros::dec;
fn sched() -> OrdinarySchedule {
OrdinarySchedule {
brackets: vec![
OrdinaryBracket {
lower: dec!(0),
rate: dec!(0.10),
},
OrdinaryBracket {
lower: dec!(10000),
rate: dec!(0.20),
},
OrdinaryBracket {
lower: dec!(40000),
rate: dec!(0.30),
},
],
}
}
#[test]
fn ordinary_tax_sums_marginal_brackets_exactly() {
assert_eq!(ordinary_tax_on(&sched(), dec!(0)), dec!(0.00));
assert_eq!(ordinary_tax_on(&sched(), dec!(10000)), dec!(1000.00));
assert_eq!(ordinary_tax_on(&sched(), dec!(25000)), dec!(4000.00));
assert_eq!(ordinary_tax_on(&sched(), dec!(50000)), dec!(10000.00));
}
fn bp() -> LtcgBreakpoints {
LtcgBreakpoints {
max_zero: dec!(48350),
max_fifteen: dec!(533400),
}
}
#[test]
fn preferential_zero_then_fifteen() {
let s = preferential_tax(&bp(), dec!(40000), dec!(20000));
assert_eq!(s.at_0, dec!(8350));
assert_eq!(s.at_15, dec!(11650));
assert_eq!(s.at_20, dec!(0));
assert_eq!(s.tax, dec!(1747.50));
}
#[test]
fn preferential_fifteen_then_twenty() {
let s = preferential_tax(&bp(), dec!(500000), dec!(100000));
assert_eq!(s.at_0, dec!(0));
assert_eq!(s.at_15, dec!(33400));
assert_eq!(s.at_20, dec!(66600));
assert_eq!(s.tax, dec!(18330.00));
}
#[test]
fn preferential_all_zero_when_under_max_zero() {
let s = preferential_tax(&bp(), dec!(10000), dec!(20000));
assert_eq!(s.at_0, dec!(20000));
assert_eq!(s.tax, dec!(0.00));
}
#[test]
fn preferential_zero_pref_is_zero_tax() {
assert_eq!(
preferential_tax(&bp(), dec!(100000), dec!(0)).tax,
dec!(0.00)
);
}
}
#[cfg(test)]
mod net_tests {
use super::*;
use rust_decimal_macros::dec;
fn lim() -> Usd {
dec!(3000)
}
#[test]
fn both_gains_no_crossnet() {
let n = net_1222(dec!(5000), dec!(8000), dec!(0), dec!(0), dec!(0), lim());
assert_eq!(n.ordinary_gain, dec!(5000));
assert_eq!(n.preferential_gain, dec!(8000));
assert_eq!(n.loss_deduction, dec!(0));
}
#[test]
fn within_character_then_crossnet_order() {
let n = net_1222(dec!(10000), dec!(-4000), dec!(0), dec!(0), dec!(0), lim());
assert_eq!(n.st_net, dec!(10000));
assert_eq!(n.lt_net, dec!(-4000));
assert_eq!(n.ordinary_gain, dec!(6000));
assert_eq!(n.preferential_gain, dec!(0));
assert_eq!(n.loss_deduction, dec!(0));
}
#[test]
fn st_loss_offsets_lt_gain_to_preferential() {
let n = net_1222(dec!(-3000), dec!(9000), dec!(0), dec!(0), dec!(0), lim());
assert_eq!(n.ordinary_gain, dec!(0));
assert_eq!(n.preferential_gain, dec!(6000));
}
#[test]
fn loss_year_3k_limit_st_first_carryforward() {
let n = net_1222(dec!(-5000), dec!(-2000), dec!(0), dec!(0), dec!(0), lim());
assert_eq!(n.loss_deduction, dec!(3000));
assert_eq!(n.st_carry, dec!(2000)); assert_eq!(n.lt_carry, dec!(2000));
}
#[test]
fn loss_limit_is_mfs_1500() {
let n = net_1222(dec!(-5000), dec!(0), dec!(0), dec!(0), dec!(0), dec!(1500));
assert_eq!(n.loss_deduction, dec!(1500));
assert_eq!(n.st_carry, dec!(3500));
assert_eq!(n.lt_carry, dec!(0));
}
#[test]
fn multi_year_carryforward_preserves_character() {
let y1 = net_1222(dec!(-5000), dec!(-2000), dec!(0), dec!(0), dec!(0), lim());
let y2 = net_1222(
dec!(0),
dec!(10000),
dec!(0),
y1.st_carry,
y1.lt_carry,
lim(),
);
assert_eq!(y2.preferential_gain, dec!(6000));
assert_eq!(y2.ordinary_gain, dec!(0));
assert_eq!(y2.loss_deduction, dec!(0));
}
#[test]
fn st_loss_only_3k_all_st_character() {
let n = net_1222(dec!(-10000), dec!(0), dec!(0), dec!(0), dec!(0), lim());
assert_eq!(n.loss_deduction, dec!(3000));
assert_eq!(n.st_carry, dec!(7000));
assert_eq!(n.lt_carry, dec!(0));
}
}
#[cfg(test)]
mod consistency_tests {
use super::*;
use crate::tax::types::Carryforward;
use rust_decimal_macros::dec;
#[test]
fn carryforward_match_is_silent() {
let c = Carryforward {
short: dec!(2000),
long: dec!(2000),
};
assert_eq!(carryforward_consistency(Some(&c), &c), None);
}
#[test]
fn carryforward_mismatch_warns() {
let prior = Carryforward {
short: dec!(2000),
long: dec!(2000),
};
let declared = Carryforward {
short: dec!(0),
long: dec!(2000),
};
assert!(carryforward_consistency(Some(&prior), &declared)
.unwrap()
.contains("does not match"));
}
#[test]
fn no_prior_is_silent() {
assert_eq!(
carryforward_consistency(None, &Carryforward::default()),
None
);
}
}