use btctax_cli::{cmd, render};
use btctax_core::{
persistence::load_all, Carryforward, DisposeKind, FilingStatus, TaxProfile, WalletId,
};
use btctax_store::Passphrase;
use rust_decimal_macros::dec;
use std::path::{Path, PathBuf};
use time::macros::date;
fn pp() -> Passphrase {
Passphrase::new("pw".into())
}
fn single_100k_profile() -> TaxProfile {
TaxProfile {
filing_status: FilingStatus::Single,
ordinary_taxable_income: dec!(100000),
magi_excluding_crypto: dec!(100000),
qualified_dividends_and_other_pref_income: dec!(0),
other_net_capital_gain: dec!(0),
capital_loss_carryforward_in: Carryforward::default(),
w2_ss_wages: dec!(0),
w2_medicare_wages: dec!(0),
schedule_c_expenses: dec!(0),
}
}
fn coinbase_wallet() -> WalletId {
WalletId::Exchange {
provider: "coinbase".into(),
account: "default".into(),
}
}
fn make_vault_with(csv: &Path) -> (tempfile::TempDir, PathBuf) {
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(), &[csv.to_path_buf()]).unwrap();
(dir, vault)
}
fn event_count(vault: &Path) -> usize {
let s = btctax_cli::Session::open(vault, &pp()).unwrap();
load_all(s.conn()).unwrap().len()
}
fn write_two_lots_csv(dir: &Path) -> PathBuf {
let p = dir.join("consult_two_lots.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,00000000-0000-0000-0000-000000000000\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\
lt-buy,2024-01-15 12:00:00 UTC,Buy,BTC,1.00000000,USD,30000.00,30000.00,30000.00,0.00,,,\r\n\
ht-buy,2025-03-01 12:00:00 UTC,Buy,BTC,1.00000000,USD,80000.00,80000.00,80000.00,0.00,,,\r\n",
)
.unwrap();
p
}
fn write_st_crossover_csv(dir: &Path) -> PathBuf {
let p = dir.join("consult_st_crossover.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,00000000-0000-0000-0000-000000000000\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\
st-buy,2024-06-16 12:00:00 UTC,Buy,BTC,1.00000000,USD,60000.00,60000.00,60000.00,0.00,,,\r\n",
)
.unwrap();
p
}
fn write_lt_only_csv(dir: &Path) -> PathBuf {
let p = dir.join("consult_lt_only.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,00000000-0000-0000-0000-000000000000\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\
lt-only-buy,2024-01-15 12:00:00 UTC,Buy,BTC,1.00000000,USD,30000.00,30000.00,30000.00,0.00,,,\r\n",
)
.unwrap();
p
}
#[test]
fn consult_what_if_picks_high_basis_lot_and_writes_nothing() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_two_lots_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let before = event_count(&vault);
let report = cmd::optimize::consult(
&vault,
&pp(),
100_000_000, coinbase_wallet(),
date!(2025 - 06 - 15), None, DisposeKind::Sell,
)
.unwrap();
let after = event_count(&vault);
assert_eq!(before, after, "consult must NOT append any event");
assert_eq!(
report.st_gain,
dec!(-12500),
"HIFO picks high-basis lot → ST loss = −$12,500"
);
assert_eq!(
report.lt_gain,
dec!(0),
"HIFO picks one lot: no LT leg (the high-basis lot is ST)"
);
assert_eq!(
report.total_federal_tax_attributable,
dec!(-660),
"total attributable = −$660 (22% × $3k §1211 deduction from $12,500 ST loss)"
);
assert_eq!(
report.proposed_selection.len(),
1,
"HIFO: exactly one lot consumed"
);
let rendered = render::render_consult(&report);
assert!(
rendered.contains("consult_two_lots") || rendered.contains("-660"),
"rendered must contain the total tax or fixture reference:\n{rendered}"
);
assert!(
rendered.contains("-12500") || rendered.contains("-660"),
"rendered must contain the ST gain or total:\n{rendered}"
);
assert!(
rendered.contains("Tax decision-support only"),
"rendered must contain the non-investment-advice footer:\n{rendered}"
);
assert!(
rendered.contains("not investment advice"),
"rendered must disclaim investment advice:\n{rendered}"
);
assert!(
!rendered.contains("timing:"),
"HIFO picks the 2025-03-01 lot → crossover 2026-03-02 (year 2026 ≠ 2025) → no timing:\n{rendered}"
);
}
#[test]
fn consult_is_deterministic() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_two_lots_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let r1 = cmd::optimize::consult(
&vault,
&pp(),
100_000_000,
coinbase_wallet(),
date!(2025 - 06 - 15),
None,
DisposeKind::Sell,
)
.unwrap();
let r2 = cmd::optimize::consult(
&vault,
&pp(),
100_000_000,
coinbase_wallet(),
date!(2025 - 06 - 15),
None,
DisposeKind::Sell,
)
.unwrap();
assert_eq!(r1, r2, "consult must be deterministic (NFR4)");
}
#[test]
fn consult_timing_insight_present_for_soon_to_cross_st_lot() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_st_crossover_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let report = cmd::optimize::consult(
&vault,
&pp(),
100_000_000,
coinbase_wallet(),
date!(2025 - 06 - 15),
None,
DisposeKind::Sell,
)
.unwrap();
assert_eq!(report.st_gain, dec!(7500), "ST gain = $7,500");
assert_eq!(
report.lt_gain,
dec!(0),
"no LT leg (lot is ST at consult date)"
);
assert_eq!(
report.total_federal_tax_attributable,
dec!(1733),
"total attributable = $1,733 (ST gain spans 22%+24% bracket)"
);
assert!(
report.timing.is_some(),
"timing insight must be present for a soon-to-cross ST lot"
);
let timing = report.timing.as_ref().unwrap();
assert_eq!(
timing.latest_crossover,
date!(2025 - 06 - 17),
"crossover date must be 2025-06-17"
);
assert_eq!(
timing.st_sat_in_selection, 100_000_000,
"all 100,000,000 sat are short-term at the consult date"
);
assert_eq!(
timing.tax_if_sold_long_term,
dec!(1125),
"tax if sold LT = $7,500 × 15% = $1,125"
);
assert_eq!(
timing.saving_if_waited,
dec!(608),
"saving if waited = $1,733 − $1,125 = $608"
);
let rendered = render::render_consult(&report);
assert!(
rendered.contains("timing:"),
"rendered must contain 'timing:' line:\n{rendered}"
);
assert!(
rendered.contains("2025-06-17"),
"rendered must contain the crossover date 2025-06-17:\n{rendered}"
);
assert!(
rendered.contains("608"),
"rendered must mention the saving $608:\n{rendered}"
);
assert!(
rendered.contains("Tax decision-support only"),
"rendered must contain the non-investment-advice footer:\n{rendered}"
);
}
#[test]
fn consult_timing_absent_for_purely_lt_lot() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_lt_only_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let report = cmd::optimize::consult(
&vault,
&pp(),
100_000_000,
coinbase_wallet(),
date!(2025 - 06 - 15),
None,
DisposeKind::Sell,
)
.unwrap();
assert_eq!(
report.st_gain,
dec!(0),
"no ST leg (lot is LT at consult date)"
);
assert_eq!(
report.lt_gain,
dec!(37500),
"LT gain = $67,500 − $30,000 = $37,500"
);
assert!(
report.timing.is_none(),
"timing insight must be OMITTED for a purely LT lot"
);
let rendered = render::render_consult(&report);
assert!(
!rendered.contains("timing:"),
"rendered must NOT contain 'timing:' for a purely LT lot:\n{rendered}"
);
assert!(
rendered.contains("Tax decision-support only"),
"rendered must contain the non-investment-advice footer:\n{rendered}"
);
}
#[test]
fn consult_future_date_requires_proceeds() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_two_lots_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let err = cmd::optimize::consult(
&vault,
&pp(),
100_000_000,
coinbase_wallet(),
date!(2025 - 12 - 31), None, DisposeKind::Sell,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.to_ascii_lowercase().contains("proceeds"),
"error must mention 'proceeds' when no dataset price and proceeds=None:\n{msg}"
);
let ok = cmd::optimize::consult(
&vault,
&pp(),
100_000_000,
coinbase_wallet(),
date!(2025 - 12 - 31),
Some(dec!(70000)), DisposeKind::Sell,
);
assert!(
ok.is_ok(),
"consult with explicit proceeds must succeed even for off-dataset date:\n{ok:?}"
);
}
#[test]
fn consult_pre2025_date_is_usage_error() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_lt_only_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
let err = cmd::optimize::consult(
&vault,
&pp(),
100_000_000,
coinbase_wallet(),
date!(2024 - 12 - 31), Some(dec!(42000)),
DisposeKind::Sell,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("pre-2025"),
"error must mention 'pre-2025' for a pre-transition date:\n{msg}"
);
}
#[test]
fn consult_no_lots_is_usage_error() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let err = cmd::optimize::consult(
&vault,
&pp(),
100_000_000,
coinbase_wallet(),
date!(2025 - 06 - 15),
None,
DisposeKind::Sell,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("no lots"),
"error must mention 'no lots' when wallet is empty:\n{msg}"
);
}
fn write_thirteen_lots_csv(dir: &Path) -> PathBuf {
let p = dir.join("consult_thirteen_lots.csv");
let mut rows = "\r\nTransactions\r\nUser,00000000-0000-0000-0000-000000000000\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".to_string();
for i in 1..=13usize {
rows.push_str(&format!(
"lot{i:02},2025-01-{i:02} 12:00:00 UTC,Buy,BTC,1.00000000,USD,60000.00,60000.00,60000.00,0.00,,,\r\n"
));
}
std::fs::write(&p, rows).unwrap();
p
}
#[test]
fn consult_large_pool_sets_approximate_and_renders_note() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_thirteen_lots_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let report = cmd::optimize::consult(
&vault,
&pp(),
100_000_000, coinbase_wallet(),
date!(2025 - 06 - 15), None,
DisposeKind::Sell,
)
.unwrap();
assert!(
report.approximate,
"ConsultReport::approximate must be true for a >12-lot pool (heuristic subset)"
);
let rendered = render::render_consult(&report);
assert!(
rendered.contains("heuristic"),
"rendered must contain 'heuristic' disclosure for approximate=true:\n{rendered}"
);
assert!(
rendered.contains(">12-lot"),
"rendered must mention the >12-lot pool in the disclosure:\n{rendered}"
);
assert!(
rendered.contains("Tax decision-support only"),
"rendered must contain the non-investment-advice footer:\n{rendered}"
);
}
#[test]
fn consult_small_pool_approximate_false_no_note() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_two_lots_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let report = cmd::optimize::consult(
&vault,
&pp(),
100_000_000,
coinbase_wallet(),
date!(2025 - 06 - 15),
None,
DisposeKind::Sell,
)
.unwrap();
assert!(
!report.approximate,
"ConsultReport::approximate must be false for a ≤12-lot pool (complete enumeration)"
);
let rendered = render::render_consult(&report);
assert!(
!rendered.contains("heuristic"),
"rendered must NOT contain 'heuristic' for approximate=false:\n{rendered}"
);
assert!(
rendered.contains("Tax decision-support only"),
"rendered must contain the non-investment-advice footer:\n{rendered}"
);
}