mod fixtures;
use btctax_cli::{cmd, render, Session};
use btctax_core::{AllocMethod, BasisSource, BlockerKind, EventPayload, OutflowClass};
use btctax_store::Passphrase;
use time::macros::{date, datetime};
fn pp() -> Passphrase {
Passphrase::new("pw".into())
}
fn now() -> time::OffsetDateTime {
datetime!(2026-02-01 12:00:00 UTC)
}
#[test]
fn report_shows_lots_and_year_filtered_disposals() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let file = fixtures::coinbase_buy_sell_send(dir.path());
cmd::import::run(&vault, &pp(), &[file]).unwrap();
let state = cmd::inspect::report(&vault, &pp(), Some(2025)).unwrap();
assert!(!state.lots.is_empty());
assert_eq!(state.disposals.len(), 1);
let text = render::render_report(&state, Some(2025));
assert!(text.contains("Holdings"));
assert!(text.contains("Disposals"));
let none = render::render_report(&state, Some(1999));
assert!(none.contains("Disposals (year 1999): none"));
}
#[test]
fn verify_reports_conservation_and_advisory_pending_no_hard_blockers() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let file = fixtures::coinbase_buy_sell_send(dir.path());
cmd::import::run(&vault, &pp(), &[file]).unwrap();
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
assert!(
report.conservation.balanced,
"Σpending closes the FR9 identity"
);
assert_eq!(report.pending, 1);
assert!(report.hard.is_empty(), "no hard blockers -> exit 0");
assert!(!report.has_hard_blockers());
let text = render::render_verify(&report);
assert!(text.contains("Conservation"));
assert!(text.contains("Path A")); }
#[test]
fn verify_surfaces_hard_blocker_and_signals_nonzero_exit() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let file = fixtures::coinbase_buy_receive(dir.path());
cmd::import::run(&vault, &pp(), &[file]).unwrap();
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
assert!(
!report.hard.is_empty(),
"unclassified TransferIn → hard UnknownBasisInbound blocker"
);
assert!(
report.has_hard_blockers(),
"has_hard_blockers() true → caller maps to non-zero exit (§7.1)"
);
assert!(
report.unknown_basis_inbounds > 0,
"unknown_basis_inbounds counter reflects the hard blocker count"
);
}
#[test]
fn verify_reports_path_b_effective_not_time_barred_after_attest_happy_path() {
use time::macros::datetime;
let now = datetime!(2026-02-01 12:00:00 UTC);
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,Recipient Address\r\n\
cb-pre,2024-01-15 12:00:00 UTC,Buy,BTC,0.20000000,USD,42500.00,8500.00,8550.00,50.00,,,\r\n\
cb-sell,2025-06-01 12:00:00 UTC,Sell,BTC,0.05000000,USD,90000.00,4500.00,4490.00,10.00,,,\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), btctax_core::LotMethod::Fifo, true).unwrap();
let a1 = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
AllocMethod::ActualPosition,
false,
now,
)
.unwrap();
cmd::reconcile::void(&vault, &pp(), &a1.canonical(), now).unwrap();
cmd::reconcile::safe_harbor_allocate(&vault, &pp(), AllocMethod::ActualPosition, false, now)
.unwrap();
cmd::reconcile::safe_harbor_attest(&vault, &pp(), now).unwrap();
let (has_allocated_lots, has_stale_timebar, attested_alloc_exists) = {
let s = btctax_cli::Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
let (state, _) = s.project().unwrap();
let has_allocated = state.lots.iter().any(|l| {
matches!(
l.basis_source,
btctax_core::BasisSource::SafeHarborAllocated
)
});
let has_timebar = state
.blockers
.iter()
.any(|b| b.kind == btctax_core::BlockerKind::SafeHarborTimebar);
let attested = events.iter().any(|e| match &e.payload {
EventPayload::SafeHarborAllocation(a) => a.timely_allocation_attested,
_ => false,
});
(has_allocated, has_timebar, attested)
};
assert!(
has_allocated_lots,
"effective Path B: SafeHarborAllocated lots must exist after attest"
);
assert!(
has_stale_timebar,
"stale SafeHarborTimebar advisory must remain (from voided inert allocation)"
);
assert!(
attested_alloc_exists,
"an attested SafeHarborAllocation must exist in the event log"
);
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
assert!(
report.safe_harbor.contains("effective"),
"safe_harbor must say 'effective' after attest, got: {:?}",
report.safe_harbor
);
assert!(
!report.safe_harbor.contains("time-barred"),
"safe_harbor must NOT say 'time-barred' after attest, got: {:?}",
report.safe_harbor
);
let text = render::render_verify(&report);
assert!(
text.contains("Path B safe-harbor allocation is effective"),
"rendered verify must show 'Path B safe-harbor allocation is effective', got:\n{text}"
);
}
#[test]
fn safe_harbor_status_remains_effective_when_all_path_b_lots_are_consumed() {
use time::macros::datetime;
let now = datetime!(2026-02-01 12:00:00 UTC);
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,Recipient Address\r\n\
cb-pre,2024-01-15 12:00:00 UTC,Buy,BTC,0.10000000,USD,42500.00,4250.00,4275.00,25.00,,,\r\n\
cb-sell-all,2025-06-15 12:00:00 UTC,Sell,BTC,0.10000000,USD,67500.00,6750.00,6740.00,10.00,,,\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), btctax_core::LotMethod::Fifo, true).unwrap();
let a1 = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
AllocMethod::ActualPosition,
false,
now,
)
.unwrap();
cmd::reconcile::void(&vault, &pp(), &a1.canonical(), now).unwrap();
cmd::reconcile::safe_harbor_allocate(&vault, &pp(), AllocMethod::ActualPosition, false, now)
.unwrap();
cmd::reconcile::safe_harbor_attest(&vault, &pp(), now).unwrap();
{
let s = btctax_cli::Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.lots
.iter()
.all(|l| l.basis_source != BasisSource::SafeHarborAllocated),
"all SafeHarborAllocated lots must be fully consumed (remaining_sat==0 → filtered)"
);
assert!(
state
.blockers
.iter()
.any(|b| b.kind == BlockerKind::SafeHarborTimebar),
"stale SafeHarborTimebar advisory must remain (from voided alloc #1)"
);
assert!(
state.disposals.iter().any(|d| d
.legs
.iter()
.any(|l| l.basis_source == BasisSource::SafeHarborAllocated)),
"disposal legs must carry SafeHarborAllocated (the consumed seed lots)"
);
}
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
assert!(
report.safe_harbor.contains("effective"),
"safe_harbor_status must report 'effective' even when all allocated lots are consumed, \
got: {:?}",
report.safe_harbor
);
assert!(
!report.safe_harbor.contains("time-barred"),
"safe_harbor_status must NOT say 'time-barred' after a successful attest, \
got: {:?}",
report.safe_harbor
);
let text = render::render_verify(&report);
assert!(
text.contains("Path B safe-harbor allocation is effective"),
"render_verify text must show 'Path B … effective', got:\n{text}"
);
}
#[test]
fn verify_reports_declared_method_and_attestation() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), btctax_core::LotMethod::Hifo, true).unwrap();
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
assert_eq!(report.declared_pre2025_method, btctax_core::LotMethod::Hifo);
assert!(report.pre2025_method_attested);
let text = render::render_verify(&report);
assert!(
text.contains("HIFO") && text.contains("attested"),
"render_verify must include 'HIFO' and 'attested', got:\n{text}"
);
}
#[test]
fn verify_lists_election_history_and_selection_count_and_compliance() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_buy_sell_send(dir.path())],
)
.unwrap();
cmd::reconcile::set_forward_method(
&vault,
&pp(),
btctax_core::LotMethod::Hifo,
None, Some(date!(2025 - 06 - 01)),
now(),
)
.unwrap();
let (disposal_ref, lot_ref, principal) = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
let leg = &state.disposals[0].legs[0];
(
state.disposals[0].event.canonical(),
format!(
"{}#{}",
leg.lot_id.origin_event_id.canonical(),
leg.lot_id.split_sequence
),
leg.sat,
)
};
let picks =
vec![btctax_cli::eventref::parse_lot_pick(&format!("{lot_ref}:{principal}")).unwrap()];
cmd::reconcile::select_lots(&vault, &pp(), &disposal_ref, picks, now()).unwrap();
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
assert_eq!(
report.elections.len(),
1,
"one MethodElection decision expected"
);
assert_eq!(
report.selection_count, 1,
"one LotSelection decision expected"
);
assert!(
!report.compliance.is_empty(),
"per-disposal compliance must be non-empty (post-2025 sell exists)"
);
}
#[test]
fn verify_partitions_lot_selection_invalid_as_hard() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::import::run(
&vault,
&pp(),
&[fixtures::coinbase_buy_sell_send(dir.path())],
)
.unwrap();
let (disposal_ref, lot_ref) = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
let leg = &state.disposals[0].legs[0];
(
state.disposals[0].event.canonical(),
format!(
"{}#{}",
leg.lot_id.origin_event_id.canonical(),
leg.lot_id.split_sequence
),
)
};
let picks = vec![btctax_cli::eventref::parse_lot_pick(&format!("{lot_ref}:1")).unwrap()];
cmd::reconcile::select_lots(&vault, &pp(), &disposal_ref, picks, now()).unwrap();
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
assert!(
report
.hard
.iter()
.any(|b| b.kind == btctax_core::BlockerKind::LotSelectionInvalid),
"LotSelectionInvalid must appear in report.hard; hard blockers: {:?}",
report.hard
);
assert!(
report.has_hard_blockers(),
"has_hard_blockers() must be true → non-zero exit (FR9)"
);
}
#[test]
fn config_set_fee_treatment_b_persists_and_affects_projection_config() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let before = cmd::admin::show_config(&vault, &pp()).unwrap();
assert_eq!(before.fee_treatment, btctax_core::FeeTreatment::TreatmentC);
let after =
cmd::admin::set_config(&vault, &pp(), Some(btctax_core::FeeTreatment::TreatmentB)).unwrap();
assert_eq!(after.fee_treatment, btctax_core::FeeTreatment::TreatmentB);
let s = btctax_cli::Session::open(&vault, &pp()).unwrap();
assert_eq!(
s.config().unwrap().fee_treatment,
btctax_core::FeeTreatment::TreatmentB
);
}
#[test]
fn verify_consistency_attested_vault_shows_declared_attested_advisory() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb_pre_dispose.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,\
Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,\
Recipient Address\r\n\
cb-pre-buy,2024-01-15 12:00:00 UTC,Buy,BTC,0.20000000,USD,42500.00,8500.00,8550.00,50.00,,,\r\n\
cb-pre-sell,2024-06-15 12:00:00 UTC,Sell,BTC,0.05000000,USD,50000.00,2500.00,2490.00,10.00,,,\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), btctax_core::LotMethod::Fifo, true).unwrap();
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
let text = render::render_verify(&report);
assert!(
text.contains("attested: true"),
"render_verify must contain 'attested: true' for an attested vault, got:\n{text}"
);
assert!(
report
.advisory
.iter()
.any(|b| b.kind == BlockerKind::Pre2025MethodNote
&& b.detail.contains("DECLARED + ATTESTED")),
"advisory Pre2025MethodNote detail must contain 'DECLARED + ATTESTED' when attested; \
advisories: {:?}",
report.advisory
);
assert!(
!text.contains("have NOT declared"),
"attested vault must NOT show 'have NOT declared' in render_verify, got:\n{text}"
);
}
#[test]
fn verify_consistency_unattested_vault_shows_have_not_declared_advisory() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb_pre_dispose.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,\
Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,\
Recipient Address\r\n\
cb-pre-buy,2024-01-15 12:00:00 UTC,Buy,BTC,0.20000000,USD,42500.00,8500.00,8550.00,50.00,,,\r\n\
cb-pre-sell,2024-06-15 12:00:00 UTC,Sell,BTC,0.05000000,USD,50000.00,2500.00,2490.00,10.00,,,\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
let text = render::render_verify(&report);
assert!(
text.contains("attested: false"),
"render_verify must contain 'attested: false' for an unattested vault, got:\n{text}"
);
assert!(
report
.advisory
.iter()
.any(|b| b.kind == BlockerKind::Pre2025MethodNote
&& b.detail.contains("have NOT declared")),
"advisory Pre2025MethodNote detail must contain 'have NOT declared' when unattested; \
advisories: {:?}",
report.advisory
);
assert!(
!text.contains("DECLARED + ATTESTED"),
"unattested vault must NOT show 'DECLARED + ATTESTED' in render_verify, got:\n{text}"
);
}
#[test]
fn verify_donation_over_5k_proxy_shows_qualified_appraisal_note() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb_appr.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,\
Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,\
Recipient Address\r\n\
cb-appr-buy,2025-01-05 12:00:00 UTC,Buy,BTC,0.10000000,USD,84000.00,8400.00,8450.00,50.00,,,\r\n\
cb-appr-send,2026-01-30 12:00:00 UTC,Send,BTC,0.10000000,USD,90000.00,,,,,,bc1qsyntheticdest\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
let out_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(
state.pending_reconciliation.len(),
1,
"Send must be pending before reclassification"
);
state.pending_reconciliation[0].event.canonical()
};
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::Donate {
appraisal_required: false,
},
btctax_cli::eventref::parse_usd_arg("90000.00").unwrap(), None,
None,
now(),
)
.unwrap();
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
let note = report
.advisory
.iter()
.find(|b| b.kind == BlockerKind::QualifiedAppraisalNote);
assert!(
note.is_some(),
"QualifiedAppraisalNote must appear in report.advisory for LT $90k donation; \
advisories: {:?}",
report.advisory
);
let detail = ¬e.unwrap().detail;
assert!(
detail.contains("90000.00"),
"detail must name the deduction proxy ($90k); got: {detail}"
);
assert!(
detail.contains("§170(f)(11)(C)"),
"detail must cite §170(f)(11)(C); got: {detail}"
);
assert!(
detail.contains("CCA 202302012"),
"detail must cite CCA 202302012 (crypto-specific point); got: {detail}"
);
assert!(
detail.contains("§170(e)"),
"detail must cite §170(e) (character caveat); got: {detail}"
);
assert!(
detail.contains("§170(f)(11)(F)"),
"detail must cite §170(f)(11)(F) (aggregation caveat); got: {detail}"
);
assert!(
report.hard.is_empty(),
"QualifiedAppraisalNote must not create hard blockers; hard: {:?}",
report.hard
);
let text = render::render_verify(&report);
assert!(
text.contains("QualifiedAppraisalNote"),
"render_verify must render QualifiedAppraisalNote advisory; got:\n{text}"
);
}
#[test]
fn verify_donation_under_5k_proxy_shows_no_qualified_appraisal_note() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb_small_appr.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,\
Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,\
Recipient Address\r\n\
cb-small-buy,2025-07-01 12:00:00 UTC,Buy,BTC,0.01000000,USD,50000.00,500.00,510.00,10.00,,,\r\n\
cb-small-send,2026-01-15 12:00:00 UTC,Send,BTC,0.01000000,USD,50000.00,,,,,,bc1qsyntheticdest\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
let out_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
state.pending_reconciliation[0].event.canonical()
};
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::Donate {
appraisal_required: false,
},
btctax_cli::eventref::parse_usd_arg("1000.00").unwrap(), None,
None,
now(),
)
.unwrap();
let report = cmd::inspect::verify(&vault, &pp()).unwrap();
assert!(
!report
.advisory
.iter()
.any(|b| b.kind == BlockerKind::QualifiedAppraisalNote),
"small ST donation (proxy = basis $510 < $5k) must NOT show QualifiedAppraisalNote; \
advisories: {:?}",
report.advisory
);
}
#[test]
fn render_report_charitable_total_year_filter_and_qualifier() {
use btctax_core::{EventId, LedgerState, Removal, RemovalKind, Source, SourceRef};
use rust_decimal_macros::dec;
use time::macros::date;
let make_removal = |src_ref: &str, date: time::Date, amount| Removal {
event: EventId::import(Source::Coinbase, SourceRef::new(src_ref)),
kind: RemovalKind::Donation,
removed_at: date,
legs: vec![],
appraisal_required: false,
donor_acquired_at: None,
claimed_deduction: Some(amount),
donee: None,
};
let mut state = LedgerState::default();
state
.removals
.push(make_removal("D-2025", date!(2025 - 12 - 15), dec!(8000.00))); state.removals.push(make_removal(
"D-2026a",
date!(2026 - 03 - 01),
dec!(10000.00),
)); state.removals.push(make_removal(
"D-2026b",
date!(2026 - 06 - 01),
dec!(6000.00),
));
let text_2026 = render::render_report(&state, Some(2026));
assert!(
text_2026.contains("16000.00"),
"year=2026 charitable total must be $16,000.00; got:\n{text_2026}"
);
assert!(
!text_2026.contains("24000.00"),
"year=2026 total must NOT include 2025 donation (prior-year excluded); got:\n{text_2026}"
);
assert!(
text_2026.contains("BEFORE §170(b) AGI limits / carryover"),
"total line must say 'BEFORE §170(b) AGI limits / carryover'; got:\n{text_2026}"
);
assert!(
text_2026.contains("Schedule A itemized"),
"total line must say 'Schedule A itemized'; got:\n{text_2026}"
);
let text_2025 = render::render_report(&state, Some(2025));
assert!(
text_2025.contains("8000.00"),
"year=2025 charitable total must be $8,000.00; got:\n{text_2025}"
);
}
#[test]
fn render_report_donation_header_shows_claimed_deduction() {
use btctax_core::{EventId, LedgerState, Removal, RemovalKind, Source, SourceRef};
use rust_decimal_macros::dec;
use time::macros::date;
let mut state = LedgerState::default();
state.removals.push(Removal {
event: EventId::import(Source::Coinbase, SourceRef::new("DON")),
kind: RemovalKind::Donation,
removed_at: date!(2026 - 03 - 01),
legs: vec![],
appraisal_required: false,
donor_acquired_at: None,
claimed_deduction: Some(dec!(10000.00)),
donee: None,
});
state.removals.push(Removal {
event: EventId::import(Source::Coinbase, SourceRef::new("GIFT")),
kind: RemovalKind::Gift,
removed_at: date!(2026 - 04 - 01),
legs: vec![],
appraisal_required: false,
donor_acquired_at: None,
claimed_deduction: None,
donee: None,
});
let text = render::render_report(&state, None);
assert!(
text.contains("claimed deduction"),
"donation header must show 'claimed deduction'; got:\n{text}"
);
assert!(
text.contains("10000.00"),
"donation header must show the deduction amount $10,000.00; got:\n{text}"
);
let gift_line = text
.lines()
.find(|l| l.contains("gift"))
.expect("gift line must appear in report");
assert!(
!gift_line.contains("claimed deduction"),
"gift header must NOT show 'claimed deduction'; got: {gift_line}"
);
}
#[test]
fn csv_removals_has_claimed_deduction_column() {
use csv::Reader;
use std::fs::File;
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb_don_gift.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\r\n\
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,\
Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes,Sender Address,\
Recipient Address\r\n\
csv-buy,2025-01-05 12:00:00 UTC,Buy,BTC,0.10000000,USD,50000.00,5000.00,5050.00,50.00,,,\r\n\
csv-send-a,2026-03-01 12:00:00 UTC,Send,BTC,0.05000000,USD,60000.00,,,,,,bc1qdonation\r\n\
csv-send-b,2026-04-01 12:00:00 UTC,Send,BTC,0.05000000,USD,62000.00,,,,,,bc1qgiftout\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
let (ref_a, ref_b) = {
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(
state.pending_reconciliation.len(),
2,
"two Sends must be pending"
);
let refs: Vec<_> = state
.pending_reconciliation
.iter()
.map(|p| p.event.canonical())
.collect();
(refs[0].clone(), refs[1].clone())
};
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&ref_a,
OutflowClass::Donate {
appraisal_required: false,
},
btctax_cli::eventref::parse_usd_arg("10000.00").unwrap(),
None,
None,
now(),
)
.unwrap();
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&ref_b,
OutflowClass::GiftOut,
btctax_cli::eventref::parse_usd_arg("12000.00").unwrap(),
None,
None,
now(),
)
.unwrap();
let out = dir.path().join("export_p2a");
let session = Session::open(&vault, &pp()).unwrap();
let (state, _) = session.project().unwrap();
btctax_cli::render::write_csv_exports(
&out,
&state,
None,
None,
&std::collections::BTreeMap::new(),
)
.unwrap();
let mut rdr = Reader::from_reader(File::open(out.join("removals.csv")).unwrap());
let headers: Vec<String> = rdr
.headers()
.unwrap()
.iter()
.map(|s| s.to_string())
.collect();
assert!(
headers.contains(&"claimed_deduction".to_string()),
"removals.csv must have 'claimed_deduction' header; headers: {headers:?}"
);
let cd_idx = headers
.iter()
.position(|h| h == "claimed_deduction")
.unwrap();
let records: Vec<csv::StringRecord> = rdr
.records()
.collect::<Result<Vec<_>, _>>()
.expect("removals.csv records must be readable");
assert!(
!records.is_empty(),
"removals.csv must have at least one data row"
);
let donation_rows: Vec<_> = records
.iter()
.filter(|r| r.get(1) == Some("donation"))
.collect();
assert!(
!donation_rows.is_empty(),
"must have at least one donation row"
);
for row in &donation_rows {
let cd = row
.get(cd_idx)
.expect("claimed_deduction column must exist");
assert!(
!cd.is_empty(),
"donation row claimed_deduction must be non-empty; row: {row:?}"
);
assert!(
cd.contains("10000"),
"LT donation claimed_deduction must contain '10000'; got: {cd}"
);
}
let gift_rows: Vec<_> = records
.iter()
.filter(|r| r.get(1) == Some("gift"))
.collect();
assert!(!gift_rows.is_empty(), "must have at least one gift row");
for row in &gift_rows {
let cd = row
.get(cd_idx)
.expect("claimed_deduction column must exist");
assert!(
cd.is_empty(),
"gift row claimed_deduction must be empty; got: {cd}"
);
}
}