use btctax_core::event::*;
use btctax_core::identity::*;
use btctax_core::price::StaticPrices;
use btctax_core::project::{
evaluate_disposal, project, CandidateDisposal, EvaluateError, ProjectionConfig,
};
use btctax_core::BlockerKind;
use rust_decimal_macros::dec;
use time::macros::{date, datetime, offset};
fn w() -> WalletId {
WalletId::Exchange {
provider: "cb".into(),
account: "m".into(),
}
}
fn imp(rf: &str, ts: time::OffsetDateTime, p: EventPayload) -> LedgerEvent {
LedgerEvent {
id: EventId::import(Source::Coinbase, SourceRef::new(rf)),
utc_timestamp: ts,
original_tz: offset!(+00:00),
wallet: Some(w()),
payload: p,
}
}
fn buy(rf: &str, ts: time::OffsetDateTime, sat: i64, cost: rust_decimal::Decimal) -> LedgerEvent {
imp(
rf,
ts,
EventPayload::Acquire(Acquire {
sat,
usd_cost: cost,
fee_usd: dec!(0),
basis_source: BasisSource::ExchangeProvided,
}),
)
}
fn sell(
rf: &str,
ts: time::OffsetDateTime,
sat: i64,
proceeds: rust_decimal::Decimal,
) -> LedgerEvent {
imp(
rf,
ts,
EventPayload::Dispose(Dispose {
sat,
usd_proceeds: proceeds,
fee_usd: dec!(0),
kind: DisposeKind::Sell,
}),
)
}
#[test]
fn synthetic_future_disposal_requires_proceeds_when_no_price() {
let evs = vec![buy(
"A",
datetime!(2025-02-01 00:00:00 UTC),
100_000,
dec!(50.00),
)];
let cand = CandidateDisposal {
existing_event: None,
wallet: w(),
date: date!(2030 - 01 - 01),
sat: 100_000,
kind: DisposeKind::Sell,
proceeds: None,
};
let err = evaluate_disposal(
&evs,
&StaticPrices::default(),
&ProjectionConfig::default(),
&cand,
None,
)
.unwrap_err();
assert_eq!(err, EvaluateError::ProceedsRequired);
}
#[test]
fn synthetic_disposal_with_proceeds_returns_gain_and_is_side_effect_free() {
let evs = vec![buy(
"A",
datetime!(2025-02-01 00:00:00 UTC),
100_000,
dec!(50.00),
)];
let cand = CandidateDisposal {
existing_event: None,
wallet: w(),
date: date!(2026 - 06 - 01),
sat: 100_000,
kind: DisposeKind::Sell,
proceeds: Some(dec!(150.00)),
};
let out = evaluate_disposal(
&evs,
&StaticPrices::default(),
&ProjectionConfig::default(),
&cand,
None,
)
.unwrap();
assert_eq!(out.legs.len(), 1);
assert_eq!(out.legs[0].gain, dec!(100.00)); assert_eq!(out.lt_gain, dec!(100.00)); let base = project(&evs, &StaticPrices::default(), &ProjectionConfig::default());
assert!(base.disposals.is_empty());
}
#[test]
fn synthetic_disposal_uses_dataset_fmv_when_proceeds_omitted_and_price_exists() {
let mut px = std::collections::BTreeMap::new();
px.insert(date!(2026 - 06 - 01), dec!(100000.00)); let prices = StaticPrices(px);
let evs = vec![buy(
"A",
datetime!(2025-02-01 00:00:00 UTC),
100_000,
dec!(50.00),
)];
let cand = CandidateDisposal {
existing_event: None,
wallet: w(),
date: date!(2026 - 06 - 01),
sat: 100_000,
kind: DisposeKind::Sell,
proceeds: None,
};
let out = evaluate_disposal(&evs, &prices, &ProjectionConfig::default(), &cand, None).unwrap();
assert_eq!(out.legs[0].proceeds, dec!(100.00));
}
#[test]
fn existing_disposal_scored_with_an_injected_selection() {
let evs = vec![
buy(
"A",
datetime!(2025-02-01 00:00:00 UTC),
100_000,
dec!(50.00),
),
buy(
"B",
datetime!(2025-03-01 00:00:00 UTC),
100_000,
dec!(90.00),
),
sell(
"D",
datetime!(2025-07-01 00:00:00 UTC),
100_000,
dec!(95.00),
),
];
let cand = CandidateDisposal {
existing_event: Some(EventId::import(Source::Coinbase, SourceRef::new("D"))),
wallet: w(),
date: date!(2025 - 07 - 01),
sat: 100_000,
kind: DisposeKind::Sell,
proceeds: None,
};
let picks = vec![LotPick {
lot: LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("B")),
split_sequence: 0,
},
sat: 100_000,
}];
let out = evaluate_disposal(
&evs,
&StaticPrices::default(),
&ProjectionConfig::default(),
&cand,
Some(&picks),
)
.unwrap();
assert_eq!(out.legs[0].basis, dec!(90.00)); }
#[test]
fn existing_disposal_selection_validated_against_resolved_principal_not_candidate_sat() {
let evs = vec![
buy(
"A",
datetime!(2025-02-01 00:00:00 UTC),
100_000,
dec!(50.00),
),
buy(
"B",
datetime!(2025-03-01 00:00:00 UTC),
100_000,
dec!(90.00),
),
sell(
"D",
datetime!(2025-07-01 00:00:00 UTC),
100_000,
dec!(95.00),
),
];
let cand = CandidateDisposal {
existing_event: Some(EventId::import(Source::Coinbase, SourceRef::new("D"))),
wallet: w(),
date: date!(2025 - 07 - 01),
sat: 60_000, kind: DisposeKind::Sell,
proceeds: None,
};
let picks = vec![LotPick {
lot: LotId {
origin_event_id: EventId::import(Source::Coinbase, SourceRef::new("B")),
split_sequence: 0,
},
sat: 60_000,
}];
let out = evaluate_disposal(
&evs,
&StaticPrices::default(),
&ProjectionConfig::default(),
&cand,
Some(&picks),
)
.unwrap();
assert!(
out.blockers
.iter()
.any(|b| matches!(b.kind, BlockerKind::LotSelectionInvalid)),
"a selection whose Σ (60000) != the resolved principal (100000) must raise \
LotSelectionInvalid — not silently mis-consume; got {:?}",
out.blockers
);
}
#[test]
fn evaluate_disposal_existing_no_selection_is_no_op_identity() {
let evs = vec![
buy(
"A",
datetime!(2025-02-01 00:00:00 UTC),
100_000,
dec!(50.00),
),
sell(
"D",
datetime!(2025-07-01 00:00:00 UTC),
100_000,
dec!(95.00),
),
];
let state = project(&evs, &StaticPrices::default(), &ProjectionConfig::default());
assert_eq!(state.disposals.len(), 1, "setup: exactly one disposal");
let ref_legs = state.disposals[0].legs.clone();
let cand = CandidateDisposal {
existing_event: Some(EventId::import(Source::Coinbase, SourceRef::new("D"))),
wallet: w(),
date: date!(2025 - 07 - 01),
sat: 100_000,
kind: DisposeKind::Sell,
proceeds: None,
};
let out = evaluate_disposal(
&evs,
&StaticPrices::default(),
&ProjectionConfig::default(),
&cand,
None, )
.unwrap();
assert_eq!(
out.legs, ref_legs,
"evaluate_disposal with no selection must match project() legs (no-op identity)"
);
assert!(
out.blockers.is_empty(),
"no blockers expected for a clean disposal with no injected selection; got {:?}",
out.blockers
);
let ref_st: rust_decimal::Decimal = ref_legs
.iter()
.filter(|l| l.term == btctax_core::Term::ShortTerm)
.map(|l| l.gain)
.sum();
let ref_lt: rust_decimal::Decimal = ref_legs
.iter()
.filter(|l| l.term == btctax_core::Term::LongTerm)
.map(|l| l.gain)
.sum();
assert_eq!(
out.st_gain, ref_st,
"st_gain must match project() (no-op identity)"
);
assert_eq!(
out.lt_gain, ref_lt,
"lt_gain must match project() (no-op identity)"
);
}