use btctax_cli::{cmd, render};
use btctax_core::persistence::{append_decision, load_all};
use btctax_core::{
Carryforward, EventPayload, FilingStatus, LotMethod, MethodElection, TaxProfile,
};
use btctax_store::Passphrase;
use rust_decimal_macros::dec;
use std::path::{Path, PathBuf};
use time::{macros::datetime, OffsetDateTime, UtcOffset};
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 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();
let mut s = btctax_cli::Session::open(&vault, &pp()).unwrap();
append_decision(
s.conn(),
EventPayload::MethodElection(MethodElection {
effective_from: time::macros::date!(2025 - 01 - 01),
method: LotMethod::Fifo,
wallet: None,
}),
datetime!(2025-01-01 00:00:00 UTC),
UtcOffset::UTC,
None,
)
.unwrap();
s.save().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_tax_saving_csv(dir: &Path) -> PathBuf {
let p = dir.join("opt_saving.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\
opt-buy-lt,2023-01-01 12:00:00 UTC,Buy,BTC,1.00000000,USD,30000.00,30000.00,30000.00,0.00,,,\r\n\
opt-buy-st,2025-01-02 12:00:00 UTC,Buy,BTC,1.00000000,USD,80000.00,80000.00,80000.00,0.00,,,\r\n\
opt-sell,2025-06-01 12:00:00 UTC,Sell,BTC,1.00000000,USD,50000.00,50000.00,50000.00,0.00,,,\r\n",
)
.unwrap();
p
}
fn write_single_lot_csv(dir: &Path) -> PathBuf {
let p = dir.join("opt_single.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\
single-buy,2025-03-01 12:00:00 UTC,Buy,BTC,1.00000000,USD,50000.00,50000.00,50000.00,0.00,,,\r\n\
single-sell,2025-06-15 12:00:00 UTC,Sell,BTC,1.00000000,USD,50000.00,50000.00,50000.00,0.00,,,\r\n",
)
.unwrap();
p
}
fn write_heuristic_pool_csv(dir: &Path) -> PathBuf {
let p = dir.join("opt_heuristic.csv");
let header = "\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";
let mut rows = String::new();
for i in 0..13usize {
let price = 40_000u32 + (i as u32) * 2_000; let subtotal = price / 100; rows.push_str(&format!(
"heur-buy-{i:02},2025-01-{day:02} 12:00:00 UTC,Buy,BTC,0.01000000,USD,\
{price}.00,{subtotal}.00,{subtotal}.00,0.00,,,\r\n",
day = i + 1
));
}
rows.push_str(
"heur-sell,2025-07-01 12:00:00 UTC,Sell,BTC,0.01000000,USD,\
50000.00,500.00,500.00,0.00,,,\r\n",
);
std::fs::write(&p, format!("{header}{rows}")).unwrap();
p
}
fn write_buy_only_csv(dir: &Path) -> PathBuf {
let p = dir.join("opt_buyonly.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\
buyonly-buy,2025-03-01 12:00:00 UTC,Buy,BTC,1.00000000,USD,84000.00,84000.00,84000.00,0.00,,,\r\n",
)
.unwrap();
p
}
#[test]
fn optimize_run_saves_tax_and_writes_nothing() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_tax_saving_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 now: OffsetDateTime = datetime!(2026-01-01 12:00:00 UTC);
let proposal = cmd::optimize::run(&vault, &pp(), 2025, now).unwrap();
let after = event_count(&vault);
assert_eq!(before, after, "optimize run must not append any event");
assert!(
proposal.delta <= dec!(0),
"expected delta ≤ 0, got {}",
proposal.delta
);
assert!(
proposal.delta < dec!(0),
"expected strict tax saving (delta < 0) for this fixture, got {}",
proposal.delta
);
let rendered = render::render_optimize_proposal(&proposal);
assert!(
rendered.contains("NOTHING is filed or bound by running this"),
"rendered output must contain the read-only disclaimer:\n{rendered}"
);
assert!(
!proposal.per_disposal.is_empty(),
"expected at least one disposal row"
);
assert!(
rendered.contains("::"),
"rendered must include per-disposal status line:\n{rendered}"
);
}
#[test]
fn optimize_run_approximate_banner_when_heuristic_pool() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_heuristic_pool_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let now: OffsetDateTime = datetime!(2026-01-01 12:00:00 UTC);
let proposal = cmd::optimize::run(&vault, &pp(), 2025, now).unwrap();
assert!(
proposal.approximate,
"13-lot pool must trigger approximate=true"
);
assert!(
matches!(
proposal.approx_reason,
Some(btctax_core::ApproxReason::PoolHeuristic {
lots: 13,
bound: 12
})
),
"expected PoolHeuristic {{ lots: 13, bound: 12 }}, got {:?}",
proposal.approx_reason
);
let rendered = render::render_optimize_proposal(&proposal);
assert!(
rendered.contains("APPROXIMATE"),
"rendered must contain APPROXIMATE banner:\n{rendered}"
);
assert!(
rendered.contains("exhaustive-enumeration bound"),
"rendered must explain the pool-heuristic reason:\n{rendered}"
);
assert!(
rendered.contains("heuristic SUBSET"),
"rendered must mention heuristic SUBSET:\n{rendered}"
);
}
#[test]
fn optimize_run_no_banner_when_exact_result() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_tax_saving_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let now: OffsetDateTime = datetime!(2026-01-01 12:00:00 UTC);
let proposal = cmd::optimize::run(&vault, &pp(), 2025, now).unwrap();
assert!(
!proposal.approximate,
"2-lot pool must give an exact (non-approximate) result"
);
let rendered = render::render_optimize_proposal(&proposal);
assert!(
!rendered.contains("APPROXIMATE"),
"rendered must NOT contain APPROXIMATE banner for an exact result:\n{rendered}"
);
}
#[test]
fn optimize_run_contemporaneous_when_now_before_sale() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_tax_saving_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let now: OffsetDateTime = datetime!(2025-01-01 12:00:00 UTC);
let proposal = cmd::optimize::run(&vault, &pp(), 2025, now).unwrap();
let rendered = render::render_optimize_proposal(&proposal);
assert!(
rendered.contains("made")
&& rendered.contains("sale")
&& rendered.contains("Contemporaneous"),
"rendered must show ContemporaneousNow for a pre-sale proposal:\n{rendered}"
);
assert!(
!rendered.contains("already executed"),
"rendered must NOT show NeedsAttestation for a pre-sale proposal:\n{rendered}"
);
}
#[test]
fn optimize_run_needs_attestation_when_now_after_sale() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_tax_saving_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let now: OffsetDateTime = datetime!(2026-01-01 12:00:00 UTC);
let proposal = cmd::optimize::run(&vault, &pp(), 2025, now).unwrap();
let rendered = render::render_optimize_proposal(&proposal);
assert!(
rendered.contains("already executed"),
"rendered must show NeedsAttestation for a post-sale proposal:\n{rendered}"
);
assert!(
!rendered.contains("made \u{2264} sale \u{2192} Contemporaneous"),
"rendered must NOT show ContemporaneousNow for a post-sale proposal:\n{rendered}"
);
}
#[test]
fn optimize_run_no_change_row_shows_no_change_not_attest() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_single_lot_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let now: OffsetDateTime = datetime!(2026-01-01 12:00:00 UTC);
let proposal = cmd::optimize::run(&vault, &pp(), 2025, now).unwrap();
assert_eq!(
proposal.per_disposal.len(),
1,
"expected exactly one disposal row"
);
assert_eq!(
proposal.per_disposal[0].proposed_selection, proposal.per_disposal[0].current_selection,
"single-lot disposal must be a no-change row"
);
let rendered = render::render_optimize_proposal(&proposal);
assert!(
rendered.contains("no change"),
"rendered must contain 'no change' note for a no-change disposal:\n{rendered}"
);
assert!(
rendered.contains("already optimal under current identification"),
"rendered must contain 'already optimal' note:\n{rendered}"
);
assert!(
!rendered.contains("already executed"),
"rendered must NOT show 'already executed' for a no-change disposal:\n{rendered}"
);
assert!(
!rendered.contains("optimize accept"),
"rendered must NOT show 'optimize accept' for a no-change disposal:\n{rendered}"
);
}
#[test]
fn optimize_run_pre2025_is_usage_error() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_tax_saving_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
let now: OffsetDateTime = datetime!(2026-01-01 12:00:00 UTC);
let err = cmd::optimize::run(&vault, &pp(), 2024, now).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("pre-2025"),
"error must mention 'pre-2025' for year=2024:\n{msg}"
);
assert!(
!msg.contains("delta"),
"error message must not contain a delta for pre-2025:\n{msg}"
);
}
#[test]
fn optimize_run_no_profile_is_usage_error() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_tax_saving_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
let now: OffsetDateTime = datetime!(2026-01-01 12:00:00 UTC);
let err = cmd::optimize::run(&vault, &pp(), 2025, now).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("year not computable"),
"error must mention 'year not computable' when no profile:\n{msg}"
);
}
#[test]
fn optimize_run_no_disposals_is_usage_error() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_buy_only_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2025, single_100k_profile()).unwrap();
let now: OffsetDateTime = datetime!(2026-01-01 12:00:00 UTC);
let err = cmd::optimize::run(&vault, &pp(), 2025, now).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("no method-honoring disposals"),
"error must mention 'no method-honoring disposals' when year has no sells:\n{msg}"
);
}