use btctax_core::conventions::{TaxDate, Usd};
use btctax_core::event::{BasisSource, DisposeKind};
use btctax_core::forms::{
form_8283, form_8949, schedule_d, Form8283HowAcquired, Form8283Section, Form8949Box,
Form8949Part,
};
use btctax_core::identity::{EventId, LotId, WalletId};
use btctax_core::state::{
Disposal, DisposalLeg, GiftZone, LedgerState, Removal, RemovalKind, RemovalLeg, Term,
};
use btctax_core::DonationDetails;
use rust_decimal_macros::dec;
use std::collections::BTreeMap;
use time::macros::date;
fn exch() -> WalletId {
WalletId::Exchange {
provider: "cb".into(),
account: "m".into(),
}
}
fn cold() -> WalletId {
WalletId::SelfCustody {
label: "cold".into(),
}
}
fn lot(origin_seq: u64, split: u32) -> LotId {
LotId {
origin_event_id: EventId::decision(origin_seq),
split_sequence: split,
}
}
fn base_leg() -> DisposalLeg {
DisposalLeg {
lot_id: lot(0, 0),
sat: 100,
proceeds: dec!(0),
basis: dec!(0),
gain: dec!(0),
term: Term::ShortTerm,
basis_source: BasisSource::ComputedFromCost,
gift_zone: None,
acquired_at: date!(2025 - 01 - 01),
wallet: exch(),
pseudo: false,
}
}
fn disposal(seq: u64, disposed_at: TaxDate, kind: DisposeKind, legs: Vec<DisposalLeg>) -> Disposal {
Disposal {
event: EventId::decision(seq),
kind,
disposed_at,
legs,
fee_mini_disposition: false,
}
}
fn state(disposals: Vec<Disposal>) -> LedgerState {
LedgerState {
disposals,
..Default::default()
}
}
#[test]
fn st_leg_is_part_i_box_c_and_lt_leg_is_part_ii_box_f() {
let st = state(vec![
disposal(
1,
date!(2025 - 03 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
term: Term::ShortTerm,
..base_leg()
}],
),
disposal(
2,
date!(2025 - 04 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
term: Term::LongTerm,
..base_leg()
}],
),
]);
let rows = form_8949(&st, 2025);
assert_eq!(rows.len(), 2);
let st_row = rows
.iter()
.find(|r| r.part == Form8949Part::ShortTerm)
.unwrap();
let lt_row = rows
.iter()
.find(|r| r.part == Form8949Part::LongTerm)
.unwrap();
assert_eq!(st_row.box_, Form8949Box::C);
assert_eq!(lt_row.box_, Form8949Box::F);
}
#[test]
fn description_is_exact_btc_amount_8dp() {
let st = state(vec![disposal(
1,
date!(2025 - 06 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
sat: 53_000_000,
..base_leg()
}],
)]);
let rows = form_8949(&st, 2025);
assert_eq!(rows[0].description, "0.53000000 BTC");
}
#[test]
fn row_fields_match_the_leg() {
let st = state(vec![disposal(
1,
date!(2025 - 09 - 15),
DisposeKind::Spend,
vec![DisposalLeg {
proceeds: dec!(100.50),
basis: dec!(60.00),
gain: dec!(40.50),
acquired_at: date!(2024 - 02 - 20),
term: Term::LongTerm,
..base_leg()
}],
)]);
let r = &form_8949(&st, 2025)[0];
assert_eq!(r.date_acquired, date!(2024 - 02 - 20));
assert_eq!(r.date_sold, date!(2025 - 09 - 15));
assert_eq!(r.proceeds, dec!(100.50));
assert_eq!(r.cost_basis, dec!(60.00));
assert_eq!(r.gain, dec!(40.50));
assert_eq!(r.disposition_kind, DisposeKind::Spend);
assert_eq!(r.adjustment_code, "");
assert_eq!(r.adjustment_amount, Usd::ZERO);
}
#[test]
fn multi_leg_disposal_spanning_st_and_lt_yields_two_rows_ordered_by_lot() {
let st = state(vec![disposal(
1,
date!(2025 - 05 - 01),
DisposeKind::Sell,
vec![
DisposalLeg {
lot_id: lot(0, 1),
term: Term::ShortTerm,
gain: dec!(5),
..base_leg()
},
DisposalLeg {
lot_id: lot(0, 0),
term: Term::LongTerm,
gain: dec!(9),
..base_leg()
},
],
)]);
let rows = form_8949(&st, 2025);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].part, Form8949Part::LongTerm);
assert_eq!(rows[0].gain, dec!(9));
assert_eq!(rows[1].part, Form8949Part::ShortTerm);
assert_eq!(rows[1].gain, dec!(5));
}
#[test]
fn no_gain_no_loss_gift_leg_row_present_with_zero_gain_and_blank_adjustments() {
let st = state(vec![disposal(
1,
date!(2025 - 08 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
proceeds: dec!(80.00),
basis: dec!(80.00), gain: dec!(0),
gift_zone: Some(GiftZone::NoGainNoLoss),
..base_leg()
}],
)]);
let rows = form_8949(&st, 2025);
assert_eq!(rows.len(), 1, "the NGNL disposition IS a reported row");
assert_eq!(rows[0].gain, Usd::ZERO);
assert_eq!(rows[0].proceeds, rows[0].cost_basis);
assert_eq!(rows[0].adjustment_code, "");
assert_eq!(rows[0].adjustment_amount, Usd::ZERO);
}
#[test]
fn year_filter_excludes_out_of_year_disposals() {
let st = state(vec![
disposal(
1,
date!(2024 - 12 - 31),
DisposeKind::Sell,
vec![base_leg()],
),
disposal(
2,
date!(2025 - 06 - 01),
DisposeKind::Sell,
vec![base_leg()],
),
disposal(
3,
date!(2026 - 01 - 01),
DisposeKind::Sell,
vec![base_leg()],
),
]);
let rows = form_8949(&st, 2025);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].date_sold, date!(2025 - 06 - 01));
}
#[test]
fn deterministic_ordering_by_date_then_event_then_lot() {
let st = state(vec![
disposal(
2,
date!(2025 - 06 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
sat: 300_000_000, ..base_leg()
}],
),
disposal(
1,
date!(2025 - 03 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
sat: 100_000_000, ..base_leg()
}],
),
disposal(
3,
date!(2025 - 03 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
sat: 200_000_000, ..base_leg()
}],
),
]);
let rows = form_8949(&st, 2025);
let order: Vec<&str> = rows.iter().map(|r| r.description.as_str()).collect();
assert_eq!(
order,
vec!["1.00000000 BTC", "2.00000000 BTC", "3.00000000 BTC"]
);
}
#[test]
fn exchange_wallet_flags_box_needs_review_self_custody_does_not() {
let st = state(vec![
disposal(
1,
date!(2025 - 03 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
lot_id: lot(1, 0),
wallet: exch(),
..base_leg()
}],
),
disposal(
2,
date!(2025 - 04 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
lot_id: lot(2, 0),
wallet: cold(),
..base_leg()
}],
),
]);
let rows = form_8949(&st, 2025);
let ex = rows.iter().find(|r| r.wallet == exch()).unwrap();
let sc = rows.iter().find(|r| r.wallet == cold()).unwrap();
assert!(ex.box_needs_review, "exchange disposition must be flagged");
assert!(!sc.box_needs_review, "self-custody must NOT be flagged");
assert_eq!(ex.box_, Form8949Box::C);
assert_eq!(sc.box_, Form8949Box::C);
}
#[test]
fn schedule_d_part_totals_hand_derived_golden() {
let st = state(vec![
disposal(
9,
date!(2024 - 12 - 31),
DisposeKind::Sell,
vec![DisposalLeg {
proceeds: dec!(999),
basis: dec!(1),
gain: dec!(998),
term: Term::ShortTerm,
..base_leg()
}],
),
disposal(
1,
date!(2025 - 03 - 01),
DisposeKind::Sell,
vec![
DisposalLeg {
proceeds: dec!(100.00),
basis: dec!(60.00),
gain: dec!(40.00),
term: Term::ShortTerm,
..base_leg()
},
DisposalLeg {
lot_id: lot(0, 1),
proceeds: dec!(50.00),
basis: dec!(30.00),
gain: dec!(20.00),
term: Term::ShortTerm,
..base_leg()
},
],
),
disposal(
2,
date!(2025 - 07 - 01),
DisposeKind::Sell,
vec![
DisposalLeg {
proceeds: dec!(200.00),
basis: dec!(150.00),
gain: dec!(50.00),
term: Term::LongTerm,
..base_leg()
},
DisposalLeg {
lot_id: lot(0, 1),
proceeds: dec!(10.00),
basis: dec!(40.00),
gain: dec!(-30.00),
term: Term::LongTerm,
..base_leg()
},
],
),
]);
let sd = schedule_d(&st, 2025);
assert_eq!(sd.st.proceeds, dec!(150.00));
assert_eq!(sd.st.cost_basis, dec!(90.00));
assert_eq!(sd.st.gain, dec!(60.00));
assert_eq!(sd.lt.proceeds, dec!(210.00));
assert_eq!(sd.lt.cost_basis, dec!(190.00));
assert_eq!(sd.lt.gain, dec!(20.00));
}
#[test]
fn schedule_d_year_filter() {
let st = state(vec![
disposal(
1,
date!(2024 - 06 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
proceeds: dec!(500),
basis: dec!(100),
gain: dec!(400),
term: Term::ShortTerm,
..base_leg()
}],
),
disposal(
2,
date!(2025 - 06 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
proceeds: dec!(70),
basis: dec!(30),
gain: dec!(40),
term: Term::ShortTerm,
..base_leg()
}],
),
]);
let sd = schedule_d(&st, 2025);
assert_eq!(sd.st.gain, dec!(40)); assert_eq!(sd.st.proceeds, dec!(70));
}
#[test]
fn schedule_d_empty_year_is_all_zero() {
let st = state(vec![disposal(
1,
date!(2024 - 06 - 01),
DisposeKind::Sell,
vec![DisposalLeg {
gain: dec!(400),
..base_leg()
}],
)]);
let sd = schedule_d(&st, 2099);
assert_eq!(sd.st.proceeds, Usd::ZERO);
assert_eq!(sd.st.cost_basis, Usd::ZERO);
assert_eq!(sd.st.gain, Usd::ZERO);
assert_eq!(sd.lt.proceeds, Usd::ZERO);
assert_eq!(sd.lt.cost_basis, Usd::ZERO);
assert_eq!(sd.lt.gain, Usd::ZERO);
}
#[test]
fn form_8949_rows_aggregate_to_schedule_d_totals() {
let st = state(vec![disposal(
1,
date!(2025 - 03 - 01),
DisposeKind::Sell,
vec![
DisposalLeg {
lot_id: lot(0, 0),
proceeds: dec!(100),
basis: dec!(60),
gain: dec!(40),
term: Term::ShortTerm,
..base_leg()
},
DisposalLeg {
lot_id: lot(0, 1),
proceeds: dec!(200),
basis: dec!(150),
gain: dec!(50),
term: Term::LongTerm,
..base_leg()
},
],
)]);
let rows = form_8949(&st, 2025);
let sd = schedule_d(&st, 2025);
let st_gain: Usd = rows
.iter()
.filter(|r| r.part == Form8949Part::ShortTerm)
.map(|r| r.gain)
.sum();
let lt_gain: Usd = rows
.iter()
.filter(|r| r.part == Form8949Part::LongTerm)
.map(|r| r.gain)
.sum();
assert_eq!(st_gain, sd.st.gain);
assert_eq!(lt_gain, sd.lt.gain);
}
fn base_removal_leg() -> RemovalLeg {
RemovalLeg {
lot_id: lot(0, 0),
sat: 100,
basis: dec!(0),
fmv_at_transfer: dec!(0),
term: Term::LongTerm,
basis_source: BasisSource::ComputedFromCost,
acquired_at: date!(2025 - 01 - 01),
pseudo: false,
}
}
fn donation(
seq: u64,
removed_at: TaxDate,
claimed_deduction: Usd,
legs: Vec<RemovalLeg>,
) -> Removal {
Removal {
event: EventId::decision(seq),
kind: RemovalKind::Donation,
removed_at,
legs,
appraisal_required: false,
donor_acquired_at: None,
claimed_deduction: Some(claimed_deduction),
donee: None,
}
}
fn gift(seq: u64, removed_at: TaxDate, legs: Vec<RemovalLeg>) -> Removal {
Removal {
event: EventId::decision(seq),
kind: RemovalKind::Gift,
removed_at,
legs,
appraisal_required: false,
donor_acquired_at: None,
claimed_deduction: None,
donee: None,
}
}
fn state_removals(removals: Vec<Removal>) -> LedgerState {
LedgerState {
removals,
..Default::default()
}
}
#[test]
fn form8283_section_a_when_deduction_at_or_below_5k() {
let st = state_removals(vec![donation(
1,
date!(2025 - 03 - 01),
dec!(5000), vec![base_removal_leg()],
)]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].section, Some(Form8283Section::A));
assert!(rows[0].needs_review);
}
#[test]
fn form8283_section_b_when_deduction_above_5k_and_needs_review() {
let st = state_removals(vec![donation(
1,
date!(2025 - 03 - 01),
dec!(5000.01), vec![base_removal_leg()],
)]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows[0].section, Some(Form8283Section::B));
assert!(
rows[0].needs_review,
"Section B needs review when no details are provided"
);
}
#[test]
fn form8283_how_acquired_mapping_incl_income_other_and_ambiguous_review() {
let cases = [
(
BasisSource::ExchangeProvided,
Form8283HowAcquired::Purchased,
),
(
BasisSource::ComputedFromCost,
Form8283HowAcquired::Purchased,
),
(BasisSource::GiftCarryover, Form8283HowAcquired::Gift),
(BasisSource::GiftFmvFallback, Form8283HowAcquired::Gift),
(BasisSource::FmvAtIncome, Form8283HowAcquired::Other),
(
BasisSource::CarriedFromTransfer,
Form8283HowAcquired::Review,
),
(
BasisSource::SafeHarborAllocated,
Form8283HowAcquired::Review,
),
(
BasisSource::ReconstructedPerWallet,
Form8283HowAcquired::Review,
),
];
for (bs, expect) in cases {
let st = state_removals(vec![donation(
1,
date!(2025 - 03 - 01),
dec!(1000),
vec![RemovalLeg {
basis_source: bs,
..base_removal_leg()
}],
)]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(
rows[0].how_acquired, expect,
"basis_source {bs:?} must map to how_acquired {expect:?}"
);
}
}
#[test]
fn form8283_claimed_deduction_first_leg_only_no_sum_double_count() {
let st = state_removals(vec![donation(
1,
date!(2025 - 03 - 01),
dec!(52000), vec![
RemovalLeg {
lot_id: lot(0, 1),
..base_removal_leg()
},
RemovalLeg {
lot_id: lot(0, 0),
..base_removal_leg()
},
],
)]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].section, Some(Form8283Section::B));
assert_eq!(rows[0].claimed_deduction, Some(dec!(52000)));
assert_eq!(rows[1].section, None, "subsequent leg carries no section");
assert_eq!(
rows[1].claimed_deduction, None,
"subsequent leg carries no deduction (no SUM double-count)"
);
let sum: Usd = rows.iter().filter_map(|r| r.claimed_deduction).sum();
assert_eq!(sum, dec!(52000));
}
#[test]
fn form8283_unmodeled_user_input_fields_and_fmv_method_honest() {
let st = state_removals(vec![donation(
1,
date!(2025 - 03 - 01),
dec!(60000),
vec![base_removal_leg()],
)]);
let r = &form_8283(&st, 2025, &BTreeMap::new())[0];
assert_eq!(r.donee, "", "None donee → empty string (unwrap_or_default)");
assert_eq!(r.appraiser, "");
assert_eq!(
r.fmv_method, "qualified appraisal",
"Section B carrier must have fmv_method = 'qualified appraisal'"
);
assert!(r.needs_review);
let st_a = state_removals(vec![donation(
1,
date!(2025 - 03 - 01),
dec!(1000),
vec![base_removal_leg()],
)]);
let r_a = &form_8283(&st_a, 2025, &BTreeMap::new())[0];
assert_eq!(
r_a.donee, "",
"None donee → empty string (unwrap_or_default)"
);
assert_eq!(r_a.appraiser, "");
assert_eq!(
r_a.fmv_method, "",
"Section A carrier must have fmv_method = '' (FMV method not modeled)"
);
assert!(r_a.needs_review);
}
#[test]
fn form8283_donee_populated_from_removal_on_carrier_row() {
let with_donee = Removal {
donee: Some("Charity X".to_string()),
..donation(
1,
date!(2025 - 03 - 01),
dec!(1000),
vec![base_removal_leg()],
)
};
let st = state_removals(vec![with_donee]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].donee, "Charity X",
"carrier row must carry the donee label from removal.donee"
);
let no_donee = donation(
2,
date!(2025 - 04 - 01),
dec!(1000),
vec![base_removal_leg()],
);
let st2 = state_removals(vec![no_donee]);
let rows2 = form_8283(&st2, 2025, &BTreeMap::new());
assert_eq!(
rows2[0].donee, "",
"None donee → empty string on carrier row"
);
let multi = Removal {
donee: Some("Charity X".to_string()),
..donation(
3,
date!(2025 - 05 - 01),
dec!(52000), vec![
RemovalLeg {
lot_id: lot(0, 1),
..base_removal_leg()
}, RemovalLeg {
lot_id: lot(0, 0),
..base_removal_leg()
}, ],
)
};
let st3 = state_removals(vec![multi]);
let rows3 = form_8283(&st3, 2025, &BTreeMap::new());
assert_eq!(rows3.len(), 2);
assert_eq!(
rows3[0].donee, "Charity X",
"carrier row must have the donee label"
);
assert_eq!(
rows3[1].donee, "",
"non-carrier row must have empty donee (first-leg convention)"
);
}
#[test]
fn form8283_dates_and_amounts_match_the_leg_and_removal() {
let st = state_removals(vec![donation(
1,
date!(2025 - 09 - 15),
dec!(4000),
vec![RemovalLeg {
basis: dec!(1000.00),
fmv_at_transfer: dec!(4000.00),
acquired_at: date!(2024 - 02 - 20),
sat: 53_000_000,
..base_removal_leg()
}],
)]);
let r = &form_8283(&st, 2025, &BTreeMap::new())[0];
assert_eq!(r.date_acquired, date!(2024 - 02 - 20));
assert_eq!(r.date_contributed, date!(2025 - 09 - 15));
assert_eq!(r.cost_basis, dec!(1000.00));
assert_eq!(r.fmv, dec!(4000.00));
assert_eq!(r.description, "0.53000000 BTC");
}
#[test]
fn form8283_year_filter_excludes_out_of_year_donations() {
let st = state_removals(vec![
donation(
1,
date!(2024 - 12 - 31),
dec!(9000),
vec![base_removal_leg()],
),
donation(
2,
date!(2025 - 06 - 01),
dec!(9000),
vec![base_removal_leg()],
),
donation(
3,
date!(2026 - 01 - 01),
dec!(9000),
vec![base_removal_leg()],
),
]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].date_contributed, date!(2025 - 06 - 01));
}
#[test]
fn form8283_deterministic_ordering_by_date_then_event_then_lot() {
let st = state_removals(vec![
donation(
2,
date!(2025 - 06 - 01),
dec!(1000),
vec![RemovalLeg {
sat: 300_000_000, ..base_removal_leg()
}],
),
donation(
1,
date!(2025 - 03 - 01),
dec!(1000),
vec![RemovalLeg {
sat: 100_000_000, ..base_removal_leg()
}],
),
donation(
3,
date!(2025 - 03 - 01),
dec!(1000),
vec![RemovalLeg {
sat: 200_000_000, ..base_removal_leg()
}],
),
]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
let order: Vec<&str> = rows.iter().map(|r| r.description.as_str()).collect();
assert_eq!(
order,
vec!["1.00000000 BTC", "2.00000000 BTC", "3.00000000 BTC"]
);
}
#[test]
fn form8283_gift_produces_no_row() {
let st = state_removals(vec![
gift(1, date!(2025 - 03 - 01), vec![base_removal_leg()]),
donation(
2,
date!(2025 - 04 - 01),
dec!(1000),
vec![base_removal_leg()],
),
]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(
rows.len(),
1,
"only the Donation yields a row; the Gift does not"
);
assert_eq!(rows[0].date_contributed, date!(2025 - 04 - 01));
}
#[test]
fn form8283_year_aggregate_triggers_section_b_when_sum_exceeds_5k() {
let st = state_removals(vec![
donation(
1,
date!(2025 - 02 - 01),
dec!(2000),
vec![base_removal_leg()],
),
donation(
2,
date!(2025 - 05 - 01),
dec!(2000),
vec![base_removal_leg()],
),
donation(
3,
date!(2025 - 08 - 01),
dec!(2000),
vec![base_removal_leg()],
),
]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 3);
for (i, row) in rows.iter().enumerate() {
assert_eq!(
row.section,
Some(Form8283Section::B),
"row {i}: expected Section B (aggregate $6,000 > $5,000); got {:?}",
row.section
);
assert_eq!(
row.fmv_method, "qualified appraisal",
"row {i}: Section B carrier must have fmv_method = 'qualified appraisal'"
);
}
}
#[test]
fn form8283_year_aggregate_under_threshold_gives_section_a() {
let st = state_removals(vec![
donation(
1,
date!(2025 - 03 - 01),
dec!(1000),
vec![base_removal_leg()],
),
donation(
2,
date!(2025 - 07 - 01),
dec!(1500),
vec![base_removal_leg()],
),
]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 2);
for (i, row) in rows.iter().enumerate() {
assert_eq!(
row.section,
Some(Form8283Section::A),
"row {i}: expected Section A (aggregate $2,500 ≤ $5,000); got {:?}",
row.section
);
assert_eq!(
row.fmv_method, "",
"row {i}: Section A carrier must have fmv_method = '' (honest gap)"
);
}
}
#[test]
fn form8283_single_large_donation_section_b_regression() {
let st = state_removals(vec![donation(
1,
date!(2025 - 04 - 01),
dec!(8000),
vec![base_removal_leg()],
)]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].section,
Some(Form8283Section::B),
"single $8,000 donation must be Section B"
);
assert_eq!(rows[0].fmv_method, "qualified appraisal");
}
#[test]
fn form8283_exact_5000_aggregate_is_section_a_not_b() {
let st = state_removals(vec![
donation(
1,
date!(2025 - 01 - 01),
dec!(3000),
vec![base_removal_leg()],
),
donation(
2,
date!(2025 - 06 - 01),
dec!(2000),
vec![base_removal_leg()],
),
]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 2);
for (i, row) in rows.iter().enumerate() {
assert_eq!(
row.section,
Some(Form8283Section::A),
"row {i}: aggregate exactly $5,000 must be Section A (§170(f)(11)(C) is '>'); \
got {:?}",
row.section
);
}
}
#[test]
fn form8283_gift_fmv_excluded_from_donation_aggregate() {
let st = state_removals(vec![
gift(
1,
date!(2025 - 02 - 01),
vec![RemovalLeg {
fmv_at_transfer: dec!(10000),
..base_removal_leg()
}],
),
donation(
2,
date!(2025 - 05 - 01),
dec!(3000),
vec![base_removal_leg()],
),
]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].section,
Some(Form8283Section::A),
"Donation $3,000 must be Section A when Gift FMV is excluded from the aggregate; \
got {:?}",
rows[0].section
);
assert_eq!(
rows[0].fmv_method, "",
"Section A carrier must have fmv_method = '' (honest gap)"
);
}
#[test]
fn form8283_fmv_method_carrier_only_subsequent_legs_empty() {
let st = state_removals(vec![donation(
1,
date!(2025 - 03 - 01),
dec!(52000), vec![
RemovalLeg {
lot_id: lot(0, 1),
..base_removal_leg()
}, RemovalLeg {
lot_id: lot(0, 0),
..base_removal_leg()
}, ],
)]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].section, Some(Form8283Section::B));
assert_eq!(
rows[0].fmv_method, "qualified appraisal",
"carrier row must have fmv_method = 'qualified appraisal'"
);
assert_eq!(rows[1].section, None, "non-carrier: section must be None");
assert_eq!(
rows[1].fmv_method, "",
"non-carrier row must have fmv_method = '' (carrier convention)"
);
}
fn full_section_b_details() -> DonationDetails {
DonationDetails {
donee_name: "Test Charity".into(),
donee_address: Some("123 Main St, Anytown USA".into()),
donee_ein: Some("12-3456789".into()),
appraiser_name: "Test Appraiser".into(),
appraiser_address: Some("456 Appraiser Ave".into()),
appraiser_tin: Some("987-65-4321".into()),
appraiser_ptin: None,
appraiser_qualifications: Some("Certified bitcoin appraiser, 10 yrs exp".into()),
appraisal_date: Some(date!(2025 - 06 - 01)),
fmv_method_override: None,
}
}
fn skeletal_details() -> DonationDetails {
DonationDetails {
donee_name: "Test Charity".into(),
donee_address: None,
donee_ein: None,
appraiser_name: "Test Appraiser".into(),
appraiser_address: None,
appraiser_tin: None,
appraiser_ptin: None,
appraiser_qualifications: None,
appraisal_date: None,
fmv_method_override: None,
}
}
#[test]
fn form8283_full_section_b_details_flips_needs_review() {
let event = EventId::decision(42);
let st = state_removals(vec![Removal {
event: event.clone(),
..donation(
42,
date!(2025 - 03 - 01),
dec!(52000),
vec![base_removal_leg()],
)
}]);
let mut map = BTreeMap::new();
map.insert(event, full_section_b_details());
let rows = form_8283(&st, 2025, &map);
assert_eq!(rows.len(), 1);
let r = &rows[0];
assert_eq!(r.appraiser, "Test Appraiser");
assert_eq!(r.donee, "Test Charity");
assert!(
!r.needs_review,
"full Section-B details → needs_review must be false"
);
assert!(r.details.is_some(), "carrier row must embed the details");
}
#[test]
fn form8283_full_section_b_with_fmv_override() {
let event = EventId::decision(43);
let mut d = full_section_b_details();
d.fmv_method_override = Some("independent appraisal".into());
let st = state_removals(vec![Removal {
event: event.clone(),
..donation(
43,
date!(2025 - 03 - 01),
dec!(52000),
vec![base_removal_leg()],
)
}]);
let mut map = BTreeMap::new();
map.insert(event, d);
let rows = form_8283(&st, 2025, &map);
assert_eq!(rows[0].fmv_method, "independent appraisal");
assert!(!rows[0].needs_review);
}
#[test]
fn form8283_skeletal_section_b_keeps_needs_review_true() {
let event = EventId::decision(44);
let st = state_removals(vec![Removal {
event: event.clone(),
..donation(
44,
date!(2025 - 03 - 01),
dec!(52000),
vec![base_removal_leg()],
)
}]);
let mut map = BTreeMap::new();
map.insert(event, skeletal_details());
let rows = form_8283(&st, 2025, &map);
let r = &rows[0];
assert_eq!(r.appraiser, "Test Appraiser", "appraiser name present");
assert_eq!(r.donee, "Test Charity", "donee name present");
assert!(
r.needs_review,
"[R0-I1] skeletal Section-B must keep needs_review true (honest gap — appraiser declaration incomplete)"
);
}
#[test]
fn form8283_section_a_with_details_flips_needs_review() {
let event = EventId::decision(45);
let st = state_removals(vec![Removal {
event: event.clone(),
..donation(
45,
date!(2025 - 03 - 01),
dec!(3000),
vec![base_removal_leg()],
)
}]);
let mut map = BTreeMap::new();
map.insert(event, skeletal_details()); let rows = form_8283(&st, 2025, &map);
let r = &rows[0];
assert_eq!(r.section, Some(Form8283Section::A));
assert!(
!r.needs_review,
"Section A: details present → needs_review must be false"
);
}
#[test]
fn form8283_section_a_fmv_override_resolves_deferral() {
let event = EventId::decision(46);
let mut d = skeletal_details();
d.fmv_method_override = Some("broker valuation".into());
let st = state_removals(vec![Removal {
event: event.clone(),
..donation(
46,
date!(2025 - 03 - 01),
dec!(3000),
vec![base_removal_leg()],
)
}]);
let mut map = BTreeMap::new();
map.insert(event, d);
let rows = form_8283(&st, 2025, &map);
assert_eq!(rows[0].fmv_method, "broker valuation");
assert_eq!(rows[0].section, Some(Form8283Section::A));
}
#[test]
fn form8283_no_details_preserves_existing_behavior() {
let event = EventId::decision(47);
let st = state_removals(vec![Removal {
event: event.clone(),
donee: Some("Habitat For Humanity".into()),
..donation(
47,
date!(2025 - 03 - 01),
dec!(52000),
vec![base_removal_leg()],
)
}]);
let rows = form_8283(&st, 2025, &BTreeMap::new());
let r = &rows[0];
assert_eq!(r.appraiser, "");
assert!(r.needs_review);
assert_eq!(r.donee, "Habitat For Humanity");
assert_eq!(
r.fmv_method, "qualified appraisal",
"Section B: section-derived default"
);
assert!(r.details.is_none(), "no details → embed must be None");
}
#[test]
fn form8283_details_embed_carrier_only_on_multi_leg() {
let event = EventId::decision(48);
let st = state_removals(vec![Removal {
event: event.clone(),
..donation(
48,
date!(2025 - 03 - 01),
dec!(52000),
vec![
RemovalLeg {
lot_id: lot(0, 1),
..base_removal_leg()
}, RemovalLeg {
lot_id: lot(0, 0),
..base_removal_leg()
}, ],
)
}]);
let mut map = BTreeMap::new();
map.insert(event, full_section_b_details());
let rows = form_8283(&st, 2025, &map);
assert_eq!(rows.len(), 2);
assert!(
rows[0].details.is_some(),
"carrier row must have embedded details"
);
assert!(
rows[1].details.is_none(),
"non-carrier row must have None details"
);
assert!(
!rows[0].needs_review,
"carrier: full details → needs_review false"
);
assert!(
rows[1].needs_review,
"non-carrier: always needs_review true"
);
}