use btctax_cli::{cmd, render};
use btctax_core::persistence::load_all;
use btctax_core::{
Carryforward, ComplianceStatus, EventId, EventPayload, FilingStatus, LotPick, LtcgBreakpoints,
OrdinaryBracket, OrdinarySchedule, TaxProfile, TaxTable, TaxTables,
};
use btctax_store::Passphrase;
use rust_decimal_macros::dec;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use time::{macros::datetime, OffsetDateTime};
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();
(dir, vault)
}
fn event_count(vault: &Path) -> usize {
let s = btctax_cli::Session::open(vault, &pp()).unwrap();
load_all(s.conn()).unwrap().len()
}
fn lotselection_lots(vault: &Path, decision: &EventId) -> Vec<LotPick> {
let s = btctax_cli::Session::open(vault, &pp()).unwrap();
let events = load_all(s.conn()).unwrap();
let e = events
.iter()
.find(|e| &e.id == decision)
.expect("decision event must be present");
match &e.payload {
EventPayload::LotSelection(ls) => ls.lots.clone(),
other => panic!("expected a LotSelection decision, got {other:?}"),
}
}
fn attestation_of(vault: &Path, disposal: &EventId) -> Option<String> {
let s = btctax_cli::Session::open(vault, &pp()).unwrap();
btctax_cli::optimize_attest::get(s.conn(), disposal).unwrap()
}
struct OneTable(TaxTable);
impl TaxTables for OneTable {
fn table_for(&self, year: i32) -> Option<&TaxTable> {
(year == self.0.year).then_some(&self.0)
}
}
fn synth_tables(year: i32) -> OneTable {
let mut ordinary = BTreeMap::new();
ordinary.insert(
FilingStatus::Single,
OrdinarySchedule {
brackets: vec![
OrdinaryBracket {
lower: dec!(0),
rate: dec!(0.10),
},
OrdinaryBracket {
lower: dec!(50000),
rate: dec!(0.22),
},
OrdinaryBracket {
lower: dec!(90000),
rate: dec!(0.32),
},
],
},
);
let mut ltcg = BTreeMap::new();
ltcg.insert(
FilingStatus::Single,
LtcgBreakpoints {
max_zero: dec!(40000),
max_fifteen: dec!(400000),
},
);
OneTable(TaxTable {
year,
source: "SYNTHETIC",
ordinary,
ltcg,
gift_annual_exclusion: dec!(19000),
ss_wage_base: dec!(176100),
gift_lifetime_exclusion: dec!(13_990_000),
})
}
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_broker_2027_csv(dir: &Path) -> PathBuf {
let p = dir.join("opt_broker27.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\
b27-buy-a,2025-01-02 12:00:00 UTC,Buy,BTC,1.00000000,USD,20000.00,20000.00,20000.00,0.00,,,\r\n\
b27-buy-b,2026-01-02 12:00:00 UTC,Buy,BTC,1.00000000,USD,90000.00,90000.00,90000.00,0.00,,,\r\n\
b27-sell,2027-06-01 12:00:00 UTC,Sell,BTC,1.00000000,USD,50000.00,50000.00,50000.00,0.00,,,\r\n",
)
.unwrap();
p
}
const BEFORE_SALE: OffsetDateTime = datetime!(2025-01-01 12:00:00 UTC); const AFTER_SALE: OffsetDateTime = datetime!(2026-01-01 12:00:00 UTC);
#[test]
fn accept_persists_contemporaneous_without_attestation() {
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 proposal = cmd::optimize::run(&vault, &pp(), 2025, BEFORE_SALE).unwrap();
let disposal = proposal.per_disposal[0].disposal.clone();
let displayed = proposal.per_disposal[0].proposed_selection.clone();
assert_ne!(
displayed, proposal.per_disposal[0].current_selection,
"fixture must offer an improving pick (proposed != current)"
);
let before = event_count(&vault);
let out = cmd::optimize::accept(&vault, &pp(), 2025, None, None, BEFORE_SALE).unwrap();
assert_eq!(
out.persisted.len(),
1,
"the contemporaneous pick must persist"
);
assert_eq!(out.persisted[0].2, "Contemporaneous");
assert!(out.skipped.is_empty(), "nothing should be skipped");
assert_eq!(
event_count(&vault),
before + 1,
"exactly one LotSelection appended"
);
let (_, decision, _) = &out.persisted[0];
assert_eq!(
lotselection_lots(&vault, decision),
displayed,
"accept must persist exactly the selection run displayed (NFR4)"
);
assert_eq!(attestation_of(&vault, &disposal), None);
let re = cmd::optimize::run(&vault, &pp(), 2025, BEFORE_SALE).unwrap();
let row = &re.per_disposal[0];
assert_eq!(
row.proposed_selection, row.current_selection,
"no-change row"
);
assert_eq!(row.status, ComplianceStatus::Contemporaneous);
let rendered = render::render_accept_outcome(&out);
assert!(rendered.contains("PERSISTED"));
assert!(rendered.contains("Contemporaneous"));
}
#[test]
fn accept_refuses_already_executed_without_attestation() {
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 proposal = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
let disposal = proposal.per_disposal[0].disposal.clone();
let before = event_count(&vault);
let out = cmd::optimize::accept(&vault, &pp(), 2025, None, None, AFTER_SALE).unwrap();
assert!(
out.persisted.is_empty(),
"post-hoc pick must NOT persist without --attest"
);
assert_eq!(out.skipped.len(), 1);
assert!(
out.skipped[0].1.contains("--attest"),
"skip reason must direct the user to --attest: {}",
out.skipped[0].1
);
assert_eq!(event_count(&vault), before, "no event appended");
assert_eq!(
attestation_of(&vault, &disposal),
None,
"no attestation row"
);
}
#[test]
fn accept_attested_persists_and_upgrades_to_attested_recording() {
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 proposal = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
let disposal = proposal.per_disposal[0].disposal.clone();
let displayed = proposal.per_disposal[0].proposed_selection.clone();
let before = event_count(&vault);
let attest = "I identified these units at the time of sale in my books";
let out = cmd::optimize::accept(
&vault,
&pp(),
2025,
Some(&disposal.canonical()),
Some(attest),
AFTER_SALE,
)
.unwrap();
assert_eq!(out.persisted.len(), 1);
assert_eq!(out.persisted[0].2, "AttestedRecording");
assert_eq!(event_count(&vault), before + 1, "LotSelection appended");
let (_, decision, _) = &out.persisted[0];
assert_eq!(lotselection_lots(&vault, decision), displayed);
assert_eq!(
attestation_of(&vault, &disposal).as_deref(),
Some(attest),
"attestation must round-trip"
);
let re = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
let row = &re.per_disposal[0];
assert_eq!(
row.proposed_selection, row.current_selection,
"R2-I1: the upgrade requires D ∈ unchanged (proposed == persisted current)"
);
assert_eq!(
row.status,
ComplianceStatus::AttestedRecording,
"attested ∧ unchanged ∧ self-envelope → AttestedRecording"
);
}
#[test]
fn accept_then_divergent_baseline_stays_noncompliant() {
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 proposal = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
let disposal = proposal.per_disposal[0].disposal.clone();
let fifo_pick = proposal.per_disposal[0].current_selection.clone(); let opt_pick = proposal.per_disposal[0].proposed_selection.clone(); assert_ne!(fifo_pick, opt_pick);
let out = cmd::optimize::accept(
&vault,
&pp(),
2025,
Some(&disposal.canonical()),
Some("attested P1"),
AFTER_SALE,
)
.unwrap();
let (_, decision, _) = out.persisted[0].clone();
let in_force = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
assert_eq!(in_force.per_disposal[0].current_selection, opt_pick);
assert_eq!(
in_force.per_disposal[0].status,
ComplianceStatus::AttestedRecording
);
cmd::reconcile::void(&vault, &pp(), &decision.canonical(), AFTER_SALE).unwrap();
assert_eq!(
attestation_of(&vault, &disposal),
None,
"void of a LotSelection must clear the attestation row (complete revocation)"
);
let re = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
let row = &re.per_disposal[0];
assert_eq!(
row.current_selection, fifo_pick,
"baseline reverts to FIFO after void"
);
assert_ne!(
row.proposed_selection, row.current_selection,
"proposed (P1) diverges from current (FIFO) → D ∉ unchanged"
);
assert_eq!(
row.status,
ComplianceStatus::NonCompliant,
"R2-I1: a lingering attestation must NOT launder a divergent/reverted baseline"
);
}
#[test]
fn accept_refuses_2027_broker_held_even_with_attestation() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_broker_2027_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2027, single_100k_profile()).unwrap();
let tables = synth_tables(2027);
let now_post_hoc: OffsetDateTime = datetime!(2028-01-01 12:00:00 UTC);
let before = event_count(&vault);
let out =
cmd::optimize::accept_with_tables(&vault, &pp(), 2027, None, None, now_post_hoc, &tables)
.unwrap();
assert!(
out.persisted.is_empty(),
"2027 broker pick must never persist"
);
assert_eq!(out.skipped.len(), 1);
let (disposal, reason) = out.skipped[0].clone();
assert!(
reason.contains("2027+ broker-held") && reason.contains("own-books"),
"skip reason must cite the 2027+ broker rule: {reason}"
);
assert_eq!(event_count(&vault), before, "no event appended");
let out2 = cmd::optimize::accept_with_tables(
&vault,
&pp(),
2027,
Some(&disposal.canonical()),
Some("I tried to attest a 2027 broker pick"),
now_post_hoc,
&tables,
)
.unwrap();
assert!(
out2.persisted.is_empty(),
"even --attest must not persist a 2027 broker pick"
);
assert_eq!(out2.skipped.len(), 1);
assert!(out2.skipped[0].1.contains("2027+ broker-held"));
assert_eq!(
event_count(&vault),
before,
"no event appended even with --attest"
);
assert_eq!(
attestation_of(&vault, &disposal),
None,
"no attestation row written for a refused 2027 broker pick"
);
}
#[test]
fn accept_refuses_2027_broker_contemporaneous_divergent_no_write() {
let csv_dir = tempfile::tempdir().unwrap();
let csv = write_broker_2027_csv(csv_dir.path());
let (_dir, vault) = make_vault_with(&csv);
cmd::tax::set_profile(&vault, &pp(), 2027, single_100k_profile()).unwrap();
let tables = synth_tables(2027);
let now_contemporaneous: OffsetDateTime = datetime!(2027-01-01 12:00:00 UTC);
let before = event_count(&vault);
let out = cmd::optimize::accept_with_tables(
&vault,
&pp(),
2027,
None,
None,
now_contemporaneous,
&tables,
)
.unwrap();
assert!(
out.persisted.is_empty(),
"a contemporaneous-timed 2027 broker divergent pick must NEVER persist (own-books insufficient)"
);
assert_eq!(out.skipped.len(), 1, "the 2027 broker disposal is skipped");
let (disposal, reason) = out.skipped[0].clone();
assert!(
reason.contains("2027+ broker-held") && reason.contains("own-books"),
"skip reason must cite the 2027+ broker rule even for a contemporaneous pick: {reason}"
);
assert_eq!(
event_count(&vault),
before,
"no LotSelection appended — categorical refusal, even with made ≤ sale (no write)"
);
assert_eq!(
attestation_of(&vault, &disposal),
None,
"no attestation row for a refused 2027 broker pick"
);
}
#[test]
fn accept_blanket_attest_is_rejected_before_any_write() {
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 proposal = cmd::optimize::run(&vault, &pp(), 2025, BEFORE_SALE).unwrap();
let disposal = proposal.per_disposal[0].disposal.clone();
let before = event_count(&vault);
let err =
cmd::optimize::accept(&vault, &pp(), 2025, None, Some("blanket"), BEFORE_SALE).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("no blanket attestation"),
"blanket attest must be rejected: {msg}"
);
assert_eq!(
event_count(&vault),
before,
"guard above the loop ⇒ NO append before it fires (no partial writes)"
);
assert_eq!(attestation_of(&vault, &disposal), None);
}
#[test]
fn void_revokes_a_persisted_selection() {
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 pre = cmd::optimize::run(&vault, &pp(), 2025, BEFORE_SALE).unwrap();
let fifo_pick = pre.per_disposal[0].current_selection.clone();
let opt_pick = pre.per_disposal[0].proposed_selection.clone();
let out = cmd::optimize::accept(&vault, &pp(), 2025, None, None, BEFORE_SALE).unwrap();
let (_, decision, _) = out.persisted[0].clone();
let after_accept = cmd::optimize::run(&vault, &pp(), 2025, BEFORE_SALE).unwrap();
assert_eq!(after_accept.per_disposal[0].current_selection, opt_pick);
cmd::reconcile::void(&vault, &pp(), &decision.canonical(), BEFORE_SALE).unwrap();
let after_void = cmd::optimize::run(&vault, &pp(), 2025, BEFORE_SALE).unwrap();
assert_eq!(
after_void.per_disposal[0].current_selection, fifo_pick,
"void must restore the prior (FIFO) baseline"
);
assert_ne!(
after_void.per_disposal[0].current_selection,
after_accept.per_disposal[0].current_selection,
"the persisted selection is no longer in force"
);
}
#[test]
fn accept_recompute_is_deterministic_and_disposal_scoped() {
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 a = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
let b = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
assert_eq!(
a.per_disposal[0].proposed_selection, b.per_disposal[0].proposed_selection,
"two recomputes must be byte-identical (NFR4)"
);
let before = event_count(&vault);
let bogus = EventId::decision(99_999);
let out = cmd::optimize::accept(
&vault,
&pp(),
2025,
Some(&bogus.canonical()),
None,
AFTER_SALE,
)
.unwrap();
assert!(
out.persisted.is_empty() && out.skipped.is_empty(),
"no disposal matched the scope"
);
assert_eq!(
event_count(&vault),
before,
"an unmatched scope appends nothing"
);
}
#[test]
fn void_clears_attestation_row_prevents_mislabel_as_attested_recording() {
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 proposal = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
let disposal = proposal.per_disposal[0].disposal.clone();
let fifo_pick = proposal.per_disposal[0].current_selection.clone(); let hifo_pick = proposal.per_disposal[0].proposed_selection.clone(); assert_ne!(
fifo_pick, hifo_pick,
"fixture must have a divergent proposal (proposed ≠ current)"
);
let out = cmd::optimize::accept(
&vault,
&pp(),
2025,
Some(&disposal.canonical()),
Some("I identified Lot B at the time of sale in my contemporaneous records"),
AFTER_SALE,
)
.unwrap();
assert_eq!(out.persisted.len(), 1);
let (_, decision, _) = out.persisted[0].clone();
assert_eq!(
attestation_of(&vault, &disposal).as_deref(),
Some("I identified Lot B at the time of sale in my contemporaneous records"),
"pre-void: attestation row must be present after accept+attest"
);
cmd::reconcile::void(&vault, &pp(), &decision.canonical(), AFTER_SALE).unwrap();
assert_eq!(
attestation_of(&vault, &disposal),
None,
"void of a LotSelection must clear the attestation row atomically — FAILS without the fix"
);
let zero_income = TaxProfile {
filing_status: FilingStatus::Single,
ordinary_taxable_income: dec!(0),
magi_excluding_crypto: dec!(0),
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),
};
cmd::tax::set_profile(&vault, &pp(), 2025, zero_income).unwrap();
let re = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
let row = &re.per_disposal[0];
assert_eq!(
row.proposed_selection, row.current_selection,
"zero-income profile: both lots cost $0 → no improvement → proposed == current (D ∈ unchanged)"
);
assert_eq!(
row.current_selection, fifo_pick,
"FIFO baseline (Lot A) is in force after void"
);
assert_ne!(
row.status,
ComplianceStatus::AttestedRecording,
"stale void must NOT produce AttestedRecording mislabel — FAILS without the fix"
);
assert_eq!(
row.status,
ComplianceStatus::NonCompliant,
"D has no in-force explicit selection and no method election → NonCompliant"
);
}
#[test]
fn bulk_void_clears_attestation_for_lotselection() {
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 proposal = cmd::optimize::run(&vault, &pp(), 2025, AFTER_SALE).unwrap();
let disposal = proposal.per_disposal[0].disposal.clone();
let out = cmd::optimize::accept(
&vault,
&pp(),
2025,
Some(&disposal.canonical()),
Some("contemporaneous records identify Lot B"),
AFTER_SALE,
)
.unwrap();
let (_, decision, _) = out.persisted[0].clone();
assert!(
attestation_of(&vault, &disposal).is_some(),
"pre-void: attestation row present after accept+attest"
);
let plan = cmd::reconcile::bulk_void_plan(&vault, &pp()).unwrap();
let row = plan
.rows
.iter()
.find(|r| r.target_event_id == decision)
.expect("the LotSelection decision must be a bulk-void candidate");
assert_eq!(
row.disposal_to_clear.as_ref(),
Some(&disposal),
"the plan precomputes disposal_to_clear = the LotSelection's disposal_event"
);
let targets: Vec<_> = plan
.rows
.iter()
.map(|r| (r.target_event_id.clone(), r.disposal_to_clear.clone()))
.collect();
cmd::reconcile::apply_bulk_void(&vault, &pp(), targets, AFTER_SALE).unwrap();
assert_eq!(
attestation_of(&vault, &disposal),
None,
"bulk-void of a LotSelection must clear its optimize_attest row inside the batch"
);
}