use btctax_cli::{cmd, Session};
use btctax_core::{BlockerKind, EventPayload};
use btctax_store::Passphrase;
use std::path::Path;
fn pp() -> Passphrase {
Passphrase::new("pw".into())
}
fn coinbase_receive_csv(dir: &Path) -> std::path::PathBuf {
let p = dir.join("cb_recv.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\
cb-recv,2025-03-01 12:00:00 UTC,Receive,BTC,0.05000000,USD,84000.00,,,,,bc1qsender,\r\n",
)
.unwrap();
p
}
fn coinbase_buy_csv(dir: &Path) -> std::path::PathBuf {
let p = dir.join("cb_buy.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\
cb-buy,2025-03-01 12:00:00 UTC,Buy,BTC,0.10000000,USD,84000.00,8400.00,8450.00,50.00,,,\r\n",
)
.unwrap();
p
}
fn vault_with(
dir: &Path,
csv: std::path::PathBuf,
want_transfer_in: bool,
) -> (std::path::PathBuf, String) {
let vault = dir.join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.join("k.asc")).unwrap();
cmd::import::run(&vault, &pp(), &[csv]).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
let want_ref = events
.iter()
.find(|e| {
if want_transfer_in {
matches!(e.payload, EventPayload::TransferIn(_))
} else {
matches!(e.payload, EventPayload::Acquire(_))
}
})
.expect("target event must exist")
.id
.canonical();
(vault, want_ref)
}
fn run_self_transfer(vault: &Path, args: &[&str]) -> (i32, String) {
let bin = env!("CARGO_BIN_EXE_btctax");
let output = std::process::Command::new(bin)
.arg("--vault")
.arg(vault.to_str().unwrap())
.arg("reconcile")
.arg("classify-inbound-self-transfer")
.args(args)
.env("BTCTAX_PASSPHRASE", "pw")
.output()
.expect("btctax binary must execute");
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let code = output.status.code().expect("process exits normally");
(code, stderr)
}
#[test]
fn classify_inbound_self_transfer_defaults_creates_lot_and_fires_advisory() {
let dir = tempfile::tempdir().unwrap();
let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);
let (code, stderr) = run_self_transfer(&vault, &[&in_ref]);
assert_eq!(code, 0, "defaults must exit 0; stderr: {stderr}");
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(state.lots.len(), 1, "a non-taxable lot is created");
assert_eq!(state.lots[0].usd_basis, rust_decimal_macros::dec!(0));
assert!(
!state.lots[0].basis_pending,
"$0 basis is computable — never pending"
);
assert!(state.income_recognized.is_empty(), "non-taxable: no income");
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::UnknownBasisInbound),
"UnknownBasisInbound must be cleared"
);
let zero_basis = state
.blockers
.iter()
.find(|b| b.kind == BlockerKind::SelfTransferInboundZeroBasis)
.expect("the zero-basis advisory must fire when --basis is omitted");
assert!(
zero_basis.detail.contains("btctax reconcile void")
&& zero_basis.detail.contains("in the TUI editor"),
"the void remedy is surface-neutral (CLI command + editor-qualified keybind): {}",
zero_basis.detail
);
assert!(
!zero_basis.detail.contains("(press 'v',"),
"no bare 'press v' TUI keybind leaks into CLI advisory text: {}",
zero_basis.detail
);
let defaulted_acq = state
.blockers
.iter()
.find(|b| b.kind == BlockerKind::SelfTransferInboundDefaultedAcquired)
.expect("omitting --acquired fires the defaulted-acquisition advisory");
assert!(
defaulted_acq.detail.contains("btctax reconcile void")
&& defaulted_acq.detail.contains("in the TUI editor"),
"the defaulted-acquired remedy is surface-neutral too: {}",
defaulted_acq.detail
);
assert!(
!defaulted_acq.detail.contains("press 'v', or run btctax"),
"no bare 'press v' in the defaulted-acquired advisory: {}",
defaulted_acq.detail
);
}
#[test]
fn classify_inbound_self_transfer_with_basis_and_acquired_has_no_advisory() {
let dir = tempfile::tempdir().unwrap();
let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);
let (code, stderr) = run_self_transfer(
&vault,
&[&in_ref, "--basis", "1234.56", "--acquired", "2015-01-02"],
);
assert_eq!(code, 0, "with overrides must exit 0; stderr: {stderr}");
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(state.lots.len(), 1);
assert_eq!(state.lots[0].usd_basis, rust_decimal_macros::dec!(1234.56));
assert_eq!(
state.lots[0].acquired_at,
time::macros::date!(2015 - 01 - 02)
);
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::SelfTransferInboundZeroBasis),
"a supplied basis must NOT fire the advisory"
);
}
#[test]
fn classify_inbound_self_transfer_negative_basis_equals_form_is_refused() {
let dir = tempfile::tempdir().unwrap();
let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);
let (code, stderr) = run_self_transfer(&vault, &[&in_ref, "--basis=-5000.00"]);
assert_ne!(
code, 0,
"a negative --basis (=-form) must be refused; stderr: {stderr}"
);
assert!(
stderr.contains("--basis") && stderr.contains(">= 0"),
"the refusal must name --basis + the >= 0 rule: {stderr}"
);
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state.lots.is_empty(),
"a refused classify must record no lot: {:?}",
state.lots
);
}
#[test]
fn classify_inbound_self_transfer_acquired_after_receipt_is_refused() {
let dir = tempfile::tempdir().unwrap();
let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);
let (code, stderr) = run_self_transfer(&vault, &[&in_ref, "--acquired", "2025-03-02"]);
assert_ne!(
code, 0,
"an --acquired date after the receipt must be refused; stderr: {stderr}"
);
assert!(
stderr.contains("--acquired") && stderr.contains("2025-03-01") && stderr.contains("receipt"),
"the refusal must name --acquired, the receipt date 2025-03-01, and the word receipt: {stderr}"
);
assert!(
stderr.contains("UTC"),
"the refusal must print the receipt tz basis (UTC): {stderr}"
);
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state.lots.is_empty(),
"a refused classify must record no lot: {:?}",
state.lots
);
}
#[test]
fn classify_inbound_self_transfer_acquired_same_day_as_receipt_is_allowed() {
let dir = tempfile::tempdir().unwrap();
let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);
let (code, stderr) = run_self_transfer(&vault, &[&in_ref, "--acquired", "2025-03-01"]);
assert_eq!(
code, 0,
"same-day --acquired must be allowed; stderr: {stderr}"
);
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(state.lots.len(), 1);
assert_eq!(
state.lots[0].acquired_at,
time::macros::date!(2025 - 03 - 01)
);
}
#[test]
fn manual_classify_inbound_self_transfer_also_long_term() {
let dir = tempfile::tempdir().unwrap();
let (vault, in_ref) = vault_with(dir.path(), coinbase_receive_csv(dir.path()), true);
let (code, stderr) = run_self_transfer(&vault, &[&in_ref]);
assert_eq!(code, 0, "defaults must exit 0; stderr: {stderr}");
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(state.lots.len(), 1);
assert_eq!(
state.lots[0].acquired_at,
time::macros::date!(2024 - 02 - 29)
);
assert!(
btctax_core::conventions::is_long_term(
state.lots[0].acquired_at,
time::macros::date!(2025 - 03 - 01)
),
"the manual default acquisition must be long-term relative to the receipt date"
);
assert!(
state
.blockers
.iter()
.any(|b| b.kind == BlockerKind::SelfTransferInboundDefaultedAcquired),
"the manual default (no --acquired) must fire the long-term-assumption advisory"
);
}
#[test]
fn classify_inbound_self_transfer_wrong_target_is_refused_at_record_time() {
let dir = tempfile::tempdir().unwrap();
let (vault, acquire_ref) = vault_with(dir.path(), coinbase_buy_csv(dir.path()), false);
let (code, stderr) = run_self_transfer(&vault, &[&acquire_ref]);
assert_ne!(
code, 0,
"a non-TransferIn target must be refused; stderr: {stderr}"
);
assert!(
stderr.contains("non-TransferIn") && stderr.contains("events list"),
"the refusal must name the wrong-type conflict + the events-list remedy: {stderr}"
);
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::DecisionConflict),
"a refused decision must append nothing (no DecisionConflict at verify)"
);
assert!(
!btctax_core::persistence::load_all(s.conn())
.unwrap()
.iter()
.any(|e| matches!(e.payload, EventPayload::ClassifyInbound(_))),
"the wrong-target classify must not be recorded"
);
}