mod fixtures;
use btctax_cli::{cmd, eventref, CliError, Session};
use btctax_core::{
AllocMethod, BlockerKind, DisposeKind, EventId, EventPayload, FeeTreatment, FmvStatus,
InboundClass, Income, IncomeKind, LotMethod, ManualFmv, OutflowClass, Source, SourceRef,
TransferTarget, WalletId,
};
use btctax_store::Passphrase;
use rust_decimal_macros::dec;
use time::macros::{date, datetime};
fn pp() -> Passphrase {
Passphrase::new("pw".into())
}
fn now() -> time::OffsetDateTime {
datetime!(2026-02-01 12:00:00 UTC) }
const STUB_PRICES_CSV: &str = "date,usd_close\n\
2024-01-15,42500.00\n\
2024-02-01,43100.50\n\
2025-01-10,91000.00\n\
2025-03-01,84000.00\n\
2025-03-02,84250.25\n\
2025-06-15,67500.00\n";
fn open_stub(vault: &std::path::Path) -> Session {
let mut s = Session::open(vault, &pp()).unwrap();
s.set_prices(Box::new(
btctax_adapters::BundledPrices::from_csv_str(STUB_PRICES_CSV).unwrap(),
));
s
}
#[test]
fn income_fmv_missing_batch_stays_blocked_under_bundled() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("m5.pgp");
{
let mut session = Session::create(&vault, &pp()).unwrap();
let batch = fixtures::income_fmv_missing_batch(27);
btctax_core::persistence::append_import_batch(session.conn(), &batch).unwrap();
session.save().unwrap();
}
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
let fmv_missing = state
.blockers
.iter()
.filter(|b| b.kind == BlockerKind::FmvMissing)
.count();
assert_eq!(
fmv_missing, 27,
"A alone leaves all 27 income events FmvMissing (idempotent import; R0-I1)"
);
assert_eq!(
state.income_recognized.len(),
0,
"none recognized while FMV is pending"
);
}
fn coinbase_with_receive(dir: &std::path::Path) -> std::path::PathBuf {
let p = dir.join("cb_recv.csv");
std::fs::write(&p, "\r\nTransactions\r\nUser,x\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 vault_with_pending(dir: &std::path::Path) -> (std::path::PathBuf, String) {
let vault = dir.join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.join("k.asc")).unwrap();
let file = fixtures::coinbase_buy_sell_send(dir);
cmd::import::run(&vault, &pp(), &[file]).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
let out_ref = state.pending_reconciliation[0].event.canonical();
(vault, out_ref)
}
#[test]
fn classify_inbound_income_resolves_unknown_basis() {
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(), &[coinbase_with_receive(dir.path())]).unwrap();
let in_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.find(|e| matches!(e.payload, EventPayload::TransferIn(_)))
.unwrap()
.id
.canonical()
};
let class = InboundClass::Income {
kind: IncomeKind::Reward,
fmv: Some(btctax_cli::eventref::parse_usd_arg("4200.00").unwrap()),
business: false,
};
cmd::reconcile::classify_inbound(&vault, &pp(), &in_ref, class, now()).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(state.income_recognized.len(), 1);
assert!(state
.blockers
.iter()
.all(|b| b.kind != btctax_core::BlockerKind::UnknownBasisInbound));
}
#[test]
fn link_transfer_clears_pending_and_relocates_lots() {
let dir = tempfile::tempdir().unwrap();
let (vault, out_ref) = vault_with_pending(dir.path());
let target =
TransferTarget::Wallet(btctax_cli::eventref::parse_wallet_id("self:cold").unwrap());
let id = cmd::reconcile::link_transfer(&vault, &pp(), &out_ref, target, now()).unwrap();
assert!(matches!(id, btctax_core::EventId::Decision { seq: 1 }));
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(state.pending_reconciliation.is_empty());
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
assert!(events
.iter()
.any(|e| matches!(e.payload, EventPayload::TransferLink(_))));
}
#[test]
fn reclassify_outflow_to_sell_creates_a_disposal() {
let dir = tempfile::tempdir().unwrap();
let (vault, out_ref) = vault_with_pending(dir.path());
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::Dispose {
kind: DisposeKind::Sell,
},
btctax_cli::eventref::parse_usd_arg("2000.00").unwrap(),
Some(btctax_cli::eventref::parse_usd_arg("3.00").unwrap()),
None,
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(state.pending_reconciliation.is_empty()); assert_eq!(state.disposals.len(), 2); }
#[test]
fn reclassify_outflow_amount_as_sats_warns_on_stderr() {
let bin = env!("CARGO_BIN_EXE_btctax");
let run = |amount: &str| -> (Option<i32>, String) {
let dir = tempfile::tempdir().unwrap();
let (vault, out_ref) = vault_with_pending(dir.path());
let out = std::process::Command::new(bin)
.args([
"--vault",
vault.to_str().unwrap(),
"reconcile",
"reclassify-outflow",
&out_ref,
"--as-kind",
"sell",
"--amount",
amount,
])
.env("BTCTAX_PASSPHRASE", "pw")
.output()
.expect("btctax binary must execute");
(
out.status.code(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
};
let (code, stderr) = run("3000000");
assert_eq!(
code,
Some(0),
"reclassify must still succeed; stderr: {stderr}"
);
assert!(
stderr.contains("warning")
&& stderr.to_lowercase().contains("sats")
&& stderr.contains("--amount"),
"a sats-as-dollars --amount must warn on stderr: {stderr}"
);
let (code2, stderr2) = run("2000");
assert_eq!(code2, Some(0), "reclassify must succeed; stderr: {stderr2}");
assert!(
!stderr2.contains("warning"),
"a plausible --amount must not warn: {stderr2}"
);
}
#[test]
fn reclassify_outflow_to_gift_creates_a_removal() {
let dir = tempfile::tempdir().unwrap();
let (vault, out_ref) = vault_with_pending(dir.path());
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::GiftOut,
btctax_cli::eventref::parse_usd_arg("2040.00").unwrap(),
None,
None,
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(state.pending_reconciliation.is_empty()); assert_eq!(state.removals.len(), 1); }
#[test]
fn reclassify_outflow_to_donate_creates_a_removal_with_appraisal_flag() {
let dir = tempfile::tempdir().unwrap();
let (vault, out_ref) = vault_with_pending(dir.path());
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::Donate {
appraisal_required: true,
},
btctax_cli::eventref::parse_usd_arg("2040.00").unwrap(),
None,
None,
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(state.pending_reconciliation.is_empty()); assert_eq!(state.removals.len(), 1);
assert!(state.removals[0].appraisal_required);
}
#[test]
fn reclassify_outflow_gift_with_donee_populates_removal_donee() {
let dir = tempfile::tempdir().unwrap();
let (vault, out_ref) = vault_with_pending(dir.path());
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::GiftOut,
btctax_cli::eventref::parse_usd_arg("2040.00").unwrap(),
None,
Some("Alice".to_string()),
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(state.removals.len(), 1);
assert_eq!(
state.removals[0].donee,
Some("Alice".to_string()),
"GiftOut with donee 'Alice' must carry donee on the Removal"
);
}
#[test]
fn reclassify_outflow_donate_with_donee_populates_removal_donee() {
let dir = tempfile::tempdir().unwrap();
let (vault, out_ref) = vault_with_pending(dir.path());
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::Donate {
appraisal_required: false,
},
btctax_cli::eventref::parse_usd_arg("2040.00").unwrap(),
None,
Some("Charity X".to_string()),
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(state.removals.len(), 1);
assert_eq!(
state.removals[0].donee,
Some("Charity X".to_string()),
"Donate with donee 'Charity X' must carry donee on the Removal"
);
}
#[test]
fn reclassify_outflow_without_donee_has_none_donee() {
let dir = tempfile::tempdir().unwrap();
let (vault, out_ref) = vault_with_pending(dir.path());
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&out_ref,
OutflowClass::GiftOut,
btctax_cli::eventref::parse_usd_arg("2040.00").unwrap(),
None,
None, now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(state.removals.len(), 1);
assert_eq!(
state.removals[0].donee, None,
"GiftOut without donee must have donee: None on the Removal"
);
}
#[test]
fn void_drops_a_revocable_decision() {
let dir = tempfile::tempdir().unwrap();
let (vault, out_ref) = vault_with_pending(dir.path());
let link = cmd::reconcile::link_transfer(
&vault,
&pp(),
&out_ref,
TransferTarget::Wallet(btctax_cli::eventref::parse_wallet_id("self:cold").unwrap()),
now(),
)
.unwrap();
cmd::reconcile::void(&vault, &pp(), &link.canonical(), now()).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(state.pending_reconciliation.len(), 1);
}
#[test]
fn set_fmv_clears_fmv_missing_blocker_and_recognizes_income() {
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::UtcOffset;
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let income_id = EventId::import(Source::River, SourceRef::new("river-income-001"));
let income_event = LedgerEvent {
id: income_id.clone(),
utc_timestamp: datetime!(2025-04-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(WalletId::Exchange {
provider: "river".into(),
account: "main".into(),
}),
payload: EventPayload::Income(Income {
sat: 50_000,
usd_fmv: None,
fmv_status: FmvStatus::Missing,
kind: IncomeKind::Interest,
business: false,
}),
};
{
let mut s = Session::open(&vault, &pp()).unwrap();
append_import_batch(s.conn(), &[income_event]).unwrap();
s.save().unwrap();
}
{
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.any(|b| b.kind == BlockerKind::FmvMissing),
"FmvMissing blocker must be present before set-fmv: {:?}",
state.blockers
);
assert!(
state.income_recognized.is_empty(),
"income must NOT be recognized while FMV is missing"
);
}
let manual_fmv = btctax_cli::eventref::parse_usd_arg("4200.00").unwrap();
let decision_id =
cmd::reconcile::set_fmv(&vault, &pp(), &income_id.canonical(), manual_fmv, now()).unwrap();
{
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
assert!(
events
.iter()
.any(|e| e.id == decision_id && matches!(e.payload, EventPayload::ManualFmv(_))),
"ManualFmv decision must be in the event log"
);
}
{
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::FmvMissing),
"FmvMissing blocker must be CLEARED after set-fmv: {:?}",
state.blockers
);
assert_eq!(
state.income_recognized.len(),
1,
"income must be recognized at the manual FMV"
);
assert_eq!(
state.income_recognized[0].usd_fmv, manual_fmv,
"income FMV must equal the manual value supplied to set-fmv"
);
assert_eq!(
state.income_recognized[0].kind,
IncomeKind::Interest,
"income kind must be preserved"
);
}
}
fn coinbase_with_order(dir: &std::path::Path) -> std::path::PathBuf {
let p = dir.join("cb_order.csv");
std::fs::write(&p, "\r\nTransactions\r\nUser,x\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-ord,2025-03-01 12:00:00 UTC,Order,BTC,0.01000000,USD,84000.00,840.00,845.00,5.00,,,\r\n").unwrap();
p
}
fn coinbase_with_order_v2(dir: &std::path::Path) -> std::path::PathBuf {
let p = dir.join("cb_order_v2.csv");
std::fs::write(&p, "\r\nTransactions\r\nUser,x\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-ord,2025-03-01 12:00:00 UTC,Order,BTC,0.01000000,USD,84000.00,840.00,860.00,20.00,,,\r\n").unwrap();
p
}
#[test]
fn classify_raw_resolves_an_unclassified_row() {
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(), &[coinbase_with_order(dir.path())]).unwrap();
let target = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.find(|e| matches!(e.payload, EventPayload::Unclassified(_)))
.unwrap()
.id
.canonical()
};
let json = r#"{"Acquire":{"sat":1000000,"usd_cost":"845.00","fee_usd":"5.00","basis_source":"ComputedFromCost"}}"#;
cmd::reconcile::classify_raw(&vault, &pp(), &target, json, now()).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(state
.blockers
.iter()
.all(|b| b.kind != btctax_core::BlockerKind::Unclassified));
assert_eq!(state.lots.len(), 1);
}
#[test]
fn classify_raw_rejects_decision_payload() {
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(), &[coinbase_with_order(dir.path())]).unwrap();
let target = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.find(|e| matches!(e.payload, EventPayload::Unclassified(_)))
.unwrap()
.id
.canonical()
};
let decision = EventPayload::ManualFmv(ManualFmv {
event: EventId::decision(1),
usd_fmv: dec!(100.00),
});
let bad_json = serde_json::to_string(&decision).unwrap();
assert!(!decision.is_imported(), "ManualFmv must not be imported");
let err = cmd::reconcile::classify_raw(&vault, &pp(), &target, &bad_json, now())
.unwrap_err()
.to_string();
assert!(
err.contains("imported"),
"expected is_imported guard error, got: {}",
err
);
}
#[test]
fn classify_raw_rejects_malformed_json() {
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(), &[coinbase_with_order(dir.path())]).unwrap();
let target = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.find(|e| matches!(e.payload, EventPayload::Unclassified(_)))
.unwrap()
.id
.canonical()
};
let malformed = "not json";
let err = cmd::reconcile::classify_raw(&vault, &pp(), &target, malformed, now())
.unwrap_err()
.to_string();
assert!(
err.contains("bad --payload-json"),
"expected parse error, got: {}",
err
);
}
#[test]
fn accept_conflict_clears_import_conflict_blocker() {
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(), &[coinbase_with_order(dir.path())]).unwrap();
cmd::import::run(&vault, &pp(), &[coinbase_with_order_v2(dir.path())]).unwrap();
let conflict_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.find(|e| matches!(e.payload, EventPayload::ImportConflict(_)))
.expect("ImportConflict must exist after re-import with changed content")
.id
.canonical()
};
cmd::reconcile::accept_conflict(&vault, &pp(), &conflict_ref, now()).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::ImportConflict),
"ImportConflict blocker must be cleared after accept"
);
}
#[test]
fn reject_conflict_clears_import_conflict_blocker() {
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(), &[coinbase_with_order(dir.path())]).unwrap();
cmd::import::run(&vault, &pp(), &[coinbase_with_order_v2(dir.path())]).unwrap();
let conflict_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.find(|e| matches!(e.payload, EventPayload::ImportConflict(_)))
.expect("ImportConflict must exist after re-import with changed content")
.id
.canonical()
};
cmd::reconcile::reject_conflict(&vault, &pp(), &conflict_ref, now()).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::ImportConflict),
"ImportConflict blocker must be cleared after reject"
);
}
fn bulk_conflict_fixture(dir: &std::path::Path) -> (std::path::PathBuf, [EventId; 2]) {
use btctax_core::event::{Acquire, BasisSource};
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::UtcOffset;
let vault = dir.join("bulk_conflict.pgp");
let mut session = Session::create(&vault, &pp()).unwrap();
let wallet = WalletId::Exchange {
provider: "coinbase".into(),
account: "main".into(),
};
let t1 = EventId::import(Source::Coinbase, SourceRef::new("cf-1"));
let t2 = EventId::import(Source::Coinbase, SourceRef::new("cf-2"));
let acq = |sat: i64, cost| {
EventPayload::Acquire(Acquire {
sat,
usd_cost: cost,
fee_usd: dec!(0),
basis_source: BasisSource::ComputedFromCost,
})
};
let mk = |id: &EventId, payload| LedgerEvent {
id: id.clone(),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet.clone()),
payload,
};
append_import_batch(
session.conn(),
&[
mk(&t1, acq(1_000_000, dec!(30000))),
mk(&t2, acq(500_000, dec!(20000))),
],
)
.unwrap();
session.save().unwrap();
append_import_batch(
session.conn(),
&[
mk(&t1, acq(1_000_000, dec!(31000))),
mk(&t2, acq(500_000, dec!(21000))),
],
)
.unwrap();
session.save().unwrap();
(vault, [t1, t2])
}
#[test]
fn bulk_resolve_plan_lists_unresolved_conflicts() {
let dir = tempfile::tempdir().unwrap();
let (vault, [t1, t2]) = bulk_conflict_fixture(dir.path());
let plan = cmd::reconcile::bulk_resolve_conflict_plan(&vault, &pp()).unwrap();
assert_eq!(plan.rows.len(), 2, "both live conflicts listed");
let targets: Vec<_> = plan.rows.iter().map(|r| r.target.clone()).collect();
assert!(
targets.contains(&t1) && targets.contains(&t2),
"rows join to the two import targets"
);
for r in &plan.rows {
match (&r.current_payload, &r.new_payload) {
(EventPayload::Acquire(cur), EventPayload::Acquire(new)) => {
assert_ne!(cur.usd_cost, new.usd_cost, "current cost != new cost");
}
other => panic!("expected Acquire current/new payloads, got {other:?}"),
}
assert_eq!(
r.new_fingerprint.len(),
8,
"8-char fingerprint disambiguator"
);
assert_ne!(r.conflict_event, r.target, "conflict event != target");
}
let conflict1 = plan
.rows
.iter()
.find(|r| r.target == t1)
.unwrap()
.conflict_event
.canonical();
cmd::reconcile::accept_conflict(&vault, &pp(), &conflict1, now()).unwrap();
let plan2 = cmd::reconcile::bulk_resolve_conflict_plan(&vault, &pp()).unwrap();
assert_eq!(plan2.rows.len(), 1, "resolved conflict excluded from plan");
assert_eq!(
plan2.rows[0].target, t2,
"only the unresolved conflict remains"
);
}
#[test]
fn bulk_resolve_cli_accept_adopts_new() {
let dir = tempfile::tempdir().unwrap();
let (vault, _) = bulk_conflict_fixture(dir.path());
let plan = cmd::reconcile::bulk_resolve_conflict_plan(&vault, &pp()).unwrap();
let conflict_events: Vec<_> = plan.rows.iter().map(|r| r.conflict_event.clone()).collect();
let n =
cmd::reconcile::apply_bulk_accept_conflicts(&vault, &pp(), conflict_events, now()).unwrap();
assert_eq!(n, 2, "two conflicts accepted");
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::ImportConflict),
"all ImportConflict blockers cleared after bulk accept"
);
let total_basis: btctax_core::Usd = state.lots.iter().map(|l| l.usd_basis).sum();
assert_eq!(
total_basis,
dec!(52000),
"accept adopts each new_payload (higher v2 costs)"
);
}
#[test]
fn bulk_resolve_cli_reject_keeps_current() {
let dir = tempfile::tempdir().unwrap();
let (vault, _) = bulk_conflict_fixture(dir.path());
let plan = cmd::reconcile::bulk_resolve_conflict_plan(&vault, &pp()).unwrap();
let conflict_events: Vec<_> = plan.rows.iter().map(|r| r.conflict_event.clone()).collect();
let n =
cmd::reconcile::apply_bulk_reject_conflicts(&vault, &pp(), conflict_events, now()).unwrap();
assert_eq!(n, 2, "two conflicts rejected");
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::ImportConflict),
"all ImportConflict blockers cleared after bulk reject"
);
let total_basis: btctax_core::Usd = state.lots.iter().map(|l| l.usd_basis).sum();
assert_eq!(
total_basis,
dec!(50000),
"reject keeps each current payload (original v1 costs)"
);
}
#[test]
fn bulk_resolve_cli_dry_run_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let (vault, _) = bulk_conflict_fixture(dir.path());
let _preview = cmd::reconcile::bulk_resolve_conflict_plan(&vault, &pp()).unwrap();
let plan2 = cmd::reconcile::bulk_resolve_conflict_plan(&vault, &pp()).unwrap();
assert_eq!(
plan2.rows.len(),
2,
"the read/preview phase resolves nothing (dry-run writes nothing)"
);
}
fn vault_with_two_classified_inbounds(dir: &std::path::Path) -> (std::path::PathBuf, [String; 2]) {
let vault = dir.join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.join("k.asc")).unwrap();
let p = dir.join("cb_two_recv.csv");
std::fs::write(&p, "\r\nTransactions\r\nUser,x\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-1,2025-03-01 12:00:00 UTC,Receive,BTC,0.05000000,USD,84000.00,,,,,bc1qsender,\r\n\
cb-recv-2,2025-04-01 12:00:00 UTC,Receive,BTC,0.03000000,USD,86000.00,,,,,bc1qsender,\r\n").unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
let ti_refs: Vec<String> = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.filter(|e| matches!(e.payload, EventPayload::TransferIn(_)))
.map(|e| e.id.canonical())
.collect()
};
assert_eq!(ti_refs.len(), 2, "two TransferIns imported");
for ti in &ti_refs {
let class = InboundClass::Income {
kind: IncomeKind::Reward,
fmv: Some(btctax_cli::eventref::parse_usd_arg("4200.00").unwrap()),
business: false,
};
cmd::reconcile::classify_inbound(&vault, &pp(), ti, class, now()).unwrap();
}
(vault, [ti_refs[0].clone(), ti_refs[1].clone()])
}
#[test]
fn bulk_void_dry_run_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let (vault, _) = vault_with_two_classified_inbounds(dir.path());
let plan = cmd::reconcile::bulk_void_plan(&vault, &pp()).unwrap();
assert_eq!(
plan.rows.len(),
2,
"both ClassifyInbound decisions voidable"
);
assert!(
plan.rows.iter().all(|r| r.disposal_to_clear.is_none()),
"ClassifyInbound targets carry no disposal_to_clear"
);
let plan2 = cmd::reconcile::bulk_void_plan(&vault, &pp()).unwrap();
assert_eq!(
plan2.rows.len(),
2,
"the read/preview phase voids nothing (dry-run writes nothing)"
);
}
#[test]
fn bulk_void_plan_omits_effective_allocation() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb_pre.csv");
std::fs::write(&p, "\r\nTransactions\r\nUser,x\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-pre,2024-01-15 12:00:00 UTC,Buy,BTC,0.20000000,USD,42500.00,8500.00,8550.00,50.00,,,\r\n").unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
let alloc = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
AllocMethod::ActualPosition,
false,
now(),
)
.unwrap();
{
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state.blockers.iter().all(|b| {
!(matches!(
b.kind,
BlockerKind::SafeHarborTimebar | BlockerKind::SafeHarborUnconservable
) && b.event.as_ref() == Some(&alloc))
}),
"the allocation must be effective (Path B) for this KAT"
);
}
let plan = cmd::reconcile::bulk_void_plan(&vault, &pp()).unwrap();
assert!(
!plan.rows.iter().any(|r| r.target_event_id == alloc),
"an effective allocation must NOT be a bulk-void candidate (#7)"
);
assert!(
plan.rows.is_empty(),
"the effective allocation is the only decision → plan is empty"
);
}
#[test]
fn bulk_void_plan_includes_inert_allocation() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_timebarred(dir.path());
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
let alloc = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
AllocMethod::ActualPosition,
false,
now(),
)
.unwrap();
{
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state.blockers.iter().any(|b| {
b.kind == BlockerKind::SafeHarborTimebar && b.event.as_ref() == Some(&alloc)
}),
"the allocation must be time-barred (inert) for this KAT"
);
}
let plan = cmd::reconcile::bulk_void_plan(&vault, &pp()).unwrap();
assert!(
plan.rows.iter().any(|r| r.target_event_id == alloc),
"an inert (time-barred) allocation MUST stay a bulk-void candidate"
);
}
#[test]
fn bulk_void_cli_reexposes_inbound_blockers() {
let dir = tempfile::tempdir().unwrap();
let (vault, _) = vault_with_two_classified_inbounds(dir.path());
{
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::UnknownBasisInbound),
"both inbounds classified → no UnknownBasisInbound pre-void"
);
}
let plan = cmd::reconcile::bulk_void_plan(&vault, &pp()).unwrap();
let targets: Vec<_> = plan
.rows
.iter()
.map(|r| (r.target_event_id.clone(), r.disposal_to_clear.clone()))
.collect();
let n = cmd::reconcile::apply_bulk_void(&vault, &pp(), targets, now()).unwrap();
assert_eq!(n, 2, "two decisions voided");
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
let unknown = state
.blockers
.iter()
.filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
.count();
assert_eq!(
unknown, 2,
"voiding the two ClassifyInbounds re-exposes both UnknownBasisInbound blockers"
);
}
#[test]
fn safe_harbor_allocate_seeds_full_pre2025_residue_even_after_a_2025_disposal() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb.csv");
std::fs::write(&p, "\r\nTransactions\r\nUser,x\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-pre,2024-01-15 12:00:00 UTC,Buy,BTC,0.20000000,USD,42500.00,8500.00,8550.00,50.00,,,\r\n\
cb-sell,2025-06-01 12:00:00 UTC,Sell,BTC,0.05000000,USD,90000.00,4500.00,4490.00,10.00,,,\r\n").unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
let id = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
AllocMethod::ActualPosition,
true,
now(),
)
.unwrap();
assert!(matches!(id, btctax_core::EventId::Decision { .. }));
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
let alloc = events
.iter()
.find_map(|e| match &e.payload {
EventPayload::SafeHarborAllocation(a) => Some(a.clone()),
_ => None,
})
.expect("allocation persisted");
assert_eq!(alloc.lots.len(), 1);
assert_eq!(alloc.lots[0].sat, 20_000_000);
assert!(alloc.timely_allocation_attested);
assert_eq!(alloc.as_of_date, btctax_core::conventions::TRANSITION_DATE);
let (state, _) = s.project().unwrap();
assert!(state
.blockers
.iter()
.all(|b| b.kind != btctax_core::BlockerKind::SafeHarborUnconservable));
}
fn vault_timebarred(dir: &std::path::Path) -> std::path::PathBuf {
let vault = dir.join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.join("k.asc")).unwrap();
let p = dir.join("cb.csv");
std::fs::write(&p, "\r\nTransactions\r\nUser,x\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-pre,2024-01-15 12:00:00 UTC,Buy,BTC,0.20000000,USD,42500.00,8500.00,8550.00,50.00,,,\r\n\
cb-sell,2025-06-01 12:00:00 UTC,Sell,BTC,0.05000000,USD,90000.00,4500.00,4490.00,10.00,,,\r\n").unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
vault
}
#[test]
fn safe_harbor_attest_cures_a_timebarred_allocation_excluding_voided_priors() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_timebarred(dir.path());
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
let a1 = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
AllocMethod::ActualPosition,
false,
now(),
)
.unwrap();
cmd::reconcile::void(&vault, &pp(), &a1.canonical(), now()).unwrap();
let _a2 = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
AllocMethod::ActualPosition,
false,
now(),
)
.unwrap();
cmd::reconcile::safe_harbor_attest(&vault, &pp(), now())
.unwrap_or_else(|e| panic!("attest should succeed: {e}"));
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(state.lots.iter().any(|l| matches!(
l.basis_source,
btctax_core::BasisSource::SafeHarborAllocated
)));
assert!(state
.blockers
.iter()
.all(|b| b.kind != btctax_core::BlockerKind::SafeHarborUnconservable));
}
#[test]
fn safe_harbor_attest_refuses_an_already_effective_allocation() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb_pre.csv");
std::fs::write(&p, "\r\nTransactions\r\nUser,x\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-pre,2024-01-15 12:00:00 UTC,Buy,BTC,0.20000000,USD,42500.00,8500.00,8550.00,50.00,,,\r\n").unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
cmd::reconcile::safe_harbor_allocate(&vault, &pp(), AllocMethod::ActualPosition, false, now())
.unwrap();
let err = cmd::reconcile::safe_harbor_attest(&vault, &pp(), now()).unwrap_err();
assert!(
matches!(&err, CliError::Usage(m) if m.contains("already effective") && m.contains("verify"))
);
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
assert_eq!(
events
.iter()
.filter(|e| matches!(e.payload, EventPayload::SafeHarborAllocation(_)))
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|e| matches!(e.payload, EventPayload::VoidDecisionEvent(_)))
.count(),
0
);
}
#[test]
fn classify_inbound_gift_donor_acquired_after_receipt_is_refused() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb_gift.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\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-gift-recv,2024-06-01 12:00:00 UTC,Receive,BTC,0.00100000,USD,40000.00,,,,,bc1qsender,\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
let in_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.find(|e| matches!(e.payload, EventPayload::TransferIn(_)))
.unwrap()
.id
.canonical()
};
let err = cmd::reconcile::classify_inbound(
&vault,
&pp(),
&in_ref,
InboundClass::GiftReceived {
donor_basis: Some(dec!(100.00)),
donor_acquired_at: Some(date!(2024 - 06 - 02)), fmv_at_gift: dec!(40.00),
},
now(),
)
.expect_err("a donor-acquired date after the receipt must be refused");
let msg = err.to_string();
assert!(
msg.contains("--donor-acquired")
&& msg.contains("2024-06-01")
&& msg.contains("receipt")
&& msg.contains("UTC"),
"the refusal must name --donor-acquired, the receipt date + tz basis: {msg}"
);
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(_))),
"a refused gift classify must append no decision"
);
}
#[test]
fn safe_harbor_allocate_carries_gift_dual_basis() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb_gift.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\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-gift-recv,2024-06-01 12:00:00 UTC,Receive,BTC,0.00100000,USD,40000.00,,,,,bc1qsender,\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
let in_ref = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events
.iter()
.find(|e| matches!(e.payload, EventPayload::TransferIn(_)))
.unwrap()
.id
.canonical()
};
cmd::reconcile::classify_inbound(
&vault,
&pp(),
&in_ref,
InboundClass::GiftReceived {
donor_basis: Some(dec!(100.00)),
donor_acquired_at: Some(date!(2021 - 01 - 01)),
fmv_at_gift: dec!(40.00),
},
now(),
)
.unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
cmd::reconcile::safe_harbor_allocate(&vault, &pp(), AllocMethod::ActualPosition, false, now())
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
let alloc = events
.iter()
.find_map(|e| match &e.payload {
EventPayload::SafeHarborAllocation(a) => Some(a.clone()),
_ => None,
})
.expect("SafeHarborAllocation must be persisted");
assert_eq!(alloc.lots.len(), 1, "one lot in the allocation");
let lot = &alloc.lots[0];
assert_eq!(
lot.usd_basis,
dec!(100.00),
"usd_basis (gain basis) must be $100 (donor carryover); got {}",
lot.usd_basis
);
assert_eq!(
lot.dual_loss_basis,
Some(dec!(40.00)),
"dual_loss_basis must be Some($40) (FMV-at-gift LOSS basis); got {:?}",
lot.dual_loss_basis
);
assert_eq!(
lot.donor_acquired_at,
Some(date!(2021 - 01 - 01)),
"donor_acquired_at must be Some(2021-01-01) for §1223(2) tacking; got {:?}",
lot.donor_acquired_at
);
}
#[test]
fn safe_harbor_allocate_refuses_when_pre2025_method_unattested() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\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-pre,2024-01-15 12:00:00 UTC,Buy,BTC,0.20000000,USD,42500.00,8500.00,8550.00,50.00,,,\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
let err = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
AllocMethod::ActualPosition,
false,
now(),
)
.unwrap_err();
assert!(
matches!(&err, CliError::Usage(m)
if m.contains("UNDECLARED pre-2025 method")
&& m.contains("--attest-pre2025-method")),
"expected refusal naming UNDECLARED method and remedy, got: {err}"
);
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
assert!(
events
.iter()
.all(|e| !matches!(e.payload, EventPayload::SafeHarborAllocation(_))),
"event log must NOT contain a SafeHarborAllocation after the refused allocate"
);
}
#[test]
fn safe_harbor_allocate_succeeds_with_explicitly_attested_fifo() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("cb.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\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-pre,2024-01-15 12:00:00 UTC,Buy,BTC,0.20000000,USD,42500.00,8500.00,8550.00,50.00,,,\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
let id = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
AllocMethod::ActualPosition,
false,
now(),
)
.unwrap();
assert!(matches!(id, EventId::Decision { .. }));
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
let alloc = events
.iter()
.find_map(|e| match &e.payload {
EventPayload::SafeHarborAllocation(a) => Some(a),
_ => None,
})
.expect("SafeHarborAllocation must be persisted");
assert_eq!(
alloc.pre2025_method,
LotMethod::Fifo,
"allocation must record the attested FIFO method"
);
}
#[test]
fn safe_harbor_residue_matches_command_lots() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_timebarred(dir.path()); cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
let (helper_lots, helper_method) = {
let s = Session::open(&vault, &pp()).unwrap();
s.safe_harbor_residue().unwrap()
};
assert!(
!helper_lots.is_empty(),
"pre-2025 residue must be non-empty"
);
cmd::reconcile::safe_harbor_allocate(&vault, &pp(), AllocMethod::ActualPosition, false, now())
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
let alloc = events
.iter()
.find_map(|e| match &e.payload {
EventPayload::SafeHarborAllocation(a) => Some(a.clone()),
_ => None,
})
.expect("SafeHarborAllocation persisted");
assert_eq!(
helper_lots, alloc.lots,
"helper lots must equal the command-appended lots"
);
assert_eq!(
helper_method, alloc.pre2025_method,
"helper method must equal the recorded pre2025_method [R0-M1]"
);
}
#[test]
fn select_lots_emits_a_lot_selection_decision() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let p = dir.path().join("sel.csv");
std::fs::write(
&p,
"\r\nTransactions\r\nUser,x\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\
sel-buy,2025-03-01 12:00:00 UTC,Buy,BTC,0.00100000,USD,84000.00,84.00,85.00,1.00,,,\r\n\
sel-sell,2025-06-15 12:00:00 UTC,Sell,BTC,0.00100000,USD,90000.00,90.00,89.00,1.00,,,\r\n",
)
.unwrap();
cmd::import::run(&vault, &pp(), &[p]).unwrap();
let disposal_ref = "import|coinbase|trade|sel-sell";
let lot_ref = "import|coinbase|trade|sel-buy#0";
let picks = vec![eventref::parse_lot_pick(&format!("{lot_ref}:100000")).unwrap()];
let id = cmd::reconcile::select_lots(&vault, &pp(), disposal_ref, picks, now()).unwrap();
assert!(
matches!(id, EventId::Decision { .. }),
"select_lots must return a Decision EventId"
);
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::LotSelection(_))),
"a LotSelection event must be present in the log after select_lots"
);
}
#[test]
fn import_selections_rejects_a_bad_header() {
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("sel.csv");
std::fs::write(
&csv,
"wrong,header,here,now\nimport|coinbase|trade|D,import|coinbase|trade|A,0,100000\n",
)
.unwrap();
let err = cmd::reconcile::import_selections(&vault, &pp(), &csv, now()).unwrap_err();
assert!(
matches!(err, CliError::Usage(_)),
"bad CSV header must produce CliError::Usage; got: {err}"
);
}
#[test]
fn import_selections_groups_rows_into_one_selection_per_disposal() {
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("sel.csv");
std::fs::write(
&csv,
"disposal_ref,origin_event_id,split_sequence,sat\n\
import|coinbase|trade|D,import|coinbase|trade|A,0,60000\n\
import|coinbase|trade|D,import|coinbase|trade|B,0,40000\n",
)
.unwrap();
let ids = cmd::reconcile::import_selections(&vault, &pp(), &csv, now()).unwrap();
assert_eq!(
ids.len(),
1,
"two rows with the same disposal_ref → one LotSelection decision"
);
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
let ls = events
.iter()
.find_map(|e| match &e.payload {
EventPayload::LotSelection(l) => Some(l.clone()),
_ => None,
})
.expect("a LotSelection event must be persisted");
assert_eq!(
ls.lots.len(),
2,
"the LotSelection must carry both picks (60k + 40k)"
);
}
#[test]
fn set_forward_method_appends_a_method_election_decision() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
let id = cmd::reconcile::set_forward_method(
&vault,
&pp(),
LotMethod::Hifo,
None, Some(date!(2025 - 06 - 01)),
now(),
)
.unwrap();
assert!(
matches!(id, EventId::Decision { .. }),
"set_forward_method must return a Decision EventId"
);
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
let me = events
.iter()
.find_map(|e| match &e.payload {
EventPayload::MethodElection(m) => Some(m.clone()),
_ => None,
})
.expect("a MethodElection event must be persisted");
assert_eq!(me.method, LotMethod::Hifo, "method must be HIFO");
assert_eq!(
me.effective_from,
date!(2025 - 06 - 01),
"effective_from must match the supplied date"
);
}
#[test]
fn set_forward_method_defaults_effective_from_to_made_date() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::reconcile::set_forward_method(&vault, &pp(), LotMethod::Lifo, None, None, now()).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
let me = events
.iter()
.find_map(|e| match &e.payload {
EventPayload::MethodElection(m) => Some(m.clone()),
_ => None,
})
.expect("a MethodElection event must be persisted");
assert_eq!(
me.effective_from,
date!(2026 - 02 - 01),
"effective_from must default to the made-date (now in UTC)"
);
}
#[test]
fn config_apply_all_no_silent_drop() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Lifo, false).unwrap();
cmd::admin::set_config(&vault, &pp(), Some(FeeTreatment::TreatmentB)).unwrap();
let cfg = cmd::admin::show_config(&vault, &pp()).unwrap();
assert_eq!(
cfg.pre2025_method,
LotMethod::Lifo,
"pre2025_method must be Lifo (not silently dropped)"
);
assert_eq!(
cfg.fee_treatment,
FeeTreatment::TreatmentB,
"fee_treatment must be B (not silently dropped by the old pre2025_method branch)"
);
}
#[test]
fn config_set_forward_method_and_fee_treatment_both_take_effect() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.path().join("k.asc")).unwrap();
cmd::reconcile::set_forward_method(&vault, &pp(), LotMethod::Hifo, None, None, now()).unwrap();
cmd::admin::set_config(&vault, &pp(), Some(FeeTreatment::TreatmentB)).unwrap();
let me = {
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
events.iter().find_map(|e| match &e.payload {
EventPayload::MethodElection(m) => Some(m.clone()),
_ => None,
})
};
assert!(
matches!(me, Some(ref m) if m.method == LotMethod::Hifo),
"a HIFO MethodElection must be persisted (--set-forward-method not dropped)"
);
let cfg = cmd::admin::show_config(&vault, &pp()).unwrap();
assert_eq!(
cfg.fee_treatment,
FeeTreatment::TreatmentB,
"fee_treatment must be B — co-passed --set-fee-treatment must not be silently dropped"
);
}
#[test]
fn attest_pre2025_method_requires_set_pre2025_method() {
fn dispatch_guard(
set_pre2025_method: Option<LotMethod>,
attest_pre2025_method: bool,
) -> Result<(), CliError> {
if attest_pre2025_method && set_pre2025_method.is_none() {
return Err(CliError::Usage(
"--attest-pre2025-method requires --set-pre2025-method".into(),
));
}
Ok(())
}
assert!(
matches!(dispatch_guard(None, true), Err(CliError::Usage(_))),
"attest without set must be a Usage error"
);
assert!(
dispatch_guard(Some(LotMethod::Hifo), true).is_ok(),
"attest with set must succeed"
);
assert!(
dispatch_guard(None, false).is_ok(),
"no-op (show config only) must succeed"
);
}
fn bulk_fixture(dir: &std::path::Path) -> (std::path::PathBuf, [EventId; 5]) {
use btctax_core::event::{Acquire, BasisSource, TransferOut};
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::macros::datetime;
use time::UtcOffset;
let vault = dir.join("bulk.pgp");
let mut session = Session::create(&vault, &pp()).unwrap();
let wallet_a = WalletId::Exchange {
provider: "coinbase".into(),
account: "main".into(),
};
let wallet_b = WalletId::Exchange {
provider: "river".into(),
account: "main".into(),
};
let mkid = |r: &str| EventId::import(Source::Coinbase, SourceRef::new(r));
let out = |sat: i64| {
EventPayload::TransferOut(TransferOut {
sat,
fee_sat: None,
dest_addr: None,
txid: None,
})
};
let acq_a = mkid("acq-a");
let acq_b = mkid("acq-b");
let o1 = mkid("o1");
let o2 = mkid("o2");
let o3 = mkid("o3");
let o4 = mkid("o4");
let o5 = mkid("o5");
let batch = vec![
LedgerEvent {
id: acq_a.clone(),
utc_timestamp: datetime!(2024-01-15 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a.clone()),
payload: EventPayload::Acquire(Acquire {
sat: 1_000_000,
usd_cost: rust_decimal_macros::dec!(30000),
fee_usd: rust_decimal_macros::dec!(0),
basis_source: BasisSource::ComputedFromCost,
}),
},
LedgerEvent {
id: acq_b.clone(),
utc_timestamp: datetime!(2024-01-15 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_b.clone()),
payload: EventPayload::Acquire(Acquire {
sat: 500_000,
usd_cost: rust_decimal_macros::dec!(20000),
fee_usd: rust_decimal_macros::dec!(0),
basis_source: BasisSource::ComputedFromCost,
}),
},
LedgerEvent {
id: o4.clone(),
utc_timestamp: datetime!(2024-02-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a.clone()),
payload: out(20_000),
},
LedgerEvent {
id: o1.clone(),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a.clone()),
payload: out(100_000),
},
LedgerEvent {
id: o3.clone(),
utc_timestamp: datetime!(2025-03-01 13:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_b.clone()),
payload: out(30_000),
},
LedgerEvent {
id: o5.clone(),
utc_timestamp: datetime!(2025-04-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a.clone()),
payload: out(40_000),
},
LedgerEvent {
id: o2.clone(),
utc_timestamp: datetime!(2025-06-15 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a.clone()),
payload: out(50_000),
},
];
append_import_batch(session.conn(), &batch).unwrap();
session.save().unwrap();
(vault, [o1, o2, o3, o4, o5])
}
fn wallet_a() -> WalletId {
WalletId::Exchange {
provider: "coinbase".into(),
account: "main".into(),
}
}
fn wallet_b() -> WalletId {
WalletId::Exchange {
provider: "river".into(),
account: "main".into(),
}
}
fn cold() -> WalletId {
WalletId::SelfCustody {
label: "cold".into(),
}
}
#[test]
fn bulk_plan_selects_pending_in_frame() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, [o1, o2, o3, _o4, o5]) = bulk_fixture(dir.path());
let s = Session::open(&vault, &pp()).unwrap();
let plan = s
.bulk_link_transfer_plan(
BulkFilter {
frame: Frame::Year(2025),
from_wallet: None,
},
wallet_b(),
)
.unwrap();
let included: Vec<_> = plan.included.iter().map(|r| r.out_event.clone()).collect();
assert_eq!(
included,
vec![o1.clone(), o5.clone(), o2.clone()],
"included sorted by date, same-wallet o3 skipped, 2024 o4 dropped"
);
let skipped: Vec<_> = plan
.skipped_same_wallet
.iter()
.map(|r| r.out_event.clone())
.collect();
assert_eq!(
skipped,
vec![o3.clone()],
"o3 (source == dest B) is skipped_same_wallet"
);
assert_eq!(plan.total_sat, 190_000, "Σ included principal_sat");
let plan2 = s
.bulk_link_transfer_plan(
BulkFilter {
frame: Frame::Year(2025),
from_wallet: Some(wallet_a()),
},
cold(),
)
.unwrap();
let included2: Vec<_> = plan2.included.iter().map(|r| r.out_event.clone()).collect();
assert_eq!(
included2,
vec![o1, o5, o2],
"from_wallet filter keeps only wallet-A outs"
);
assert!(
plan2.skipped_same_wallet.is_empty(),
"cold is no source → nothing skipped"
);
}
#[test]
fn bulk_plan_usd_total_floor_when_price_missing() {
use btctax_cli::{BulkFilter, Frame};
use rust_decimal_macros::dec;
let dir = tempfile::tempdir().unwrap();
let (vault, _ids) = bulk_fixture(dir.path());
let s = open_stub(&vault);
let plan = s
.bulk_link_transfer_plan(
BulkFilter {
frame: Frame::Year(2025),
from_wallet: Some(wallet_a()),
},
cold(),
)
.unwrap();
assert_eq!(plan.included.len(), 3);
assert_eq!(
plan.missing_price_count, 1,
"o5 (2025-04-01) has no bundled price"
);
assert_eq!(
plan.total_usd_value_floor,
dec!(84.00) + dec!(33.75),
"floor = Σ of priced rows only (o1 + o2)"
);
assert_eq!(
plan.included
.iter()
.filter(|r| r.usd_value.is_none())
.count(),
1
);
}
#[test]
fn bulk_cli_dry_run_writes_nothing() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _ids) = bulk_fixture(dir.path());
let before = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
let plan = cmd::reconcile::bulk_link_plan(
&vault,
&pp(),
BulkFilter {
frame: Frame::All,
from_wallet: None,
},
cold(),
)
.unwrap();
assert!(
!plan.included.is_empty(),
"plan must select rows (so the no-write is meaningful)"
);
let after = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
assert_eq!(
before, after,
"computing the plan must not append any event"
);
}
#[test]
fn bulk_cli_apply_is_atomic_single_save() {
let dir = tempfile::tempdir().unwrap();
let (vault, [o1, o2, _o3, _o4, _o5]) = bulk_fixture(dir.path());
let pending_before = {
let s = Session::open(&vault, &pp()).unwrap();
s.project().unwrap().0.pending_reconciliation.len()
};
assert_eq!(pending_before, 5, "all five outs start pending");
let n = cmd::reconcile::apply_bulk_link_transfer(
&vault,
&pp(),
vec![o1.clone(), o2.clone()],
cold(),
now(),
)
.unwrap();
assert_eq!(n, 2);
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
let still_pending: std::collections::BTreeSet<_> = state
.pending_reconciliation
.iter()
.map(|p| p.event.clone())
.collect();
assert_eq!(state.pending_reconciliation.len(), 3);
assert!(!still_pending.contains(&o1) && !still_pending.contains(&o2));
let links: Vec<_> = btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.filter_map(|e| match e.payload {
EventPayload::TransferLink(tl) => Some(tl),
_ => None,
})
.collect();
assert_eq!(links.len(), 2, "exactly two TransferLinks appended");
assert!(links
.iter()
.all(|tl| tl.in_event_or_wallet == TransferTarget::Wallet(cold())));
}
#[test]
fn bulk_cli_no_match_exits_clean() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _ids) = bulk_fixture(dir.path());
let s = Session::open(&vault, &pp()).unwrap();
let plan = s
.bulk_link_transfer_plan(
BulkFilter {
frame: Frame::Year(2030),
from_wallet: None,
},
cold(),
)
.unwrap();
assert!(plan.included.is_empty(), "no pending outs in 2030");
assert!(plan.skipped_same_wallet.is_empty());
assert_eq!(plan.total_sat, 0);
}
fn sti_fixture(dir: &std::path::Path) -> (std::path::PathBuf, [EventId; 4]) {
use btctax_core::event::TransferIn;
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::UtcOffset;
let vault = dir.join("sti.pgp");
let mut session = Session::create(&vault, &pp()).unwrap();
let mkid = |r: &str| EventId::import(Source::Coinbase, SourceRef::new(r));
let tin = |sat: i64| {
EventPayload::TransferIn(TransferIn {
sat,
src_addr: None,
txid: None,
})
};
let ev = |id: EventId, ts, wallet: WalletId, sat: i64| LedgerEvent {
id,
utc_timestamp: ts,
original_tz: UtcOffset::UTC,
wallet: Some(wallet),
payload: tin(sat),
};
let i1 = mkid("sti-i1");
let i2 = mkid("sti-i2");
let i3 = mkid("sti-i3");
let i4 = mkid("sti-i4");
let batch = vec![
ev(
i1.clone(),
datetime!(2025-03-01 12:00:00 UTC),
wallet_a(),
100_000,
),
ev(
i2.clone(),
datetime!(2025-04-01 12:00:00 UTC),
wallet_a(),
50_000,
),
ev(
i3.clone(),
datetime!(2025-06-15 12:00:00 UTC),
wallet_b(),
40_000,
),
ev(
i4.clone(),
datetime!(2024-02-01 12:00:00 UTC),
wallet_a(),
20_000,
),
];
append_import_batch(session.conn(), &batch).unwrap();
session.save().unwrap();
(vault, [i1, i2, i3, i4])
}
#[test]
fn bulk_sti_plan_selects_unknown_inbounds_in_frame() {
use btctax_cli::{BulkStiFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, [i1, i2, i3, _i4]) = sti_fixture(dir.path());
let s = Session::open(&vault, &pp()).unwrap();
let plan = s
.bulk_self_transfer_in_plan(BulkStiFilter {
frame: Frame::Year(2025),
wallet: None,
})
.unwrap();
let included: Vec<_> = plan.included.iter().map(|r| r.in_event.clone()).collect();
assert_eq!(
included,
vec![i1.clone(), i2.clone(), i3.clone()],
"included sorted by date; the 2024 inbound is out of frame"
);
assert_eq!(plan.total_sat, 190_000, "Σ included sat");
let plan2 = s
.bulk_self_transfer_in_plan(BulkStiFilter {
frame: Frame::Year(2025),
wallet: Some(wallet_a()),
})
.unwrap();
let included2: Vec<_> = plan2.included.iter().map(|r| r.in_event.clone()).collect();
assert_eq!(
included2,
vec![i1, i2],
"wallet filter keeps only wallet-A inbounds"
);
}
#[test]
fn bulk_sti_plan_excludes_already_classified_and_walletless() {
use btctax_cli::{BulkStiFilter, Frame};
use btctax_core::event::TransferIn;
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::UtcOffset;
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("sti-excl.pgp");
let mut session = Session::create(&vault, &pp()).unwrap();
let mkid = |r: &str| EventId::import(Source::Coinbase, SourceRef::new(r));
let tin = |sat: i64| {
EventPayload::TransferIn(TransferIn {
sat,
src_addr: None,
txid: None,
})
};
let normal = mkid("sti-normal");
let gift4 = mkid("sti-gift4");
let walletless = mkid("sti-walletless");
let batch = vec![
LedgerEvent {
id: normal.clone(),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a()),
payload: tin(100_000),
},
LedgerEvent {
id: gift4.clone(),
utc_timestamp: datetime!(2025-03-05 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a()),
payload: tin(70_000),
},
LedgerEvent {
id: walletless.clone(),
utc_timestamp: datetime!(2025-03-10 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: None,
payload: tin(60_000),
},
];
append_import_batch(session.conn(), &batch).unwrap();
session.save().unwrap();
drop(session);
cmd::reconcile::classify_inbound(
&vault,
&pp(),
&gift4.canonical(),
InboundClass::GiftReceived {
donor_basis: None,
donor_acquired_at: None,
fmv_at_gift: dec!(1000),
},
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
let flagged: std::collections::BTreeSet<_> = state
.blockers
.iter()
.filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
.filter_map(|b| b.event.clone())
.collect();
assert!(
flagged.contains(&normal) && flagged.contains(&gift4) && flagged.contains(&walletless),
"all three inbounds re-fire UnknownBasisInbound: {flagged:?}"
);
let plan = s
.bulk_self_transfer_in_plan(BulkStiFilter {
frame: Frame::All,
wallet: None,
})
.unwrap();
let included: Vec<_> = plan.included.iter().map(|r| r.in_event.clone()).collect();
assert_eq!(
included,
vec![normal],
"only the plain unclassified wallet-bearing inbound is a candidate; \
gift-case-4 (already-classified, filter-3) and wallet-less (M2) are excluded"
);
}
#[test]
fn bulk_sti_plan_fmv_floor_when_price_missing() {
use btctax_cli::{BulkStiFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _ids) = sti_fixture(dir.path());
let s = open_stub(&vault);
let plan = s
.bulk_self_transfer_in_plan(BulkStiFilter {
frame: Frame::Year(2025),
wallet: Some(wallet_a()),
})
.unwrap();
assert_eq!(plan.included.len(), 2);
assert_eq!(
plan.missing_price_count, 1,
"i2 (2025-04-01) has no bundled price"
);
assert_eq!(
plan.total_usd_fmv_floor,
dec!(84.00),
"floor = Σ of priced rows only (i1)"
);
assert_eq!(
plan.included.iter().filter(|r| r.usd_fmv.is_none()).count(),
1
);
}
#[test]
fn bulk_sti_cli_dry_run_writes_nothing() {
use btctax_cli::{BulkStiFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _ids) = sti_fixture(dir.path());
let before = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
let plan = cmd::reconcile::bulk_self_transfer_in_plan(
&vault,
&pp(),
BulkStiFilter {
frame: Frame::All,
wallet: None,
},
)
.unwrap();
assert!(
!plan.included.is_empty(),
"plan must select rows (so the no-write is meaningful)"
);
let after = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
assert_eq!(
before, after,
"computing the plan must not append any event"
);
}
#[test]
fn bulk_sti_cli_apply_is_atomic_single_save() {
let dir = tempfile::tempdir().unwrap();
let (vault, [i1, i2, i3, _i4]) = sti_fixture(dir.path());
let n = cmd::reconcile::apply_bulk_self_transfer_in(
&vault,
&pp(),
vec![i1.clone(), i3.clone()],
now(),
)
.unwrap();
assert_eq!(n, 2);
let s = Session::open(&vault, &pp()).unwrap();
let classified: Vec<_> = btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.filter_map(|e| match e.payload {
EventPayload::ClassifyInbound(ci) => Some(ci),
_ => None,
})
.collect();
assert_eq!(classified.len(), 2, "exactly two ClassifyInbound appended");
assert!(
classified.iter().all(|ci| matches!(
ci.as_,
InboundClass::SelfTransferMine {
basis: None,
acquired_at: None
}
)),
"every appended classification is SelfTransferMine{{None,None}}"
);
let (state, _) = s.project().unwrap();
let flagged: std::collections::BTreeSet<_> = state
.blockers
.iter()
.filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
.filter_map(|b| b.event.clone())
.collect();
assert!(
!flagged.contains(&i1) && !flagged.contains(&i3),
"classified inbounds' UnknownBasisInbound blockers cleared"
);
assert!(flagged.contains(&i2), "the untouched i2 stays flagged");
for id in [&i1, &i3] {
let lot = state
.lots
.iter()
.find(|l| &l.lot_id.origin_event_id == id)
.expect("a lot was created for the classified inbound");
assert_eq!(
lot.usd_basis,
dec!(0),
"self-transfer-in defaults to $0 basis"
);
}
}
#[test]
fn bulk_sti_cli_no_match_exits_clean() {
use btctax_cli::{BulkStiFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _ids) = sti_fixture(dir.path());
let s = Session::open(&vault, &pp()).unwrap();
let plan = s
.bulk_self_transfer_in_plan(BulkStiFilter {
frame: Frame::Year(2030),
wallet: None,
})
.unwrap();
assert!(plan.included.is_empty(), "no inbounds in 2030");
assert_eq!(plan.total_sat, 0);
assert_eq!(plan.total_usd_fmv_floor, dec!(0));
assert_eq!(plan.missing_price_count, 0);
}
#[test]
fn bulk_income_plan_lists_pending_inbounds() {
use btctax_cli::{BulkIncomeFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, [i1, _i2, i3, _i4]) = sti_fixture(dir.path());
let s = open_stub(&vault);
let plan = s
.bulk_classify_income_plan(BulkIncomeFilter {
frame: Frame::Year(2025),
wallet: None,
})
.unwrap();
let included: Vec<_> = plan.included.iter().map(|r| r.in_event.clone()).collect();
assert_eq!(
included,
vec![i1, i3],
"included = priced 2025 inbounds sorted by date (i2 unpriced, i4 out of frame dropped)"
);
assert_eq!(
plan.excluded_missing_price, 1,
"i2 (2025-04-01) has no price"
);
assert_eq!(plan.total_sat, 140_000, "Σ included sat");
assert_eq!(
plan.total_income_usd,
dec!(111.00),
"Σ income = $84.00 + $27.00"
);
assert_eq!(
plan.included.iter().map(|r| r.fmv).collect::<Vec<_>>(),
vec![dec!(84.00), dec!(27.00)],
"each included row carries its resolved auto-FMV"
);
}
#[test]
fn bulk_income_plan_excludes_already_classified() {
use btctax_cli::{BulkIncomeFilter, Frame};
use btctax_core::event::TransferIn;
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::UtcOffset;
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("inc-excl.pgp");
let mut session = Session::create(&vault, &pp()).unwrap();
let mkid = |r: &str| EventId::import(Source::Coinbase, SourceRef::new(r));
let tin = |sat: i64| {
EventPayload::TransferIn(TransferIn {
sat,
src_addr: None,
txid: None,
})
};
let normal = mkid("inc-normal");
let gift4 = mkid("inc-gift4");
let batch = vec![
LedgerEvent {
id: normal.clone(),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a()),
payload: tin(100_000),
},
LedgerEvent {
id: gift4.clone(),
utc_timestamp: datetime!(2025-03-02 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a()),
payload: tin(70_000),
},
];
append_import_batch(session.conn(), &batch).unwrap();
session.save().unwrap();
drop(session);
cmd::reconcile::classify_inbound(
&vault,
&pp(),
&gift4.canonical(),
InboundClass::GiftReceived {
donor_basis: None,
donor_acquired_at: None,
fmv_at_gift: dec!(1000),
},
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
let flagged: std::collections::BTreeSet<_> = state
.blockers
.iter()
.filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
.filter_map(|b| b.event.clone())
.collect();
assert!(
flagged.contains(&normal) && flagged.contains(&gift4),
"both inbounds re-fire UnknownBasisInbound: {flagged:?}"
);
let plan = s
.bulk_classify_income_plan(BulkIncomeFilter {
frame: Frame::All,
wallet: None,
})
.unwrap();
let included: Vec<_> = plan.included.iter().map(|r| r.in_event.clone()).collect();
assert_eq!(
included,
vec![normal],
"only the plain unclassified inbound is a candidate; the already-classified gift-case-4 is excluded"
);
}
#[test]
fn bulk_income_plan_excludes_missing_price() {
use btctax_cli::{BulkIncomeFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, [i1, i2, _i3, _i4]) = sti_fixture(dir.path());
let s = open_stub(&vault);
let plan = s
.bulk_classify_income_plan(BulkIncomeFilter {
frame: Frame::Year(2025),
wallet: Some(wallet_a()),
})
.unwrap();
let included: Vec<_> = plan.included.iter().map(|r| r.in_event.clone()).collect();
assert_eq!(included, vec![i1], "only the priced inbound is included");
assert!(
!included.contains(&i2),
"the unpriced inbound (i2) is NOT classified as income (would year-gate)"
);
assert_eq!(plan.excluded_missing_price, 1, "i2 surfaced as excluded");
assert_eq!(
plan.total_income_usd,
dec!(84.00),
"income = priced row only"
);
}
#[test]
fn bulk_income_plan_excludes_wallet_less() {
use btctax_cli::{BulkIncomeFilter, Frame};
use btctax_core::event::TransferIn;
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::UtcOffset;
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("inc-walletless.pgp");
let mut session = Session::create(&vault, &pp()).unwrap();
let mkid = |r: &str| EventId::import(Source::Coinbase, SourceRef::new(r));
let tin = |sat: i64| {
EventPayload::TransferIn(TransferIn {
sat,
src_addr: None,
txid: None,
})
};
let normal = mkid("inc-normal");
let walletless = mkid("inc-walletless");
let batch = vec![
LedgerEvent {
id: normal.clone(),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a()),
payload: tin(100_000),
},
LedgerEvent {
id: walletless.clone(),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: None,
payload: tin(60_000),
},
];
append_import_batch(session.conn(), &batch).unwrap();
session.save().unwrap();
drop(session);
let s = Session::open(&vault, &pp()).unwrap();
let plan = s
.bulk_classify_income_plan(BulkIncomeFilter {
frame: Frame::All,
wallet: None,
})
.unwrap();
let included: Vec<_> = plan.included.iter().map(|r| r.in_event.clone()).collect();
assert_eq!(
included,
vec![normal],
"the wallet-less inbound is excluded (creates no lot; no missing-price count)"
);
assert_eq!(
plan.excluded_missing_price, 0,
"wallet-less is a DISTINCT exclusion, not counted as missing-price"
);
}
#[test]
fn bulk_income_dry_run_writes_nothing() {
use btctax_cli::{BulkIncomeFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _ids) = sti_fixture(dir.path());
let before = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
let plan = cmd::reconcile::bulk_classify_income_plan(
&vault,
&pp(),
BulkIncomeFilter {
frame: Frame::All,
wallet: None,
},
)
.unwrap();
assert!(
!plan.included.is_empty(),
"plan must select rows (so the no-write is meaningful)"
);
let after = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
assert_eq!(
before, after,
"computing the plan must not append any event"
);
}
#[test]
fn bulk_income_apply_sets_autofmv() {
let dir = tempfile::tempdir().unwrap();
let (vault, [i1, _i2, _i3, _i4]) = sti_fixture(dir.path());
let n = cmd::reconcile::apply_bulk_classify_inbound_income(
&vault,
&pp(),
vec![i1.clone()],
IncomeKind::Mining,
false,
now(),
)
.unwrap();
assert_eq!(n, 1);
let s = Session::open(&vault, &pp()).unwrap();
let ci = btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.find_map(|e| match e.payload {
EventPayload::ClassifyInbound(ci) if ci.transfer_in_event == i1 => Some(ci),
_ => None,
})
.expect("a ClassifyInbound for i1");
assert!(
matches!(
ci.as_,
InboundClass::Income {
fmv: Some(v),
kind: IncomeKind::Mining,
business: false
} if v == dec!(85.44)
),
"decision fmv == fmv_of(date, sat) == $85.44; got {:?}",
ci.as_
);
let (state, _) = s.project().unwrap();
let rec = state
.income_recognized
.iter()
.find(|r| r.event == i1)
.expect("i1 recognized as income");
assert_eq!(
rec.usd_fmv,
dec!(85.44),
"projected Income.usd_fmv == fmv_of"
);
let lot = state
.lots
.iter()
.find(|l| l.lot_id.origin_event_id == i1)
.expect("income lot created");
assert_eq!(
lot.usd_basis,
dec!(85.44),
"lot basis == the recognized FMV"
);
}
#[test]
fn bulk_income_apply_recognizes_income() {
let dir = tempfile::tempdir().unwrap();
let (vault, [i1, i2, i3, _i4]) = sti_fixture(dir.path());
let income_before = {
let s = Session::open(&vault, &pp()).unwrap();
s.project().unwrap().0.income_recognized.len()
};
let n = cmd::reconcile::apply_bulk_classify_inbound_income(
&vault,
&pp(),
vec![i1.clone(), i3.clone()],
IncomeKind::Staking,
false,
now(),
)
.unwrap();
assert_eq!(n, 2);
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(
state.income_recognized.len(),
income_before + 2,
"income_recognized grew by the included count"
);
let flagged: std::collections::BTreeSet<_> = state
.blockers
.iter()
.filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
.filter_map(|b| b.event.clone())
.collect();
assert!(
!flagged.contains(&i1) && !flagged.contains(&i3),
"classified inbounds' UnknownBasisInbound cleared"
);
assert!(flagged.contains(&i2), "the untouched i2 stays flagged");
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::FmvMissing),
"a priced income batch introduces NO FmvMissing year-gate"
);
}
#[test]
fn bulk_income_uniform_kind_and_business() {
let dir = tempfile::tempdir().unwrap();
let (vault, [i1, _i2, i3, _i4]) = sti_fixture(dir.path());
cmd::reconcile::apply_bulk_classify_inbound_income(
&vault,
&pp(),
vec![i1, i3],
IncomeKind::Interest,
true,
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let classified: Vec<_> = btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.filter_map(|e| match e.payload {
EventPayload::ClassifyInbound(ci) => Some(ci.as_),
_ => None,
})
.collect();
assert_eq!(classified.len(), 2);
assert!(
classified.iter().all(|c| matches!(
c,
InboundClass::Income {
kind: IncomeKind::Interest,
business: true,
fmv: Some(_)
}
)),
"every row carries the uniform kind=Interest + business=true (each fmv is Some)"
);
}
fn match_fixture(
dir: &std::path::Path,
legs: &[(&str, &str, i64, WalletId, time::OffsetDateTime)],
) -> std::path::PathBuf {
use btctax_core::event::{TransferIn, TransferOut};
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::UtcOffset;
let vault = dir.join("match.pgp");
let mut session = Session::create(&vault, &pp()).unwrap();
let batch: Vec<LedgerEvent> = legs
.iter()
.map(|(r, kind, sat, wallet, ts)| {
let payload = match *kind {
"in" => EventPayload::TransferIn(TransferIn {
sat: *sat,
src_addr: None,
txid: None,
}),
"out" => EventPayload::TransferOut(TransferOut {
sat: *sat,
fee_sat: None,
dest_addr: None,
txid: None,
}),
other => panic!("bad kind {other}"),
};
LedgerEvent {
id: EventId::import(Source::Coinbase, SourceRef::new(*r)),
utc_timestamp: *ts,
original_tz: UtcOffset::UTC,
wallet: Some(wallet.clone()),
payload,
}
})
.collect();
append_import_batch(session.conn(), &batch).unwrap();
session.save().unwrap();
vault
}
fn id_of(r: &str) -> EventId {
EventId::import(Source::Coinbase, SourceRef::new(r))
}
#[test]
fn self_transfer_match_proposes_drop_and_relocate() {
let dir = tempfile::tempdir().unwrap();
let vault = match_fixture(
dir.path(),
&[
(
"in-d",
"in",
100_000,
wallet_a(),
datetime!(2025-03-01 12:00:00 UTC),
),
(
"out-d",
"out",
100_000,
wallet_a(),
datetime!(2025-03-02 12:00:00 UTC),
),
(
"out-r",
"out",
100_000,
wallet_a(),
datetime!(2025-04-01 12:00:00 UTC),
),
(
"in-r",
"in",
100_000,
wallet_b(),
datetime!(2025-04-02 12:00:00 UTC),
),
],
);
let s = Session::open(&vault, &pp()).unwrap();
let events_before = btctax_core::persistence::load_all(s.conn()).unwrap().len();
let plan = s.self_transfer_match_plan().unwrap();
let events_after = btctax_core::persistence::load_all(s.conn()).unwrap().len();
assert_eq!(
events_before, events_after,
"the matcher must persist NOTHING (confirmed-not-automatic)"
);
assert_eq!(plan.len(), 2, "exactly two pairs proposed");
let drop = plan
.iter()
.find(|p| p.action == btctax_cli::MatchAction::Drop)
.expect("a DROP proposal");
assert_eq!(drop.in_event, id_of("in-d"));
assert_eq!(drop.out_event, id_of("out-d"));
assert!(!drop.ambiguous);
let reloc = plan
.iter()
.find(|p| p.action == btctax_cli::MatchAction::Relocate)
.expect("a RELOCATE proposal");
assert_eq!(reloc.in_event, id_of("in-r"));
assert_eq!(reloc.out_event, id_of("out-r"));
assert!(!reloc.ambiguous);
}
#[test]
fn self_transfer_match_excludes_reconciled_legs() {
let dir = tempfile::tempdir().unwrap();
let vault = {
use btctax_core::event::{Acquire, BasisSource, TransferIn, TransferOut};
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::UtcOffset;
let vault = dir.path().join("fm.pgp");
let mut s = Session::create(&vault, &pp()).unwrap();
let batch = vec![
LedgerEvent {
id: id_of("acq"),
utc_timestamp: datetime!(2025-01-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a()),
payload: EventPayload::Acquire(Acquire {
sat: 100_000,
usd_cost: dec!(50),
fee_usd: dec!(0),
basis_source: BasisSource::ComputedFromCost,
}),
},
LedgerEvent {
id: id_of("in-inc"),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a()),
payload: EventPayload::TransferIn(TransferIn {
sat: 100_000,
src_addr: None,
txid: None,
}),
},
LedgerEvent {
id: id_of("out-disp"),
utc_timestamp: datetime!(2025-03-02 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(wallet_a()),
payload: EventPayload::TransferOut(TransferOut {
sat: 100_000,
fee_sat: None,
dest_addr: None,
txid: None,
}),
},
];
append_import_batch(s.conn(), &batch).unwrap();
s.save().unwrap();
vault
};
cmd::reconcile::classify_inbound(
&vault,
&pp(),
&id_of("in-inc").canonical(),
InboundClass::Income {
kind: IncomeKind::Reward,
fmv: Some(dec!(4200)),
business: false,
},
now(),
)
.unwrap();
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&id_of("out-disp").canonical(),
OutflowClass::Dispose {
kind: DisposeKind::Sell,
},
dec!(5000),
None,
None,
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let plan = s.self_transfer_match_plan().unwrap();
assert!(
plan.is_empty(),
"reconciled legs are NOT candidates — no coincidental match proposed: {plan:?}"
);
}
#[test]
fn self_transfer_match_flags_ambiguity() {
let dir = tempfile::tempdir().unwrap();
let vault = match_fixture(
dir.path(),
&[
(
"in-x",
"in",
100_000,
wallet_a(),
datetime!(2025-03-03 12:00:00 UTC),
),
(
"out-1",
"out",
100_000,
wallet_a(),
datetime!(2025-03-03 13:00:00 UTC),
),
(
"out-2",
"out",
100_000,
wallet_a(),
datetime!(2025-03-04 12:00:00 UTC),
),
],
);
let s = Session::open(&vault, &pp()).unwrap();
let plan = s.self_transfer_match_plan().unwrap();
assert_eq!(
plan.len(),
2,
"the in matches both outs → two flagged pairs"
);
assert!(
plan.iter().all(|p| p.ambiguous),
"a 1-in/2-out collision must flag BOTH pairs ambiguous, never auto-pick: {plan:?}"
);
}
#[test]
fn apply_self_transfer_passthrough_drops_both_legs() {
let dir = tempfile::tempdir().unwrap();
let vault = match_fixture(
dir.path(),
&[
(
"in-d",
"in",
100_000,
wallet_a(),
datetime!(2025-03-01 12:00:00 UTC),
),
(
"out-d",
"out",
100_000,
wallet_a(),
datetime!(2025-03-02 12:00:00 UTC),
),
],
);
let before = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
cmd::reconcile::apply_self_transfer_passthrough(
&vault,
&pp(),
&id_of("in-d").canonical(),
&id_of("out-d").canonical(),
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let events = btctax_core::persistence::load_all(s.conn()).unwrap();
assert_eq!(events.len(), before + 1, "exactly one decision appended");
assert_eq!(
events
.iter()
.filter(|e| matches!(e.payload, EventPayload::SelfTransferPassthrough(_)))
.count(),
1
);
let (state, _) = s.project().unwrap();
assert!(state.lots.is_empty(), "in-leg skipped → no lot");
assert!(
state.pending_reconciliation.is_empty(),
"out-leg skipped → not pending"
);
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::UnknownBasisInbound
&& b.kind != BlockerKind::UnmatchedOutflows),
"both legs vanish cleanly"
);
}
use btctax_core::event::{Acquire, BasisSource, TransferOut};
use btctax_core::persistence::append_import_batch;
use btctax_core::LedgerEvent;
use time::UtcOffset;
fn reclass_batch_vault(
dir: &std::path::Path,
outs: &[(&str, i64, time::OffsetDateTime)],
) -> (std::path::PathBuf, WalletId, [EventId; 2], Vec<EventId>) {
let vault = dir.join("reclass-batch.pgp");
let mut session = Session::create(&vault, &pp()).unwrap();
let w = wallet_a();
let mkid = |r: &str| EventId::import(Source::Coinbase, SourceRef::new(r));
let acq_a = mkid("racq-a");
let acq_b = mkid("racq-b");
let mut batch = vec![
LedgerEvent {
id: acq_a.clone(),
utc_timestamp: datetime!(2025-01-15 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(w.clone()),
payload: EventPayload::Acquire(Acquire {
sat: 100_000,
usd_cost: dec!(40.00),
fee_usd: dec!(0),
basis_source: BasisSource::ComputedFromCost,
}),
},
LedgerEvent {
id: acq_b.clone(),
utc_timestamp: datetime!(2025-01-20 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(w.clone()),
payload: EventPayload::Acquire(Acquire {
sat: 100_000,
usd_cost: dec!(80.00),
fee_usd: dec!(0),
basis_source: BasisSource::ComputedFromCost,
}),
},
];
let mut out_ids = Vec::new();
for (label, sat, ts) in outs {
let id = mkid(label);
out_ids.push(id.clone());
batch.push(LedgerEvent {
id,
utc_timestamp: *ts,
original_tz: UtcOffset::UTC,
wallet: Some(w.clone()),
payload: EventPayload::TransferOut(TransferOut {
sat: *sat,
fee_sat: None,
dest_addr: None,
txid: None,
}),
});
}
append_import_batch(session.conn(), &batch).unwrap();
session.save().unwrap();
(vault, w, [acq_a, acq_b], out_ids)
}
#[test]
fn bulk_reclassify_outflow_plan_lists_pending_outflows() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, [o1, o2, o3, _o4, _o5]) = bulk_fixture(dir.path());
let s = open_stub(&vault);
let plan = s
.bulk_reclassify_outflow_plan(BulkFilter {
frame: Frame::Year(2025),
from_wallet: None,
})
.unwrap();
let included: Vec<_> = plan.included.iter().map(|r| r.out_event.clone()).collect();
assert_eq!(
included.len(),
3,
"o1 + o3 + o2 included (o5 unpriced, o4 out of frame)"
);
assert!(included.contains(&o1) && included.contains(&o3) && included.contains(&o2));
assert_eq!(
plan.excluded_missing_price, 1,
"o5 (2025-04-01) has no price"
);
assert_eq!(
plan.included.last().unwrap().out_event,
o2,
"latest-dated row sorts last"
);
let plan_b = s
.bulk_reclassify_outflow_plan(BulkFilter {
frame: Frame::Year(2025),
from_wallet: Some(wallet_b()),
})
.unwrap();
let included_b: Vec<_> = plan_b
.included
.iter()
.map(|r| r.out_event.clone())
.collect();
assert_eq!(
included_b,
vec![o3],
"from_wallet filter keeps only wallet-B outs"
);
}
#[test]
fn bulk_reclassify_outflow_plan_excludes_missing_price() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, [_o1, _o2, _o3, _o4, o5]) = bulk_fixture(dir.path());
let s = open_stub(&vault);
let plan = s
.bulk_reclassify_outflow_plan(BulkFilter {
frame: Frame::Year(2025),
from_wallet: Some(wallet_a()),
})
.unwrap();
let included: Vec<_> = plan.included.iter().map(|r| r.out_event.clone()).collect();
assert!(
!included.contains(&o5),
"the unpriced out (o5, 2025-04-01) is NOT reclassified"
);
assert_eq!(plan.excluded_missing_price, 1, "o5 surfaced as excluded");
}
#[test]
fn bulk_reclassify_outflow_apply_never_emits_fabricated_proceeds() {
let dir = tempfile::tempdir().unwrap();
let (vault, _w, _acqs, outs) = reclass_batch_vault(
dir.path(),
&[("ro-unpriced", 40_000, datetime!(2030-01-01 12:00:00 UTC))],
);
let o_unpriced = outs[0].clone();
let bogus = EventId::import(Source::Coinbase, SourceRef::new("no-such-out"));
let n = cmd::reconcile::apply_bulk_reclassify_outflow(
&vault,
&pp(),
vec![o_unpriced, bogus],
DisposeKind::Sell,
now(),
)
.unwrap();
assert_eq!(
n, 0,
"no fabricated-proceeds Sell appended for an unpriced / bogus id"
);
let s = Session::open(&vault, &pp()).unwrap();
let ro_count = btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.filter(|e| matches!(e.payload, EventPayload::ReclassifyOutflow(_)))
.count();
assert_eq!(ro_count, 0, "no ReclassifyOutflow appended");
assert!(
s.bulk_estimated().unwrap().is_empty(),
"no side-table row for a skipped row"
);
}
#[test]
fn bulk_reclassify_outflow_plan_resolves_fmv_as_proceeds() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _w, _acqs, outs) = reclass_batch_vault(
dir.path(),
&[("ro-1", 100_000, datetime!(2025-03-01 12:00:00 UTC))],
);
let o = outs[0].clone();
let s = Session::open(&vault, &pp()).unwrap();
let plan = s
.bulk_reclassify_outflow_plan(BulkFilter {
frame: Frame::All,
from_wallet: None,
})
.unwrap();
assert_eq!(plan.included.len(), 1);
assert_eq!(
plan.included[0].fmv,
dec!(85.44),
"row.fmv == fmv_of(date, sat)"
);
drop(s);
cmd::reconcile::apply_bulk_reclassify_outflow(
&vault,
&pp(),
vec![o.clone()],
DisposeKind::Sell,
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let ro = btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.find_map(|e| match e.payload {
EventPayload::ReclassifyOutflow(ro) if ro.transfer_out_event == o => Some(ro),
_ => None,
})
.expect("a ReclassifyOutflow for o");
assert_eq!(
ro.principal_proceeds_or_fmv,
dec!(85.44),
"payload proceeds == row.fmv"
);
assert!(matches!(
ro.as_,
OutflowClass::Dispose {
kind: DisposeKind::Sell
}
));
}
#[test]
fn bulk_reclassify_outflow_plan_estimated_gain_matches_pending_legs_basis() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _w, [acq_a, acq_b], outs) = reclass_batch_vault(
dir.path(),
&[("ro-span", 150_000, datetime!(2025-03-01 12:00:00 UTC))],
);
let o = outs[0].clone();
let s = open_stub(&vault);
let plan = s
.bulk_reclassify_outflow_plan(BulkFilter {
frame: Frame::All,
from_wallet: None,
})
.unwrap();
assert_eq!(plan.included.len(), 1);
let row = &plan.included[0];
assert_eq!(row.fmv, dec!(126.00), "resolved FMV");
assert_eq!(
row.basis_usd,
dec!(80.00),
"Σ pending legs basis (A $40 + half-B $40)"
);
assert_eq!(
row.estimated_gain,
dec!(46.00),
"round_cents(126.00 − 80.00)"
);
assert_ne!(
row.estimated_gain,
dec!(0),
"provably not a coincidental zero"
);
let (state, _) = s.project().unwrap();
let pt = state
.pending_reconciliation
.iter()
.find(|p| p.event == o)
.unwrap();
let legs_basis: btctax_core::Usd = pt.legs.iter().map(|l| l.usd_basis).sum();
assert_eq!(legs_basis, dec!(80.00));
let a_leg = pt
.legs
.iter()
.find(|l| l.lot_id.origin_event_id == acq_a)
.unwrap();
let b_leg = pt
.legs
.iter()
.find(|l| l.lot_id.origin_event_id == acq_b)
.unwrap();
assert_eq!(a_leg.sat, 100_000, "all of A consumed");
assert_eq!(b_leg.sat, 50_000, "half of B consumed");
}
#[test]
fn bulk_reclassify_outflow_plan_batch_gain_not_double_counted() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _w, [acq_a, _acq_b], outs) = reclass_batch_vault(
dir.path(),
&[
("ro-d1", 60_000, datetime!(2025-03-01 12:00:00 UTC)), ("ro-d2", 80_000, datetime!(2025-06-15 12:00:00 UTC)), ],
);
let (o1, o2) = (outs[0].clone(), outs[1].clone());
let s = open_stub(&vault);
let plan = s
.bulk_reclassify_outflow_plan(BulkFilter {
frame: Frame::All,
from_wallet: None,
})
.unwrap();
assert_eq!(plan.included.len(), 2);
let r1 = plan.included.iter().find(|r| r.out_event == o1).unwrap();
let r2 = plan.included.iter().find(|r| r.out_event == o2).unwrap();
assert_eq!(r1.basis_usd, dec!(24.00));
assert_eq!(r1.estimated_gain, dec!(26.40));
assert_eq!(r2.basis_usd, dec!(48.00));
assert_eq!(r2.estimated_gain, dec!(6.00));
assert_eq!(
plan.total_estimated_gain,
r1.estimated_gain + r2.estimated_gain,
"Σ row gains == plan total (no double-count)"
);
assert_eq!(plan.total_estimated_gain, dec!(32.40));
let (state, _) = s.project().unwrap();
let pt2 = state
.pending_reconciliation
.iter()
.find(|p| p.event == o2)
.unwrap();
let a_leg2 = pt2
.legs
.iter()
.find(|l| l.lot_id.origin_event_id == acq_a)
.unwrap();
assert_eq!(
a_leg2.sat, 40_000,
"o2 consumes A's 40k remainder, not A's original 100k"
);
}
#[test]
fn bulk_reclassify_outflow_scope_excludes_gift_and_donate() {
use btctax_cli::eventref::parse_dispose_kind;
assert!(matches!(
parse_dispose_kind("sell").unwrap(),
DisposeKind::Sell
));
assert!(matches!(
parse_dispose_kind("spend").unwrap(),
DisposeKind::Spend
));
for bad in ["gift", "donate", "GIFT", "Donate"] {
let err = parse_dispose_kind(bad).unwrap_err();
assert!(
matches!(err, CliError::Usage(_)),
"{bad} must be a Usage error"
);
}
assert!(parse_dispose_kind("junk").is_err());
}
#[test]
fn bulk_reclassify_outflow_apply_uniform_kind() {
let dir = tempfile::tempdir().unwrap();
let (vault, _w, _acqs, outs) = reclass_batch_vault(
dir.path(),
&[
("ro-u1", 40_000, datetime!(2025-03-01 12:00:00 UTC)),
("ro-u2", 40_000, datetime!(2025-06-15 12:00:00 UTC)),
],
);
cmd::reconcile::apply_bulk_reclassify_outflow(
&vault,
&pp(),
outs.clone(),
DisposeKind::Spend,
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let kinds: Vec<_> = btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.filter_map(|e| match e.payload {
EventPayload::ReclassifyOutflow(ro) => Some(ro.as_),
_ => None,
})
.collect();
assert_eq!(kinds.len(), 2);
assert!(
kinds.iter().all(|k| matches!(
k,
OutflowClass::Dispose {
kind: DisposeKind::Spend
}
)),
"every row is Dispose{{Spend}}"
);
}
#[test]
fn bulk_reclassify_outflow_fee_usd_none_but_fee_sat_flows() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("reclass-fee.pgp");
let mut session = Session::create(&vault, &pp()).unwrap();
let w = wallet_a();
let acq = EventId::import(Source::Coinbase, SourceRef::new("fee-acq"));
let out = EventId::import(Source::Coinbase, SourceRef::new("fee-out"));
let batch = vec![
LedgerEvent {
id: acq.clone(),
utc_timestamp: datetime!(2025-01-15 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(w.clone()),
payload: EventPayload::Acquire(Acquire {
sat: 1_000_000,
usd_cost: dec!(100.00),
fee_usd: dec!(0),
basis_source: BasisSource::ComputedFromCost,
}),
},
LedgerEvent {
id: out.clone(),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(w.clone()),
payload: EventPayload::TransferOut(TransferOut {
sat: 100_000,
fee_sat: Some(5_000),
dest_addr: None,
txid: None,
}),
},
];
append_import_batch(session.conn(), &batch).unwrap();
session.save().unwrap();
drop(session);
cmd::reconcile::apply_bulk_reclassify_outflow(
&vault,
&pp(),
vec![out.clone()],
DisposeKind::Sell,
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let ro = btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.find_map(|e| match e.payload {
EventPayload::ReclassifyOutflow(ro) => Some(ro),
_ => None,
})
.unwrap();
assert_eq!(
ro.fee_usd, None,
"fee_usd is always None on a bulk reclassify"
);
let (state, _) = s.project().unwrap();
let lot = state
.lots
.iter()
.find(|l| l.lot_id.origin_event_id == acq)
.unwrap();
assert_eq!(
lot.remaining_sat, 895_000,
"lot drawn down by principal + fee (fee_sat flows)"
);
}
#[test]
fn bulk_reclassify_outflow_empty_refuses() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _ids) = bulk_fixture(dir.path());
let s = Session::open(&vault, &pp()).unwrap();
let plan = s
.bulk_reclassify_outflow_plan(BulkFilter {
frame: Frame::Year(2030),
from_wallet: None,
})
.unwrap();
assert!(plan.included.is_empty(), "no pending outs in 2030");
assert_eq!(plan.total_sat, 0);
assert_eq!(plan.excluded_missing_price, 0);
drop(s);
let before = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
let n = cmd::reconcile::apply_bulk_reclassify_outflow(
&vault,
&pp(),
vec![],
DisposeKind::Sell,
now(),
)
.unwrap();
assert_eq!(n, 0, "empty id list appends nothing");
let after = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
assert_eq!(before, after, "no event appended for an empty batch");
}
#[test]
fn bulk_reclassify_outflow_dry_run_writes_nothing() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _ids) = bulk_fixture(dir.path());
let before = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
let plan = cmd::reconcile::bulk_reclassify_outflow_plan(
&vault,
&pp(),
BulkFilter {
frame: Frame::All,
from_wallet: None,
},
)
.unwrap();
assert!(
!plan.included.is_empty(),
"plan must select rows (so the no-write is meaningful)"
);
let after = {
let s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn()).unwrap().len()
};
assert_eq!(
before, after,
"computing the plan must not append any event"
);
}
#[test]
fn bulk_reclassify_outflow_estimated_flag_persists_and_joins() {
let dir = tempfile::tempdir().unwrap();
let (vault, _w, _acqs, outs) = reclass_batch_vault(
dir.path(),
&[
("ro-flag-bulk", 40_000, datetime!(2025-03-01 12:00:00 UTC)),
("ro-flag-solo", 40_000, datetime!(2025-06-15 12:00:00 UTC)),
],
);
let (bulk_out, solo_out) = (outs[0].clone(), outs[1].clone());
cmd::reconcile::apply_bulk_reclassify_outflow(
&vault,
&pp(),
vec![bulk_out.clone()],
DisposeKind::Sell,
now(),
)
.unwrap();
cmd::reconcile::reclassify_outflow(
&vault,
&pp(),
&solo_out.canonical(),
OutflowClass::Dispose {
kind: DisposeKind::Sell,
},
dec!(54.00),
None,
None,
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let flagged = s.bulk_estimated().unwrap();
assert!(
flagged.contains_key(&bulk_out),
"bulk reclassify is flagged"
);
assert!(
!flagged.contains_key(&solo_out),
"single-`o` reclassify is NOT flagged (control)"
);
let (state, _) = s.project().unwrap();
assert!(
state.disposals.iter().any(|d| d.event == bulk_out),
"the flagged key == a real Disposal.event (the Disposals-tab join lands)"
);
}
#[test]
fn bulk_reclassify_outflow_cli_void_clears_estimated_flag() {
let dir = tempfile::tempdir().unwrap();
let (vault, _w, _acqs, outs) = reclass_batch_vault(
dir.path(),
&[(
"ro-cli-void-clear",
40_000,
datetime!(2025-03-01 12:00:00 UTC),
)],
);
let bulk_out = outs[0].clone();
cmd::reconcile::apply_bulk_reclassify_outflow(
&vault,
&pp(),
vec![bulk_out.clone()],
DisposeKind::Sell,
now(),
)
.unwrap();
{
let s = Session::open(&vault, &pp()).unwrap();
assert!(
s.bulk_estimated().unwrap().contains_key(&bulk_out),
"flag set after bulk reclassify"
);
}
let reclass_id = {
let s = Session::open(&vault, &pp()).unwrap();
let (events, _st, _c) = s.load_events_and_project().unwrap();
events
.iter()
.find_map(|e| match &e.payload {
EventPayload::ReclassifyOutflow(ro) if ro.transfer_out_event == bulk_out => {
Some(e.id.clone())
}
_ => None,
})
.expect("reclassify decision exists")
};
cmd::reconcile::void(&vault, &pp(), &reclass_id.canonical(), now()).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
assert!(
!s.bulk_estimated().unwrap().contains_key(&bulk_out),
"CLI void of a reclassify MUST clear its [est] flag (stale-marker gap)"
);
}
#[test]
fn bulk_reclassify_outflow_cli_mid_batch_failure_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let (vault, _w, _acqs, outs) = reclass_batch_vault(
dir.path(),
&[
("ro-mb1", 40_000, datetime!(2025-03-01 12:00:00 UTC)),
("ro-mb2", 40_000, datetime!(2025-06-15 12:00:00 UTC)),
],
);
{
let mut session = Session::open(&vault, &pp()).unwrap();
session
.conn()
.execute_batch(
"CREATE TRIGGER inject_reclass_midbatch BEFORE INSERT ON events \
WHEN NEW.decision_seq = 2 \
BEGIN SELECT RAISE(ABORT, 'injected mid-batch append failure'); END;",
)
.unwrap();
session.save().unwrap();
}
let res = cmd::reconcile::apply_bulk_reclassify_outflow(
&vault,
&pp(),
outs.clone(),
DisposeKind::Sell,
now(),
);
assert!(res.is_err(), "a mid-batch abort surfaces as Err");
let s = Session::open(&vault, &pp()).unwrap();
let ro_count = btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.filter(|e| matches!(e.payload, EventPayload::ReclassifyOutflow(_)))
.count();
assert_eq!(
ro_count, 0,
"no ReclassifyOutflow appended after a mid-batch failure"
);
assert!(
s.bulk_estimated().unwrap().is_empty(),
"no phantom side-table rows after a mid-batch failure"
);
}
#[test]
fn bulk_reclassify_outflow_apply_then_disposals_reflect() {
use btctax_cli::{BulkFilter, Frame};
let dir = tempfile::tempdir().unwrap();
let (vault, _w, _acqs, _outs) = reclass_batch_vault(
dir.path(),
&[
("ro-e1", 40_000, datetime!(2025-03-01 12:00:00 UTC)),
("ro-e2", 40_000, datetime!(2025-06-15 12:00:00 UTC)),
],
);
let (disp_before, pend_before) = {
let s = Session::open(&vault, &pp()).unwrap();
let (st, _) = s.project().unwrap();
(st.disposals.len(), st.pending_reconciliation.len())
};
assert_eq!(pend_before, 2, "both outs start pending");
let plan = {
let s = Session::open(&vault, &pp()).unwrap();
s.bulk_reclassify_outflow_plan(BulkFilter {
frame: Frame::All,
from_wallet: None,
})
.unwrap()
};
let ids: Vec<_> = plan.included.iter().map(|r| r.out_event.clone()).collect();
let n =
cmd::reconcile::apply_bulk_reclassify_outflow(&vault, &pp(), ids, DisposeKind::Sell, now())
.unwrap();
assert_eq!(n, 2);
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert_eq!(
state.disposals.len(),
disp_before + 2,
"disposals grew by the included count"
);
assert_eq!(
state.pending_reconciliation.len(),
pend_before - 2,
"pending shrank by the same count"
);
assert!(
state
.blockers
.iter()
.all(|b| b.kind != BlockerKind::FmvMissing),
"a priced reclassify batch introduces NO FmvMissing"
);
}