use crate::conventions::{Sat, TaxDate, Usd, SATS_PER_BTC};
use crate::donation::DonationDetails;
use crate::event::{BasisSource, DisposeKind};
use crate::identity::{EventId, LotId, WalletId};
use crate::state::{LedgerState, RemovalKind, RemovalLeg, Term};
use crate::tax::tables::QUALIFIED_APPRAISAL_THRESHOLD;
use rust_decimal::Decimal;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Form8949Part {
ShortTerm,
LongTerm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Form8949Box {
C,
F,
I,
L,
}
pub const DIGITAL_ASSET_8949_FIRST_YEAR: i32 = 2025;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Form8949Row {
pub part: Form8949Part,
pub box_: Form8949Box,
pub box_needs_review: bool,
pub description: String,
pub date_acquired: TaxDate,
pub date_sold: TaxDate,
pub proceeds: Usd,
pub cost_basis: Usd,
pub adjustment_code: String,
pub adjustment_amount: Usd,
pub gain: Usd,
pub wallet: WalletId,
pub disposition_kind: DisposeKind,
}
fn btc_amount_description(sat: Sat) -> String {
let btc = Decimal::from(sat) / Decimal::from(SATS_PER_BTC);
format!("{btc:.8} BTC")
}
pub fn form_8949(state: &LedgerState, year: i32) -> Vec<Form8949Row> {
let mut keyed: Vec<(
TaxDate,
&crate::identity::EventId,
&crate::identity::LotId,
Form8949Row,
)> = Vec::new();
for d in state
.disposals
.iter()
.filter(|d| d.disposed_at.year() == year)
{
for leg in &d.legs {
let da = year >= DIGITAL_ASSET_8949_FIRST_YEAR;
let (part, box_) = match leg.term {
Term::ShortTerm => (
Form8949Part::ShortTerm,
if da { Form8949Box::I } else { Form8949Box::C },
),
Term::LongTerm => (
Form8949Part::LongTerm,
if da { Form8949Box::L } else { Form8949Box::F },
),
};
let row = Form8949Row {
part,
box_,
box_needs_review: matches!(leg.wallet, WalletId::Exchange { .. }),
description: btc_amount_description(leg.sat),
date_acquired: leg.acquired_at,
date_sold: d.disposed_at,
proceeds: leg.proceeds,
cost_basis: leg.basis,
adjustment_code: String::new(),
adjustment_amount: Usd::ZERO,
gain: leg.gain,
wallet: leg.wallet.clone(),
disposition_kind: d.kind,
};
keyed.push((d.disposed_at, &d.event, &leg.lot_id, row));
}
}
keyed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(b.1)).then(a.2.cmp(b.2)));
keyed.into_iter().map(|(_, _, _, r)| r).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ScheduleDPart {
pub proceeds: Usd,
pub cost_basis: Usd,
pub gain: Usd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ScheduleDTotals {
pub st: ScheduleDPart,
pub lt: ScheduleDPart,
}
pub fn schedule_d(state: &LedgerState, year: i32) -> ScheduleDTotals {
let mut totals = ScheduleDTotals::default();
for d in state
.disposals
.iter()
.filter(|d| d.disposed_at.year() == year)
{
for leg in &d.legs {
let part = match leg.term {
Term::ShortTerm => &mut totals.st,
Term::LongTerm => &mut totals.lt,
};
part.proceeds += leg.proceeds;
part.cost_basis += leg.basis;
part.gain += leg.gain;
}
}
totals
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Form8283Section {
A,
B,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Form8283HowAcquired {
Purchased,
Gift,
Other,
Review,
}
pub fn how_acquired_from(bs: BasisSource) -> Form8283HowAcquired {
use Form8283HowAcquired as H;
match bs {
BasisSource::ExchangeProvided | BasisSource::ComputedFromCost => H::Purchased,
BasisSource::GiftCarryover | BasisSource::GiftFmvFallback => H::Gift,
BasisSource::FmvAtIncome => H::Other,
BasisSource::CarriedFromTransfer
| BasisSource::SafeHarborAllocated
| BasisSource::ReconstructedPerWallet
| BasisSource::SelfTransferInbound
| BasisSource::EstimatedConservative => H::Review,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Form8283Row {
pub section: Option<Form8283Section>,
pub description: String,
pub how_acquired: Form8283HowAcquired,
pub date_acquired: TaxDate,
pub date_contributed: TaxDate,
pub cost_basis: Usd,
pub fmv: Usd,
pub claimed_deduction: Option<Usd>,
pub fmv_method: String,
pub donee: String,
pub appraiser: String,
pub needs_review: bool,
pub details: Option<DonationDetails>,
}
pub fn year_donation_deduction(state: &LedgerState, year: i32) -> Usd {
state
.removals
.iter()
.filter(|r| r.kind == RemovalKind::Donation && r.removed_at.year() == year)
.filter_map(|r| r.claimed_deduction)
.sum()
}
pub fn form_8283(
state: &LedgerState,
year: i32,
details: &BTreeMap<EventId, DonationDetails>,
) -> Vec<Form8283Row> {
let year_agg_deduction: Usd = year_donation_deduction(state, year);
let section = if year_agg_deduction > QUALIFIED_APPRAISAL_THRESHOLD {
Form8283Section::B
} else {
Form8283Section::A
};
let carrier_fmv_method = match section {
Form8283Section::B => "qualified appraisal".to_string(),
Form8283Section::A => String::new(),
};
let mut keyed: Vec<(TaxDate, &EventId, &LotId, Form8283Row)> = Vec::new();
for r in state
.removals
.iter()
.filter(|r| r.kind == RemovalKind::Donation && r.removed_at.year() == year)
{
let carrier_idx: Option<usize> = r
.legs
.iter()
.enumerate()
.min_by(|(_, a): &(usize, &RemovalLeg), (_, b)| a.lot_id.cmp(&b.lot_id))
.map(|(i, _)| i);
for (i, leg) in r.legs.iter().enumerate() {
let is_first = Some(i) == carrier_idx;
let d = if is_first {
details.get(&r.event)
} else {
None
};
let row = Form8283Row {
section: is_first.then_some(section),
description: btc_amount_description(leg.sat),
how_acquired: how_acquired_from(leg.basis_source),
date_acquired: leg.acquired_at,
date_contributed: r.removed_at,
cost_basis: leg.basis,
fmv: leg.fmv_at_transfer,
claimed_deduction: if is_first { r.claimed_deduction } else { None },
fmv_method: if is_first {
d.and_then(|d| d.fmv_method_override.clone())
.unwrap_or_else(|| carrier_fmv_method.clone())
} else {
String::new()
},
donee: if is_first {
d.map(|d| d.donee_name.clone())
.unwrap_or_else(|| r.donee.clone().unwrap_or_default())
} else {
String::new()
},
appraiser: if is_first {
d.map(|d| d.appraiser_name.clone()).unwrap_or_default()
} else {
String::new()
},
needs_review: if is_first {
d.is_none_or(|d| !d.is_review_complete(section))
} else {
true
},
details: if is_first { d.cloned() } else { None },
};
keyed.push((r.removed_at, &r.event, &leg.lot_id, row));
}
}
keyed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(b.1)).then(a.2.cmp(b.2)));
keyed.into_iter().map(|(_, _, _, r)| r).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_is_exact_decimal_8dp() {
assert_eq!(btc_amount_description(53_000_000), "0.53000000 BTC");
assert_eq!(btc_amount_description(1), "0.00000001 BTC");
assert_eq!(btc_amount_description(SATS_PER_BTC), "1.00000000 BTC");
assert_eq!(btc_amount_description(12_345_678), "0.12345678 BTC");
}
}