use btctax_cli::{cmd, eventref, Session};
use btctax_core::{EventPayload, InboundClass, TransferTarget};
use btctax_store::Passphrase;
use std::path::Path;
fn pp() -> Passphrase {
Passphrase::new("pw".into())
}
fn now() -> time::OffsetDateTime {
time::macros::datetime!(2026 - 01 - 01 0:00 UTC)
}
const HEADER: &str = "\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";
fn vault_buy_send_recv(dir: &Path) -> std::path::PathBuf {
let vault = dir.join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.join("k.asc")).unwrap();
let csv = dir.join("cb.csv");
std::fs::write(
&csv,
format!(
"{HEADER}\
cb-buy,2025-01-15 12:00:00 UTC,Buy,BTC,0.10000000,USD,84000.00,8400.00,8450.00,50.00,,,\r\n\
cb-send,2025-06-20 12:00:00 UTC,Send,BTC,0.03000000,USD,105000.00,,,,,,bc1qdest\r\n\
cb-recv,2025-03-01 12:00:00 UTC,Receive,BTC,0.05000000,USD,84000.00,,,,,bc1qsender,\r\n"
),
)
.unwrap();
cmd::import::run(&vault, &pp(), &[csv]).unwrap();
vault
}
#[test]
fn events_list_reports_decidable_events_and_decision_status() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_buy_send_recv(dir.path());
let rows = cmd::inspect::events_list(&vault, &pp()).unwrap();
let kinds: Vec<&str> = rows.iter().map(|r| r.kind).collect();
assert!(
kinds.contains(&"transfer-in") && kinds.contains(&"transfer-out"),
"the Send + Receive must be listed as decidable; got kinds {kinds:?}"
);
assert!(
!kinds.contains(&"acquire"),
"a Buy (Acquire) is fully determined — NOT decidable; got kinds {kinds:?}"
);
assert!(
rows.iter().all(|r| r.decision_ref.is_none()),
"nothing is decided yet: {:?}",
rows.iter().map(|r| &r.decision_ref).collect::<Vec<_>>()
);
let in_ref = rows
.iter()
.find(|r| r.kind == "transfer-in")
.unwrap()
.reff
.clone();
cmd::reconcile::classify_inbound(
&vault,
&pp(),
&in_ref,
InboundClass::SelfTransferMine {
basis: None,
acquired_at: None,
},
now(),
)
.unwrap();
let rows2 = cmd::inspect::events_list(&vault, &pp()).unwrap();
let ti = rows2.iter().find(|r| r.kind == "transfer-in").unwrap();
assert_eq!(ti.reff, in_ref, "the ref is stable across the decision");
assert!(
ti.decision_ref
.as_deref()
.is_some_and(|d| d.starts_with("decision|")),
"the decided inbound must now carry its decision ref; got {:?}",
ti.decision_ref
);
let to = rows2.iter().find(|r| r.kind == "transfer-out").unwrap();
assert!(to.decision_ref.is_none(), "the Send is still decidable");
}
#[test]
fn events_list_transfer_link_decides_both_legs() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let csv = dir.path().join("cb.csv");
std::fs::write(
&csv,
format!(
"{HEADER}\
cb-buy,2025-01-15 12:00:00 UTC,Buy,BTC,0.05000000,USD,84000.00,4200.00,4250.00,50.00,,,\r\n\
cb-send,2025-06-20 12:00:00 UTC,Send,BTC,0.05000000,USD,105000.00,,,,,,bc1qdest\r\n\
cb-recv,2025-06-21 12:00:00 UTC,Receive,BTC,0.05000000,USD,105000.00,,,,,bc1qsender,\r\n"
),
)
.unwrap();
cmd::import::run(&vault, &pp(), &[csv]).unwrap();
let rows = cmd::inspect::events_list(&vault, &pp()).unwrap();
let out_ref = rows
.iter()
.find(|r| r.kind == "transfer-out")
.unwrap()
.reff
.clone();
let in_ref = rows
.iter()
.find(|r| r.kind == "transfer-in")
.unwrap()
.reff
.clone();
let in_id = eventref::parse_event_id(&in_ref).unwrap();
cmd::reconcile::link_transfer(
&vault,
&pp(),
&out_ref,
TransferTarget::InEvent(in_id),
now(),
)
.unwrap();
let rows2 = cmd::inspect::events_list(&vault, &pp()).unwrap();
let ti = rows2.iter().find(|r| r.kind == "transfer-in").unwrap();
let to = rows2.iter().find(|r| r.kind == "transfer-out").unwrap();
assert!(
ti.decision_ref
.as_deref()
.is_some_and(|d| d.starts_with("decision|")),
"the linked IN-leg must be decided (not decidable); got {:?}",
ti.decision_ref
);
assert!(
to.decision_ref.is_some(),
"the out-leg is decided by the link"
);
assert_eq!(
ti.decision_ref, to.decision_ref,
"both legs carry the SAME link decision ref"
);
}
#[test]
fn events_list_void_returns_to_decidable_then_redecide() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_buy_send_recv(dir.path());
let in_ref = cmd::inspect::events_list(&vault, &pp())
.unwrap()
.iter()
.find(|r| r.kind == "transfer-in")
.unwrap()
.reff
.clone();
let d1 = cmd::reconcile::classify_inbound(
&vault,
&pp(),
&in_ref,
InboundClass::SelfTransferMine {
basis: None,
acquired_at: None,
},
now(),
)
.unwrap();
let d1_ref = d1.canonical();
let ti = cmd::inspect::events_list(&vault, &pp()).unwrap();
let ti = ti.iter().find(|r| r.kind == "transfer-in").unwrap();
assert_eq!(
ti.decision_ref.as_deref(),
Some(d1_ref.as_str()),
"decided by d1"
);
cmd::reconcile::void(&vault, &pp(), &d1_ref, now()).unwrap();
let rows = cmd::inspect::events_list(&vault, &pp()).unwrap();
let ti = rows.iter().find(|r| r.kind == "transfer-in").unwrap();
assert!(
ti.decision_ref.is_none(),
"a voided decision must return the row to decidable; got {:?}",
ti.decision_ref
);
let d2 = cmd::reconcile::classify_inbound(
&vault,
&pp(),
&in_ref,
InboundClass::SelfTransferMine {
basis: None,
acquired_at: None,
},
now(),
)
.unwrap();
let rows = cmd::inspect::events_list(&vault, &pp()).unwrap();
let ti = rows.iter().find(|r| r.kind == "transfer-in").unwrap();
assert_eq!(
ti.decision_ref.as_deref(),
Some(d2.canonical().as_str()),
"re-decide must show the survivor, not the voided d1"
);
assert_ne!(ti.decision_ref.as_deref(), Some(d1_ref.as_str()));
}
#[test]
fn a_listed_ref_pastes_verbatim_into_reclassify_outflow() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_buy_send_recv(dir.path());
let bin = env!("CARGO_BIN_EXE_btctax");
let out = std::process::Command::new(bin)
.args(["--vault", vault.to_str().unwrap(), "events", "list"])
.env("BTCTAX_PASSPHRASE", "pw")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0), "events list must exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
let ref_paste = stdout
.lines()
.find(|l| l.contains("transfer-out"))
.and_then(|l| l.split_whitespace().next())
.expect("a transfer-out row with a ref")
.to_owned();
let re = std::process::Command::new(bin)
.args([
"--vault",
vault.to_str().unwrap(),
"reconcile",
"reclassify-outflow",
&ref_paste,
"--as-kind",
"sell",
"--amount",
"3100",
])
.env("BTCTAX_PASSPHRASE", "pw")
.output()
.unwrap();
assert_eq!(
re.status.code(),
Some(0),
"a listed ref must be accepted verbatim by reclassify-outflow; stderr: {}",
String::from_utf8_lossy(&re.stderr)
);
}
#[test]
fn pseudo_defaulted_event_lists_as_decidable() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let csv = dir.path().join("recv.csv");
std::fs::write(
&csv,
format!(
"{HEADER}\
cb-recv,2025-03-01 12:00:00 UTC,Receive,BTC,0.05000000,USD,84000.00,,,,,bc1qsender,\r\n"
),
)
.unwrap();
cmd::import::run(&vault, &pp(), &[csv]).unwrap();
cmd::reconcile::pseudo_set_mode(&vault, &pp(), true).unwrap();
let rows = cmd::inspect::events_list(&vault, &pp()).unwrap();
let ti = rows
.iter()
.find(|r| r.kind == "transfer-in")
.expect("the unknown-basis inbound must be listed");
assert!(
ti.decision_ref.is_none(),
"a pseudo-defaulted event must list as DECIDABLE (no persisted decision); got {:?}",
ti.decision_ref
);
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
assert!(
!events
.iter()
.any(|e| matches!(e.payload, EventPayload::ClassifyInbound(_))),
"pseudo mode must persist no ClassifyInbound"
);
}