use crate::conservative_promote::{clamped_leg_basis, estimate_share_of, PromoteSet};
use crate::conventions::{
is_long_term, long_term_default_acquired, round_cents, split_pro_rata, Sat, TaxDate, Usd,
TRANSITION_DATE,
};
use crate::event::{BasisSource, DisposeKind};
use crate::identity::{EventId, LotId};
use crate::price::{fmv_of, PriceProvider};
use crate::project::pools::{pool_key, Consumed, PoolKey, PoolSet};
use crate::project::resolve::{sort_canonical, Eff, ElectionRec, Op, Resolution};
use crate::project::transition;
use crate::state::{
BlockerKind, Disposal, DisposalLeg, FoldStats, GiftZone, IncomeRecord, LedgerState, Lot,
PendingLeg, PendingTransfer, Removal, RemovalKind, RemovalLeg, ShortfallRecord, Term,
};
use crate::{FeeTreatment, LotMethod, ProjectionConfig};
use std::collections::BTreeMap;
pub(crate) struct FoldCtx<'a> {
pub config: &'a ProjectionConfig,
pub elections: &'a [ElectionRec],
pub selections: &'a BTreeMap<EventId, Vec<crate::event::LotPick>>,
pub promotes: PromoteSet,
}
fn applicable_method(
date: TaxDate,
wallet: &crate::identity::WalletId,
ctx: &FoldCtx,
) -> LotMethod {
if date < TRANSITION_DATE {
ctx.config.pre2025_method
} else {
crate::project::resolve::resolve_election(date, wallet, ctx.elections)
.map(|e| e.method)
.unwrap_or(LotMethod::Hifo)
}
}
#[allow(clippy::too_many_arguments)]
fn consume_principal(
pools: &mut PoolSet,
key: &PoolKey,
need: Sat,
date: TaxDate,
wallet: &crate::identity::WalletId,
ctx: &FoldCtx,
st: &mut LedgerState,
ev: &EventId,
) -> (Vec<Consumed>, Sat) {
let method = applicable_method(date, wallet, ctx);
let selection = ctx.selections.get(ev).map(|v| v.as_slice());
let r = pools.consume(key, need, method, selection);
if let Some(reason) = r.selection_error {
st.add_blocker(BlockerKind::LotSelectionInvalid, Some(ev.clone()), reason);
}
(r.consumed, r.shortfall)
}
fn term_for(start: TaxDate, disposed: TaxDate) -> Term {
if is_long_term(start, disposed) {
Term::LongTerm
} else {
Term::ShortTerm
}
}
fn note_pre2025_once(
st: &mut LedgerState,
date: TaxDate,
ev: &EventId,
method: LotMethod,
attested: bool,
) {
if date < TRANSITION_DATE
&& !st
.blockers
.iter()
.any(|b| b.kind == BlockerKind::Pre2025MethodNote)
{
let m = match method {
LotMethod::Fifo => "FIFO",
LotMethod::Lifo => "LIFO",
LotMethod::Hifo => "HIFO",
};
let detail = if attested {
format!(
"pre-2025 lots reconstructed under your DECLARED + ATTESTED filed method {m} (§7.4); \
carryforward basis into 2025 reflects that method"
)
} else {
format!(
"pre-2025 lots reconstructed under {m} (FIFO is the §7.4 legal default); \
you have NOT declared your filed pre-2025 lot method — if your filed pre-2025 returns \
used a different method your carryforward basis may differ. \
Declare it: config --set-pre2025-method <m> --attest-pre2025-method"
)
};
st.add_blocker(BlockerKind::Pre2025MethodNote, Some(ev.clone()), detail);
}
}
fn make_disposal_legs(
consumed: &[Consumed],
total_net_proceeds: Usd,
disposed: TaxDate,
st: &mut LedgerState,
ev: &EventId,
ev_pseudo: bool,
promotes: &PromoteSet,
) -> Vec<DisposalLeg> {
let total_sat: i64 = consumed.iter().map(|c| c.sat).sum();
let mut legs = Vec::new();
let mut allocated = Usd::ZERO;
for (i, c) in consumed.iter().enumerate() {
let proceeds = if i + 1 == consumed.len() {
total_net_proceeds - allocated
} else {
let (p, _) = split_pro_rata(total_net_proceeds, c.sat, total_sat);
allocated += p;
p
};
if c.basis_pending {
st.add_blocker(
BlockerKind::FmvMissing,
Some(ev.clone()),
"disposal consumes a basis-pending lot",
);
}
let (basis, gain, term, gift_zone, acquired_at) = if c.dual {
let loss_basis = c.loss_basis.expect("dual=true implies loss_basis is Some");
if proceeds > c.gain_basis {
let t = term_for(c.gain_hp_start, disposed);
(
c.gain_basis,
round_cents(proceeds - c.gain_basis),
t,
Some(GiftZone::Gain),
c.gain_hp_start, )
} else if proceeds < loss_basis {
let t = term_for(c.loss_hp_start, disposed);
(
loss_basis,
round_cents(proceeds - loss_basis),
t,
Some(GiftZone::Loss),
c.loss_hp_start, )
} else {
let t = term_for(c.gain_hp_start, disposed);
(
proceeds,
Usd::ZERO,
t,
Some(GiftZone::NoGainNoLoss),
c.gain_hp_start,
)
}
} else {
let basis = clamped_leg_basis(
promotes.get(&c.lot_id.origin_event_id),
c.sat,
c.gain_basis,
proceeds,
);
let t = term_for(c.gain_hp_start, disposed);
(
basis,
round_cents(proceeds - basis),
t,
None,
c.gain_hp_start,
)
};
legs.push(DisposalLeg {
lot_id: c.lot_id.clone(),
sat: c.sat,
proceeds,
basis,
gain,
term,
basis_source: c.basis_source,
gift_zone,
acquired_at,
wallet: c.wallet.clone(),
pseudo: c.pseudo || ev_pseudo,
});
}
legs
}
fn make_removal_legs(
consumed: &[Consumed],
total_fmv: Usd,
removed: TaxDate,
st: &mut LedgerState,
ev: &EventId,
ev_pseudo: bool,
promotes: &PromoteSet,
) -> (Vec<RemovalLeg>, Option<TaxDate>) {
let total_sat: i64 = consumed.iter().map(|c| c.sat).sum();
let mut legs = Vec::new();
let mut allocated = Usd::ZERO;
let mut donor = None;
for (i, c) in consumed.iter().enumerate() {
if c.basis_pending {
st.add_blocker(
BlockerKind::UnknownBasisInbound,
Some(ev.clone()),
"removal consumes a basis-pending lot",
);
}
let fmv = if i + 1 == consumed.len() {
total_fmv - allocated
} else {
let (f, _) = split_pro_rata(total_fmv, c.sat, total_sat);
allocated += f;
f
};
donor = donor.or(c.donor_acquired_at);
let basis = clamped_leg_basis(
promotes.get(&c.lot_id.origin_event_id),
c.sat,
c.gain_basis,
Usd::ZERO,
);
legs.push(RemovalLeg {
lot_id: c.lot_id.clone(),
sat: c.sat,
basis,
fmv_at_transfer: fmv,
term: term_for(c.gain_hp_start, removed),
basis_source: c.basis_source,
acquired_at: c.gain_hp_start,
pseudo: c.pseudo || ev_pseudo, });
}
(legs, donor)
}
#[derive(Default)]
struct FeeCarry {
gain_basis: Usd,
loss_basis: Option<Usd>,
}
impl FeeCarry {
fn rehome_onto_lot(&self, lot: &mut Lot) {
lot.usd_basis += self.gain_basis;
if let Some(l) = self.loss_basis {
if let Some(dl) = lot.dual_loss_basis.as_mut() {
*dl += l;
}
}
}
fn rehome_onto_removal_leg(&self, leg: &mut RemovalLeg) {
leg.basis += self.gain_basis;
}
fn rehome_onto_disposal_leg(&self, leg: &mut DisposalLeg) {
leg.basis += self.gain_basis;
leg.gain = round_cents(leg.proceeds - leg.basis);
}
}
#[allow(clippy::too_many_arguments)]
fn consume_fee(
pools: &mut PoolSet,
key: &PoolKey,
fee_sat: Sat,
config: &ProjectionConfig,
prices: &dyn PriceProvider,
date: TaxDate,
wallet: &crate::identity::WalletId,
stats: &mut FoldStats,
st: &mut LedgerState,
ev: &EventId,
ev_pseudo: bool,
promotes: &PromoteSet,
) -> FeeCarry {
if fee_sat <= 0 {
return FeeCarry::default();
}
let (consumed, shortfall) = pools.consume_fifo(key, fee_sat);
if shortfall > 0 {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(ev.clone()),
format!("self-transfer/gift fee short by {shortfall} sat"),
);
st.shortfalls.push(ShortfallRecord {
event: ev.clone(),
wallet: Some(wallet.clone()),
date,
principal_sat: 0,
fee_sat: shortfall,
});
}
stats.fee_sats_consumed += consumed.iter().map(|c| c.sat).sum::<Sat>(); match config.self_transfer_fee {
FeeTreatment::TreatmentC => {
let gain_basis: Usd = consumed
.iter()
.map(|c| match promotes.get(&c.lot_id.origin_event_id) {
Some(entry) => (c.gain_basis - estimate_share_of(entry, c.sat)).max(Usd::ZERO),
None => c.gain_basis,
})
.sum();
let has_loss = consumed.iter().any(|c| c.loss_basis.is_some());
let loss_basis = has_loss.then(|| consumed.iter().filter_map(|c| c.loss_basis).sum());
FeeCarry {
gain_basis,
loss_basis,
}
}
FeeTreatment::TreatmentB => {
if !consumed.is_empty() {
let net = fmv_of(prices, date, fee_sat).unwrap_or(Usd::ZERO);
let legs = make_disposal_legs(&consumed, net, date, st, ev, ev_pseudo, promotes);
st.disposals.push(Disposal {
event: ev.clone(),
kind: DisposeKind::Spend,
disposed_at: date,
legs,
fee_mini_disposition: true,
});
}
FeeCarry::default()
}
}
}
pub fn fold(
mut res: Resolution,
prices: &dyn PriceProvider,
config: &ProjectionConfig,
) -> LedgerState {
sort_canonical(&mut res.timeline);
res.timeline.sort_by_key(|e| e.date() >= TRANSITION_DATE);
let pseudo_count = res.pseudo_decisions.len();
let mut st = LedgerState {
blockers: res.blockers,
pseudo_synthetic_count: pseudo_count,
..Default::default()
};
if pseudo_count > 0 {
st.add_blocker(
BlockerKind::PseudoReconcileActive,
None,
format!(
"pseudo-reconcile mode is ON — {pseudo_count} synthetic default(s) are filling \
unresolved classifications with DELIBERATELY-FICTIONAL guesses. Rows flagged [PSEUDO] \
are placeholders, NOT real tax data. Correct them toward truth (or `reconcile pseudo \
approve` to attest chosen ones); export/forms are BLOCKED while this is active."
),
);
}
let mut pools = PoolSet::default();
let mut stats = FoldStats::default(); let mut seeded = false;
let ctx = FoldCtx {
config,
elections: &res.elections,
selections: &res.selections,
promotes: res.promotes.clone(), };
st.promoted_origins = ctx.promotes.keys().cloned().collect();
for eff in &res.timeline {
if !seeded && eff.date() >= TRANSITION_DATE {
transition::seed_transition(&res.transition, &mut pools, &mut st);
seeded = true;
}
fold_event(eff, prices, &ctx, &mut pools, &mut st, &mut stats);
}
finalize(&mut st, pools, stats); st
}
pub fn pools_before(
mut res: Resolution,
prices: &dyn PriceProvider,
config: &ProjectionConfig,
target: &EventId,
) -> PoolSet {
sort_canonical(&mut res.timeline);
res.timeline.sort_by_key(|e| e.date() >= TRANSITION_DATE);
let mut st = LedgerState::default(); let mut pools = PoolSet::default();
let mut stats = FoldStats::default();
let mut seeded = false;
let ctx = FoldCtx {
config,
elections: &res.elections,
selections: &res.selections,
promotes: res.promotes.clone(), };
for eff in &res.timeline {
if !seeded && eff.date() >= TRANSITION_DATE {
transition::seed_transition(&res.transition, &mut pools, &mut st);
seeded = true;
}
if &eff.id == target {
return pools; }
fold_event(eff, prices, &ctx, &mut pools, &mut st, &mut stats);
}
pools
}
pub fn state_as_of(
mut res: Resolution,
prices: &dyn PriceProvider,
config: &ProjectionConfig,
at: TaxDate,
) -> LedgerState {
sort_canonical(&mut res.timeline);
res.timeline.sort_by_key(|e| e.date() >= TRANSITION_DATE);
let mut st = LedgerState {
blockers: res.blockers,
..Default::default()
};
let mut pools = PoolSet::default();
let mut stats = FoldStats::default();
let mut seeded = false;
let ctx = FoldCtx {
config,
elections: &res.elections,
selections: &res.selections,
promotes: res.promotes.clone(), };
for eff in &res.timeline {
if !seeded && eff.date() >= TRANSITION_DATE {
transition::seed_transition(&res.transition, &mut pools, &mut st);
seeded = true;
}
if eff.date() > at {
continue;
}
fold_event(eff, prices, &ctx, &mut pools, &mut st, &mut stats);
}
if !seeded && at >= TRANSITION_DATE {
transition::seed_transition(&res.transition, &mut pools, &mut st);
}
finalize(&mut st, pools, stats);
st
}
pub(crate) fn fold_event(
eff: &Eff,
prices: &dyn PriceProvider,
ctx: &FoldCtx,
pools: &mut PoolSet,
st: &mut LedgerState,
stats: &mut FoldStats,
) {
let date = eff.date();
let ev_pseudo = eff.pseudo;
match &eff.op {
Op::Acquire(a) => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::Unclassified,
Some(eff.id.clone()),
"acquire without wallet",
);
return;
}
};
let lot = Lot {
lot_id: LotId {
origin_event_id: eff.id.clone(),
split_sequence: 0,
},
wallet: wallet.clone(),
acquired_at: date,
original_sat: a.sat,
remaining_sat: a.sat,
usd_basis: a.usd_cost + a.fee_usd, basis_source: a.basis_source,
dual_loss_basis: None,
donor_acquired_at: None,
basis_pending: false,
pseudo: ev_pseudo, };
pools.new_origin_lot(pool_key(date, &wallet), lot);
stats.sigma_in += a.sat; }
Op::Dispose {
sat,
proceeds,
fee_usd,
fee_sat,
kind,
} => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
"dispose without wallet",
);
return;
}
};
let key = pool_key(date, &wallet);
note_pre2025_once(
st,
date,
&eff.id,
ctx.config.pre2025_method,
ctx.config.pre2025_method_attested,
); let (consumed, shortfall) =
consume_principal(pools, &key, *sat, date, &wallet, ctx, st, &eff.id);
if shortfall > 0 {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
format!("dispose short by {shortfall} sat"),
);
st.shortfalls.push(ShortfallRecord {
event: eff.id.clone(),
wallet: Some(wallet.clone()),
date,
principal_sat: shortfall,
fee_sat: 0,
});
}
if !consumed.is_empty() {
let net = round_cents(*proceeds - *fee_usd); let mut legs =
make_disposal_legs(&consumed, net, date, st, &eff.id, ev_pseudo, &ctx.promotes);
let carry = consume_fee(
pools,
&key,
fee_sat.unwrap_or(0),
ctx.config,
prices,
date,
&wallet,
stats,
st,
&eff.id,
ev_pseudo,
&ctx.promotes,
);
if let Some(last) = legs.last_mut() {
carry.rehome_onto_disposal_leg(last);
} else if carry.gain_basis > Usd::ZERO {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
"fee carry has no surviving disposal leg to re-home onto (principal == 0)",
);
}
st.disposals.push(Disposal {
event: eff.id.clone(),
kind: *kind,
disposed_at: date,
legs,
fee_mini_disposition: false,
});
}
}
Op::Income {
sat,
fmv,
kind,
business,
} => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::FmvMissing,
Some(eff.id.clone()),
"income without wallet",
);
return;
}
};
let (basis, pending) = match fmv {
Some(v) => {
st.income_recognized.push(IncomeRecord {
event: eff.id.clone(),
recognized_at: date,
sat: *sat,
usd_fmv: *v,
kind: *kind,
business: *business,
pseudo: ev_pseudo, });
(*v, false)
}
None => {
st.add_blocker(
BlockerKind::FmvMissing,
Some(eff.id.clone()),
"income FMV missing",
);
(Usd::ZERO, true) }
};
let lot = Lot {
lot_id: LotId {
origin_event_id: eff.id.clone(),
split_sequence: 0,
},
wallet: wallet.clone(),
acquired_at: date,
original_sat: *sat,
remaining_sat: *sat,
usd_basis: basis,
basis_source: BasisSource::FmvAtIncome,
dual_loss_basis: None,
donor_acquired_at: None,
basis_pending: pending,
pseudo: ev_pseudo,
};
pools.new_origin_lot(pool_key(date, &wallet), lot);
stats.sigma_in += *sat; }
Op::PendingOut { sat, fee_sat } => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
"pending out without wallet",
);
return;
}
};
let key = pool_key(date, &wallet);
let total_sat = *sat + fee_sat.unwrap_or(0);
let (consumed, shortfall) = pools.consume_fifo(&key, total_sat);
if shortfall > 0 {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
format!("pending out short by {shortfall} sat"),
);
st.shortfalls.push(ShortfallRecord {
event: eff.id.clone(),
wallet: Some(wallet.clone()),
date,
principal_sat: shortfall,
fee_sat: 0,
});
}
let legs: Vec<PendingLeg> = consumed
.iter()
.map(|c| PendingLeg {
lot_id: c.lot_id.clone(),
sat: c.sat,
usd_basis: c.gain_basis,
acquired_at: c.acquired_at,
pseudo: c.pseudo || ev_pseudo, })
.collect();
st.pending_reconciliation.push(PendingTransfer {
event: eff.id.clone(),
principal_sat: *sat,
fee_sat: *fee_sat,
legs,
});
st.add_blocker(
BlockerKind::UnmatchedOutflows,
Some(eff.id.clone()),
"unmatched transfer out",
);
}
Op::SelfTransfer { sat, fee_sat, dest } => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
"self transfer without source wallet",
);
return;
}
};
let key = pool_key(date, &wallet);
let (consumed, shortfall) =
consume_principal(pools, &key, *sat, date, &wallet, ctx, st, &eff.id);
if shortfall > 0 {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
format!("self transfer short by {shortfall} sat"),
);
st.shortfalls.push(ShortfallRecord {
event: eff.id.clone(),
wallet: Some(wallet.clone()),
date,
principal_sat: shortfall,
fee_sat: 0,
});
}
let mut relocated: Vec<Lot> = Vec::new();
for c in &consumed {
let seq = pools.bump_split(&c.lot_id.origin_event_id);
relocated.push(Lot {
lot_id: LotId {
origin_event_id: c.lot_id.origin_event_id.clone(),
split_sequence: seq,
},
wallet: dest.clone(),
acquired_at: c.acquired_at,
original_sat: c.sat,
remaining_sat: c.sat,
usd_basis: c.gain_basis,
basis_source: if c.basis_source == BasisSource::EstimatedConservative {
BasisSource::EstimatedConservative
} else {
BasisSource::CarriedFromTransfer
},
dual_loss_basis: c.loss_basis,
donor_acquired_at: c.donor_acquired_at,
basis_pending: c.basis_pending,
pseudo: c.pseudo || ev_pseudo,
});
}
let carry = consume_fee(
pools,
&key,
fee_sat.unwrap_or(0),
ctx.config,
prices,
date,
&wallet,
stats,
st,
&eff.id,
ev_pseudo,
&ctx.promotes,
);
if let Some(last) = relocated.last_mut() {
carry.rehome_onto_lot(last);
} else if carry.gain_basis > Usd::ZERO {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
"fee carry has no surviving lot to re-home onto (principal == 0)",
);
}
let dest_key = pool_key(date, dest);
for lot in relocated {
pools.push_lot(dest_key.clone(), lot);
}
}
Op::UnknownInbound { sat: _ } => {
st.add_blocker(
BlockerKind::UnknownBasisInbound,
Some(eff.id.clone()),
"unclassified TransferIn — basis unknown",
);
}
Op::IncomeInbound {
sat,
fmv,
kind,
business,
} => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::FmvMissing,
Some(eff.id.clone()),
"income inbound without wallet",
);
return;
}
};
let (basis, pending) = match fmv {
Some(v) => {
st.income_recognized.push(IncomeRecord {
event: eff.id.clone(),
recognized_at: date,
sat: *sat,
usd_fmv: *v,
kind: *kind,
business: *business,
pseudo: ev_pseudo, });
(*v, false)
}
None => {
st.add_blocker(
BlockerKind::FmvMissing,
Some(eff.id.clone()),
"income inbound FMV missing",
);
(Usd::ZERO, true)
}
};
let lot = Lot {
lot_id: LotId {
origin_event_id: eff.id.clone(),
split_sequence: 0,
},
wallet: wallet.clone(),
acquired_at: date,
original_sat: *sat,
remaining_sat: *sat,
usd_basis: basis,
basis_source: BasisSource::FmvAtIncome,
dual_loss_basis: None,
donor_acquired_at: None,
basis_pending: pending,
pseudo: ev_pseudo,
};
pools.new_origin_lot(pool_key(date, &wallet), lot);
stats.sigma_in += *sat;
}
Op::GiftReceived {
sat,
donor_basis,
donor_acquired_at,
fmv_at_gift,
} => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::FmvMissing,
Some(eff.id.clone()),
"gift received without wallet",
);
return;
}
};
let (usd_basis, dual_loss_basis, basis_source, pending) = match donor_basis {
Some(b) => {
if *fmv_at_gift >= *b {
(*b, None, BasisSource::GiftCarryover, false)
} else {
(*b, Some(*fmv_at_gift), BasisSource::GiftCarryover, false)
}
}
None => match donor_acquired_at {
Some(d) => {
match fmv_of(prices, *d, *sat) {
Some(fmv) => (fmv, None, BasisSource::GiftFmvFallback, false),
None => {
st.add_blocker(
BlockerKind::UnknownBasisInbound,
Some(eff.id.clone()),
"gift received: donor basis unknown and price unavailable at donor acquisition date",
);
(Usd::ZERO, None, BasisSource::GiftFmvFallback, true)
}
}
}
None => {
st.add_blocker(
BlockerKind::UnknownBasisInbound,
Some(eff.id.clone()),
"gift received: donor basis and acquisition date both unknown",
);
(Usd::ZERO, None, BasisSource::GiftCarryover, true)
}
},
};
let lot = Lot {
lot_id: LotId {
origin_event_id: eff.id.clone(),
split_sequence: 0,
},
wallet: wallet.clone(),
acquired_at: date,
original_sat: *sat,
remaining_sat: *sat,
usd_basis,
basis_source,
dual_loss_basis,
donor_acquired_at: *donor_acquired_at,
basis_pending: pending,
pseudo: ev_pseudo,
};
pools.new_origin_lot(pool_key(date, &wallet), lot);
stats.sigma_in += *sat; }
Op::SelfTransferInbound {
sat,
basis,
acquired_at,
} => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::UnknownBasisInbound,
Some(eff.id.clone()),
"self-transfer inbound without wallet — nowhere to create the lot",
);
return;
}
};
let usd_basis = basis.unwrap_or(Usd::ZERO); let acq = acquired_at.unwrap_or_else(|| long_term_default_acquired(date));
if basis.is_none() {
st.add_blocker(
BlockerKind::SelfTransferInboundZeroBasis,
Some(eff.id.clone()),
"basis defaulted to $0 — likely overstates your eventual gain. To supply the real \
cost, VOID this classification (run `btctax reconcile void`, or press 'v' in the \
TUI editor) and re-classify with --basis — classify-inbound is first-wins, so \
re-running without voiding first would conflict, not update.",
);
}
if acquired_at.is_none() {
st.add_blocker(
BlockerKind::SelfTransferInboundDefaultedAcquired,
Some(eff.id.clone()),
"acquisition date defaulted to 1 year + 1 day before receipt — holding period assumed \
LONG-TERM. If these coins were held for a year or less, correct it with --acquired \
(VOID this classification first: run `btctax reconcile void`, or press 'v' in the \
TUI editor — classify-inbound is first-wins).",
);
}
let lot = Lot {
lot_id: LotId {
origin_event_id: eff.id.clone(),
split_sequence: 0,
},
wallet: wallet.clone(),
acquired_at: acq, original_sat: *sat,
remaining_sat: *sat,
usd_basis,
basis_source: BasisSource::SelfTransferInbound,
dual_loss_basis: None,
donor_acquired_at: None, basis_pending: false, pseudo: ev_pseudo,
};
pools.new_origin_lot(pool_key(date, &wallet), lot);
stats.sigma_in += *sat; }
Op::GiftOut {
sat,
fmv,
fee_sat,
donee,
..
} => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
"gift out without wallet",
);
return;
}
};
let key = pool_key(date, &wallet);
note_pre2025_once(
st,
date,
&eff.id,
ctx.config.pre2025_method,
ctx.config.pre2025_method_attested,
); let (consumed, shortfall) =
consume_principal(pools, &key, *sat, date, &wallet, ctx, st, &eff.id);
if shortfall > 0 {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
format!("gift out short by {shortfall} sat"),
);
st.shortfalls.push(ShortfallRecord {
event: eff.id.clone(),
wallet: Some(wallet.clone()),
date,
principal_sat: shortfall,
fee_sat: 0,
});
}
if !consumed.is_empty() {
let (mut legs, donor_acquired_at) =
make_removal_legs(&consumed, *fmv, date, st, &eff.id, ev_pseudo, &ctx.promotes);
let carry = consume_fee(
pools,
&key,
fee_sat.unwrap_or(0),
ctx.config,
prices,
date,
&wallet,
stats,
st,
&eff.id,
ev_pseudo,
&ctx.promotes,
);
if let Some(last) = legs.last_mut() {
carry.rehome_onto_removal_leg(last);
} else if carry.gain_basis > Usd::ZERO {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
"fee carry has no surviving removal leg to re-home onto (principal == 0)",
);
}
st.removals.push(Removal {
event: eff.id.clone(),
kind: RemovalKind::Gift,
removed_at: date,
legs,
appraisal_required: false,
donor_acquired_at,
claimed_deduction: None,
donee: donee.clone(),
});
}
}
Op::Donate {
sat,
fmv,
appraisal_required,
fee_sat,
donee,
..
} => {
let wallet = match &eff.wallet {
Some(w) => w.clone(),
None => {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
"donate without wallet",
);
return;
}
};
let key = pool_key(date, &wallet);
note_pre2025_once(
st,
date,
&eff.id,
ctx.config.pre2025_method,
ctx.config.pre2025_method_attested,
); let (consumed, shortfall) =
consume_principal(pools, &key, *sat, date, &wallet, ctx, st, &eff.id);
if shortfall > 0 {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
format!("donate short by {shortfall} sat"),
);
st.shortfalls.push(ShortfallRecord {
event: eff.id.clone(),
wallet: Some(wallet.clone()),
date,
principal_sat: shortfall,
fee_sat: 0,
});
}
if !consumed.is_empty() {
let (mut legs, donor_acquired_at) =
make_removal_legs(&consumed, *fmv, date, st, &eff.id, ev_pseudo, &ctx.promotes);
let carry = consume_fee(
pools,
&key,
fee_sat.unwrap_or(0),
ctx.config,
prices,
date,
&wallet,
stats,
st,
&eff.id,
ev_pseudo,
&ctx.promotes,
);
if let Some(last) = legs.last_mut() {
carry.rehome_onto_removal_leg(last);
} else if carry.gain_basis > Usd::ZERO {
st.add_blocker(
BlockerKind::UncoveredDisposal,
Some(eff.id.clone()),
"fee carry has no surviving removal leg to re-home onto (principal == 0)",
);
}
let claimed_deduction: Usd = legs
.iter()
.map(|leg| {
if leg.term == Term::LongTerm {
leg.fmv_at_transfer
} else {
leg.fmv_at_transfer.min(leg.basis)
}
})
.sum();
if claimed_deduction > crate::tax::tables::QUALIFIED_APPRAISAL_THRESHOLD {
st.add_blocker(
BlockerKind::QualifiedAppraisalNote,
Some(eff.id.clone()),
format!(
"Claimed deduction ${claimed_deduction:.2} exceeds the \
§170(f)(11)(C) $5,000 threshold. Qualified appraisal likely required: \
CCA 202302012 — a crypto donation with a claimed deduction >$5,000 \
requires a qualified appraisal; the exchange-price/readily-valued \
exception does NOT apply to crypto. \
This is the exact §170(e) deduction for a non-dealer individual \
investor donating a capital asset (LT→FMV; ST→min(FMV,basis)). \
Caveat (a) dealer/inventory: crypto held as inventory/for sale in a \
trade or business (§1221(a)(1)) or other ordinary-income property \
deducts at basis under §170(e) REGARDLESS of holding period — this \
figure assumes capital-asset (investor) status and would OVER-STATE \
for a dealer; verify. \
Caveat (b) donee type: LT→FMV assumes a public charity (50%-limit org); \
a non-operating private foundation reduces appreciated LT crypto to \
basis (§170(e)(1)(B)(ii); crypto is not qualified appreciated stock) — \
donee type is not modeled; would OVER-STATE for a private-foundation \
gift; verify. \
§170(f)(11)(F) aggregation: this flags a single donation; the $5,000 \
test also aggregates similar donated items across the tax year — \
cross-donation aggregation is not considered here."
),
);
}
st.removals.push(Removal {
event: eff.id.clone(),
kind: RemovalKind::Donation,
removed_at: date,
legs,
appraisal_required: *appraisal_required,
donor_acquired_at,
claimed_deduction: Some(claimed_deduction),
donee: donee.clone(),
});
}
}
Op::Unclassified => {
st.add_blocker(
BlockerKind::Unclassified,
Some(eff.id.clone()),
"unclassified BTC-side row",
);
}
Op::Skip => {}
}
}
pub fn finalize(st: &mut LedgerState, pools: PoolSet, mut stats: FoldStats) {
let mut holdings: BTreeMap<crate::identity::WalletId, Sat> = BTreeMap::new();
let mut lots: Vec<Lot> = Vec::new();
for (_key, pool) in pools.pools {
for lot in pool {
if lot.remaining_sat > 0 {
*holdings.entry(lot.wallet.clone()).or_insert(0) += lot.remaining_sat;
lots.push(lot);
}
}
}
lots.sort_by(|a, b| {
a.wallet
.cmp(&b.wallet)
.then(a.acquired_at.cmp(&b.acquired_at))
.then(a.lot_id.cmp(&b.lot_id))
});
st.lots = lots;
st.holdings_by_wallet = holdings;
st.blockers.sort_by(|a, b| {
a.kind
.cmp(&b.kind)
.then_with(|| a.event.cmp(&b.event))
.then_with(|| a.detail.cmp(&b.detail))
});
stats.sigma_pending = st
.pending_reconciliation
.iter()
.map(|p| p.principal_sat + p.fee_sat.unwrap_or(0))
.sum();
st.stats = stats;
}