use btctax_cli::{cmd, CliError, Session};
use btctax_core::conservative::Coverage;
use btctax_core::{
Acknowledgment, DeclareTranche, EventId, EventPayload, FloorMethod, LedgerEvent, LotMethod,
PromoteTranche, WalletId,
};
use btctax_store::Passphrase;
use std::path::Path;
use time::macros::date;
fn pp() -> Passphrase {
Passphrase::new("pw".into())
}
fn now() -> time::OffsetDateTime {
time::macros::datetime!(2026 - 01 - 01 0:00 UTC)
}
const HEADER: &str = "\r\nTransactions\r\nUser,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";
fn tranche_wallet() -> WalletId {
WalletId::Exchange {
provider: "cold".into(),
account: "vault".into(),
}
}
fn count<P: Fn(&EventPayload) -> bool>(vault: &Path, pred: P) -> usize {
let s = Session::open(vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn())
.unwrap()
.iter()
.filter(|e| pred(&e.payload))
.count()
}
fn vault_pre2025_buy(dir: &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,
format!(
"{HEADER}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();
vault
}
fn vault_effective_alloc(dir: &Path) -> std::path::PathBuf {
let vault = vault_pre2025_buy(dir);
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
btctax_core::AllocMethod::ActualPosition,
false,
now(),
)
.unwrap();
vault
}
fn vault_inert_alloc(dir: &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,
format!(
"{HEADER}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 alloc = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
btctax_core::AllocMethod::ActualPosition,
false,
now(),
)
.unwrap();
{
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state
.blockers
.iter()
.any(|b| b.event.as_ref() == Some(&alloc)
&& b.kind == btctax_core::BlockerKind::SafeHarborTimebar),
"fixture must produce an INERT (time-barred) allocation"
);
}
vault
}
#[test]
fn pre2025_tranche_refused_under_effective_allocation() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_effective_alloc(dir.path());
let err = cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2018 - 01 - 01),
date!(2018 - 12 - 31),
now(),
)
.unwrap_err();
assert!(
matches!(err, CliError::Usage(_)),
"expected Usage; got {err}"
);
assert!(
err.to_string().to_lowercase().contains("safe-harbor")
|| err.to_string().to_lowercase().contains("allocation"),
"the refusal must name the allocation collision: {err}"
);
assert_eq!(
count(&vault, |p| matches!(p, EventPayload::DeclareTranche(_))),
0,
"the refused tranche must NOT be appended (fail-closed)"
);
}
#[test]
fn pre2025_tranche_refused_under_inert_allocation() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_inert_alloc(dir.path());
let err = cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2018 - 01 - 01),
date!(2018 - 12 - 31),
now(),
)
.unwrap_err();
assert!(
matches!(err, CliError::Usage(_)),
"an INERT (non-voided) allocation is still in-force for this guard: {err}"
);
assert_eq!(
count(&vault, |p| matches!(p, EventPayload::DeclareTranche(_))),
0,
);
}
#[test]
fn allocation_refused_under_a_pre2025_tranche() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_pre2025_buy(dir.path());
cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2018 - 01 - 01),
date!(2018 - 12 - 31),
now(),
)
.unwrap();
cmd::admin::set_pre2025_method(&vault, &pp(), LotMethod::Fifo, true).unwrap();
let err = cmd::reconcile::safe_harbor_allocate(
&vault,
&pp(),
btctax_core::AllocMethod::ActualPosition,
false,
now(),
)
.unwrap_err();
assert!(
matches!(err, CliError::Usage(_)) && err.to_string().to_lowercase().contains("tranche"),
"the refusal must name the tranche collision: {err}"
);
assert_eq!(
count(&vault, |p| matches!(
p,
EventPayload::SafeHarborAllocation(_)
)),
0,
"the refused allocation must NOT be appended (fail-closed)"
);
}
#[test]
fn post2025_tranche_records_cleanly_beside_effective_allocation() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_effective_alloc(dir.path());
cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2025 - 06 - 01),
date!(2025 - 12 - 31),
now(),
)
.expect(
"a ≥2025 tranche must record cleanly beside an effective allocation (P7 not foreclosed)",
);
assert_eq!(
count(&vault, |p| matches!(p, EventPayload::DeclareTranche(_))),
1,
"the ≥2025 tranche must be appended",
);
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
!state.blockers.iter().any(|b| matches!(
b.kind,
btctax_core::BlockerKind::SafeHarborUnconservable
| btctax_core::BlockerKind::SafeHarborTimebar
)),
"a ≥2025 tranche must NOT poison the effective allocation (Path B preserved): {:?}",
state.blockers
);
assert!(
state
.lots
.iter()
.any(|l| l.basis_source == btctax_core::BasisSource::SafeHarborAllocated),
"the pre-2025 residue must be SEEDED under the effective allocation (Path B), not reconstructed"
);
assert!(
state.lots.iter().any(|l| l.basis_source
== btctax_core::BasisSource::EstimatedConservative
&& l.remaining_sat == 50_000_000),
"the ≥2025 tranche coexists as its own EstimatedConservative lot"
);
}
#[test]
fn safe_harbor_residue_refuses_when_a_pre2025_tranche_exists() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_pre2025_buy(dir.path());
cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2018 - 01 - 01),
date!(2018 - 12 - 31),
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let err = s
.safe_harbor_residue()
.expect_err("a pre-2025 tranche must refuse the allocate-flow opener");
assert!(
format!("{err}").contains("mutually exclusive"),
"the refusal names the D-8 mutual exclusion: {err}"
);
}
fn promote_ev(seq: u64, target: EventId, filed_basis: rust_decimal::Decimal) -> LedgerEvent {
LedgerEvent {
id: EventId::decision(seq),
utc_timestamp: now(),
original_tz: time::UtcOffset::UTC,
wallet: None,
payload: EventPayload::PromoteTranche(PromoteTranche {
target,
method: FloorMethod::WindowLowClose,
filed_basis,
coverage: Coverage::Full,
provenance_attested: true,
acknowledgment: Acknowledgment {
phrase: "I understand and accept the risk".into(),
shown_terms: vec![],
provenance_text: "acquired by purchase within the declared window".into(),
provenance_version: "v1".into(),
},
part_ii_narrative: "cash P2P purchase, no records; window bounded on-chain".into(),
}),
}
}
#[test]
fn a_promoted_tranche_still_refuses_a_safe_harbor_allocation_at_record_time() {
let tranche = LedgerEvent {
id: EventId::decision(1),
utc_timestamp: now(),
original_tz: time::UtcOffset::UTC,
wallet: None,
payload: EventPayload::DeclareTranche(DeclareTranche {
sat: 50_000_000,
wallet: tranche_wallet(),
window_start: date!(2018 - 01 - 01),
window_end: date!(2018 - 12 - 31),
}),
};
let promote = promote_ev(2, EventId::decision(1), rust_decimal::Decimal::from(12_000));
let events = vec![tranche, promote];
assert!(
cmd::tranche::guard_allocation_vs_tranche(&events).is_err(),
"a promoted pre-2025 tranche still blocks a safe-harbor allocation (D-8, tag-keyed)"
);
}
fn empty_vault(dir: &Path) -> std::path::PathBuf {
let vault = dir.join("vault.pgp");
cmd::init::run(&vault, &pp(), &dir.join("k.asc")).unwrap();
vault
}
#[test]
fn declare_tranche_records_and_folds_to_zero_basis_lot() {
let dir = tempfile::tempdir().unwrap();
let vault = empty_vault(dir.path());
cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2020 - 01 - 01),
date!(2020 - 12 - 31),
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
let lot = state
.lots
.iter()
.find(|l| l.wallet == tranche_wallet())
.expect("a tranche lot in the declared wallet");
assert_eq!(
lot.usd_basis,
btctax_core::Usd::ZERO,
"tranche basis is $0 (D-7)"
);
assert_eq!(
lot.basis_source,
btctax_core::BasisSource::EstimatedConservative
);
assert_eq!(
lot.acquired_at,
date!(2020 - 12 - 31),
"homed at window_end (D-2)"
);
assert!(!lot.pseudo, "a filed tranche is NOT pseudo (D-5)");
assert!(
!state.pseudo_active(),
"a real tranche never activates pseudo mode (D-5)"
);
}
#[test]
fn declare_tranche_refuses_nonpositive_sat() {
for bad in [0_i64, -1] {
let dir = tempfile::tempdir().unwrap();
let vault = empty_vault(dir.path());
let err = cmd::tranche::declare_tranche(
&vault,
&pp(),
bad,
tranche_wallet(),
date!(2020 - 01 - 01),
date!(2020 - 12 - 31),
now(),
)
.unwrap_err();
assert!(
matches!(err, CliError::Usage(_)) && err.to_string().contains("> 0"),
"sat {bad} must be refused: {err}"
);
assert_eq!(
count(&vault, |p| matches!(p, EventPayload::DeclareTranche(_))),
0,
"the refused tranche must NOT be appended (fail-closed)"
);
}
}
#[test]
fn declare_tranche_refuses_inverted_window() {
let dir = tempfile::tempdir().unwrap();
let vault = empty_vault(dir.path());
let err = cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2020 - 12 - 31),
date!(2020 - 01 - 01),
now(),
)
.unwrap_err();
assert!(
matches!(err, CliError::Usage(_)) && err.to_string().to_lowercase().contains("window"),
"an inverted window must be refused: {err}"
);
assert_eq!(
count(&vault, |p| matches!(p, EventPayload::DeclareTranche(_))),
0,
);
}
#[test]
fn declare_tranche_accepts_future_window_end() {
let dir = tempfile::tempdir().unwrap();
let vault = empty_vault(dir.path());
cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2027 - 01 - 01),
date!(2027 - 12 - 31),
now(),
)
.expect("a future window_end warns but does not refuse");
assert_eq!(
count(&vault, |p| matches!(p, EventPayload::DeclareTranche(_))),
1,
);
}
#[test]
fn filed_tranche_year_exports_clean() {
let dir = tempfile::tempdir().unwrap();
let vault = empty_vault(dir.path());
cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2020 - 01 - 01),
date!(2020 - 12 - 31),
now(),
)
.unwrap();
let out = dir.path().join("export_out");
cmd::admin::export_snapshot(&vault, &pp(), &out, Some(2020), None)
.expect("a filed-tranche year must export clean with NO attestation (not pseudo, D-5)");
}
#[test]
fn attest_refused_under_a_pre2025_tranche() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_pre2025_buy(dir.path());
cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2018 - 01 - 01),
date!(2018 - 12 - 31),
now(),
)
.unwrap();
let err = cmd::reconcile::safe_harbor_attest(&vault, &pp(), now()).unwrap_err();
assert!(
matches!(err, CliError::Usage(_)) && err.to_string().to_lowercase().contains("tranche"),
"attest must refuse with the TRANCHE message (guard fires before 'no allocation'): {err}"
);
}
#[test]
fn tranche_dip_advisory_reaches_the_tax_year_report() {
use btctax_core::{Carryforward, FilingStatus, TaxProfile};
use rust_decimal_macros::dec;
let dir = tempfile::tempdir().unwrap();
let vault = empty_vault(dir.path());
cmd::tranche::declare_tranche(
&vault,
&pp(),
100_000_000,
tranche_wallet(),
date!(2018 - 01 - 01),
date!(2018 - 12 - 31),
now(),
)
.unwrap();
let csv = dir.path().join("sell.csv");
std::fs::write(
&csv,
format!(
"{HEADER}cb-sell,2020-06-01 12:00:00 UTC,Sell,BTC,0.50000000,USD,80000.00,40000.00,40000.00,0.00,,,\r\n"
),
)
.unwrap();
cmd::import::run(&vault, &pp(), &[csv]).unwrap();
let profile = TaxProfile {
filing_status: FilingStatus::Single,
ordinary_taxable_income: dec!(40000),
magi_excluding_crypto: dec!(60000),
qualified_dividends_and_other_pref_income: dec!(0),
other_net_capital_gain: dec!(0),
capital_loss_carryforward_in: Carryforward::default(),
w2_ss_wages: dec!(0),
w2_medicare_wages: dec!(0),
schedule_c_expenses: dec!(0),
};
cmd::tax::set_profile(&vault, &pp(), 2020, profile, false).unwrap();
let report = cmd::tax::report_tax_year(&vault, &pp(), 2020, dec!(0)).unwrap();
let adv = report
.tranche_advisory
.expect("the tranche dip advisory must reach the tax-year report (surfacing)");
assert!(
adv.to_lowercase().contains("undocumented"),
"the dip advisory text must surface: {adv}"
);
assert!(
adv.contains("$0"),
"basis as filed ($0) must surface: {adv}"
);
}
#[test]
fn wallet_is_known_covers_imports_and_tranche_payloads_and_flags_phantoms() {
use btctax_core::event::{Acquire, DeclareTranche};
use btctax_core::identity::{EventId, Source, SourceRef};
use btctax_core::{BasisSource, LedgerEvent};
use rust_decimal_macros::dec;
use time::macros::date;
let imported = WalletId::Exchange {
provider: "cb".into(),
account: "m".into(),
};
let declared = WalletId::SelfCustody {
label: "cold".into(),
};
let phantom = WalletId::SelfCustody {
label: "typo".into(),
};
let import = LedgerEvent {
id: EventId::import(Source::Coinbase, SourceRef::new("x")),
utc_timestamp: now(),
original_tz: time::UtcOffset::UTC,
wallet: Some(imported.clone()),
payload: EventPayload::Acquire(Acquire {
sat: 1,
usd_cost: dec!(1),
fee_usd: dec!(0),
basis_source: BasisSource::ExchangeProvided,
}),
};
let tranche = LedgerEvent {
id: EventId::decision(1),
utc_timestamp: now(),
original_tz: time::UtcOffset::UTC,
wallet: None,
payload: EventPayload::DeclareTranche(DeclareTranche {
sat: 1,
wallet: declared.clone(),
window_start: date!(2018 - 01 - 01),
window_end: date!(2018 - 12 - 31),
}),
};
let evs = vec![import, tranche];
assert!(
cmd::tranche::wallet_is_known(&evs, &imported),
"an import's e.wallet is known"
);
assert!(
cmd::tranche::wallet_is_known(&evs, &declared),
"a tranche payload's wallet is known"
);
assert!(
!cmd::tranche::wallet_is_known(&evs, &phantom),
"a never-referenced wallet is a phantom (→ warn)"
);
}
#[test]
fn reconcile_void_refuses_voiding_an_effective_allocation() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_effective_alloc(dir.path());
let alloc_id = effective_alloc_id(&vault);
let err = cmd::reconcile::void(&vault, &pp(), &alloc_id.canonical(), now()).unwrap_err();
assert!(
matches!(err, CliError::Usage(_)) && err.to_string().to_lowercase().contains("effective"),
"voiding an effective allocation is refused as irrevocable: {err}"
);
}
#[test]
fn handcrafted_void_of_effective_alloc_then_tranche_admits_and_survives_via_path_a() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_effective_alloc(dir.path());
let alloc_id = effective_alloc_id(&vault);
{
let mut s = Session::open(&vault, &pp()).unwrap();
btctax_core::persistence::append_decision(
s.conn(),
EventPayload::VoidDecisionEvent(btctax_core::event::VoidDecisionEvent {
target_event_id: alloc_id,
}),
now(),
time::UtcOffset::UTC,
None,
)
.unwrap();
s.save().unwrap();
}
cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2018 - 01 - 01),
date!(2018 - 12 - 31),
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
state.lots.iter().any(|l| l.basis_source
== btctax_core::BasisSource::EstimatedConservative
&& l.remaining_sat > 0),
"the tranche survives via Path A (tag intact)"
);
assert!(
!state
.lots
.iter()
.any(|l| l.basis_source == btctax_core::BasisSource::SafeHarborAllocated),
"no Path-B seed lot — the tranche is never silently discarded (SPEC D-8)"
);
assert!(
!state
.blockers
.iter()
.any(|b| b.kind == btctax_core::BlockerKind::SafeHarborUnconservable),
"no Hard SafeHarborUnconservable left on the retired allocation: {:?}",
state.blockers
);
}
fn effective_alloc_id(vault: &Path) -> btctax_core::EventId {
let s = Session::open(vault, &pp()).unwrap();
btctax_core::persistence::load_all(s.conn())
.unwrap()
.into_iter()
.find(|e| matches!(e.payload, EventPayload::SafeHarborAllocation(_)))
.map(|e| e.id)
.expect("an allocation is on file")
}
#[test]
fn void_inert_alloc_then_declare_pre2025_tranche_keeps_the_year_computable() {
let dir = tempfile::tempdir().unwrap();
let vault = vault_inert_alloc(dir.path());
let alloc_id = effective_alloc_id(&vault);
cmd::reconcile::void(&vault, &pp(), &alloc_id.canonical(), now()).unwrap(); cmd::tranche::declare_tranche(
&vault,
&pp(),
50_000_000,
tranche_wallet(),
date!(2018 - 01 - 01),
date!(2018 - 12 - 31),
now(),
)
.unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (state, _) = s.project().unwrap();
assert!(
!state
.blockers
.iter()
.any(|b| b.kind.severity() == btctax_core::Severity::Hard),
"no Hard blocker survives the void-inert-then-declare flow ⇒ every year computes (I-1): {:?}",
state.blockers
);
assert!(
state.lots.iter().any(|l| l.basis_source
== btctax_core::BasisSource::EstimatedConservative
&& l.remaining_sat > 0),
"the tranche lot survives via Path A (tag intact)"
);
}