use btctax_core::event::{
AllocMethod, ClassifyInbound, EventPayload, LedgerEvent, MethodElection, RejectImport,
SafeHarborAllocation, SupersedeImport, VoidDecisionEvent,
};
use btctax_core::identity::{EventId, Source, SourceRef};
use btctax_core::state::{Blocker, BlockerKind};
use btctax_core::{
is_revocable_payload, voidable_decisions, InboundClass, IncomeKind, LotMethod, WalletId,
};
use time::macros::{date, datetime};
use time::UtcOffset;
fn decision(seq: u64, payload: EventPayload) -> LedgerEvent {
LedgerEvent {
id: EventId::Decision { seq },
utc_timestamp: datetime!(2026-02-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: None,
payload,
}
}
fn import_ev(name: &str, payload: EventPayload) -> LedgerEvent {
LedgerEvent {
id: EventId::import(Source::River, SourceRef::new(name)),
utc_timestamp: datetime!(2025-03-01 12:00:00 UTC),
original_tz: UtcOffset::UTC,
wallet: Some(WalletId::Exchange {
provider: "River".into(),
account: "main".into(),
}),
payload,
}
}
fn method_election(seq: u64) -> LedgerEvent {
decision(
seq,
EventPayload::MethodElection(MethodElection {
effective_from: date!(2024 - 01 - 01),
method: LotMethod::Fifo,
wallet: None,
}),
)
}
fn classify_inbound(seq: u64, target: EventId) -> LedgerEvent {
decision(
seq,
EventPayload::ClassifyInbound(ClassifyInbound {
transfer_in_event: target,
as_: InboundClass::Income {
kind: IncomeKind::Staking,
fmv: None,
business: false,
},
}),
)
}
fn allocation(seq: u64) -> LedgerEvent {
decision(
seq,
EventPayload::SafeHarborAllocation(SafeHarborAllocation {
method: AllocMethod::ActualPosition,
as_of_date: date!(2025 - 01 - 01),
lots: vec![],
timely_allocation_attested: false,
pre2025_method: LotMethod::Fifo,
}),
)
}
fn blocker_on(kind: BlockerKind, id: &EventId) -> Blocker {
Blocker {
kind,
event: Some(id.clone()),
detail: String::new(),
}
}
#[test]
fn voidable_decisions_matches_single_void_flow() {
let ti = import_ev(
"vd-ti",
EventPayload::TransferIn(btctax_core::event::TransferIn {
sat: 100_000,
src_addr: None,
txid: None,
}),
);
let ci_live = classify_inbound(1, ti.id.clone());
let me = method_election(2);
let supersede = decision(
3,
EventPayload::SupersedeImport(SupersedeImport {
conflict_event: ti.id.clone(),
}),
);
let reject = decision(
4,
EventPayload::RejectImport(RejectImport {
conflict_event: ti.id.clone(),
}),
);
let ci_voided = classify_inbound(5, ti.id.clone());
let void_of_5 = decision(
6,
EventPayload::VoidDecisionEvent(VoidDecisionEvent {
target_event_id: ci_voided.id.clone(),
}),
);
let events = vec![
ti,
ci_live.clone(),
me.clone(),
supersede,
reject,
ci_voided,
void_of_5,
];
let blockers: Vec<Blocker> = vec![];
let got: Vec<EventId> = voidable_decisions(&events, &blockers)
.into_iter()
.map(|e| e.id.clone())
.collect();
assert_eq!(
got,
vec![ci_live.id.clone(), me.id.clone()],
"voidable set = the live revocable decisions only (order = events order)"
);
assert!(is_revocable_payload(&ci_live.payload));
assert!(is_revocable_payload(&me.payload));
assert!(!is_revocable_payload(&EventPayload::SupersedeImport(
SupersedeImport {
conflict_event: EventId::decision(99)
}
)));
assert!(!is_revocable_payload(&EventPayload::VoidDecisionEvent(
VoidDecisionEvent {
target_event_id: EventId::decision(99)
}
)));
}
#[test]
fn bulk_void_excludes_effective_allocation() {
let alloc = allocation(1);
let events = vec![alloc.clone()];
let blockers: Vec<Blocker> = vec![];
let got = voidable_decisions(&events, &blockers);
assert!(
got.is_empty(),
"an effective allocation must NOT be a voidable candidate (#7)"
);
}
#[test]
fn bulk_void_includes_inert_allocation() {
let alloc = allocation(1);
let events = vec![alloc.clone()];
let timebar = vec![blocker_on(BlockerKind::SafeHarborTimebar, &alloc.id)];
assert_eq!(
voidable_decisions(&events, &timebar)
.into_iter()
.map(|e| e.id.clone())
.collect::<Vec<_>>(),
vec![alloc.id.clone()],
"a timebarred (inert) allocation stays voidable"
);
let unconservable = vec![blocker_on(BlockerKind::SafeHarborUnconservable, &alloc.id)];
assert_eq!(
voidable_decisions(&events, &unconservable)
.into_iter()
.map(|e| e.id.clone())
.collect::<Vec<_>>(),
vec![alloc.id.clone()],
"an unconservable (inert) allocation stays voidable"
);
}