use crate::conservative_promote::{PromoteEntry, PromoteSet};
use crate::conventions::{tax_date, Sat, TaxDate, Usd, TRANSITION_DATE, TY2025_RETURN_DUE};
use crate::event::*;
use crate::identity::{EventId, LotId, SourceRef, WalletId};
use crate::price::{fmv_of, PriceProvider};
use crate::project::{FeeTreatment, LotMethod, ProjectionConfig};
use crate::state::{Blocker, BlockerKind, Lot};
use std::collections::{BTreeMap, BTreeSet};
use time::{OffsetDateTime, UtcOffset};
pub(crate) fn method_election_is_forward(me: &MethodElection, made: TaxDate) -> bool {
me.effective_from >= TRANSITION_DATE && me.effective_from >= made
}
#[derive(Debug, Clone)]
pub enum Op {
Acquire(Acquire),
Dispose {
sat: Sat,
proceeds: Usd,
fee_usd: Usd,
fee_sat: Option<Sat>,
kind: DisposeKind,
},
Income {
sat: Sat,
fmv: Option<Usd>,
kind: IncomeKind,
business: bool,
},
PendingOut {
sat: Sat,
fee_sat: Option<Sat>,
},
SelfTransfer {
sat: Sat,
fee_sat: Option<Sat>,
dest: WalletId,
},
UnknownInbound {
sat: Sat,
},
IncomeInbound {
sat: Sat,
fmv: Option<Usd>,
kind: IncomeKind,
business: bool,
},
GiftReceived {
sat: Sat,
donor_basis: Option<Usd>,
donor_acquired_at: Option<TaxDate>,
fmv_at_gift: Usd,
},
SelfTransferInbound {
sat: Sat,
basis: Option<Usd>,
acquired_at: Option<TaxDate>,
},
GiftOut {
sat: Sat,
fmv: Usd,
fee_sat: Option<Sat>, fee_usd: Option<Usd>, donee: Option<String>, },
Donate {
sat: Sat,
fmv: Usd,
appraisal_required: bool,
fee_sat: Option<Sat>, fee_usd: Option<Usd>, donee: Option<String>, },
Unclassified,
Skip, }
#[derive(Debug, Clone)]
pub struct Eff {
pub id: EventId,
pub utc: OffsetDateTime,
pub tz: UtcOffset,
pub src_priority: u8,
pub src_ref: SourceRef,
pub wallet: Option<crate::identity::WalletId>,
pub op: Op,
pub pseudo: bool,
}
impl Eff {
pub fn date(&self) -> TaxDate {
tax_date(self.utc, self.tz)
}
}
#[derive(Debug, Clone)]
pub enum TransitionMode {
PathA,
PathB { seed: Vec<crate::state::Lot> },
}
#[derive(Debug, Clone)]
pub struct AllocationVoid {
pub void_id: EventId,
pub target: EventId,
}
#[derive(Debug, Clone)]
struct TrancheVoid {
void_id: EventId,
target: EventId,
}
#[derive(Debug, Clone)]
pub struct ElectionRec {
pub effective_from: TaxDate,
pub method: crate::LotMethod,
pub decision_seq: u64,
pub wallet: Option<WalletId>,
}
pub(crate) fn resolve_election<'a>(
date: TaxDate,
wallet: &WalletId,
elections: &'a [ElectionRec],
) -> Option<&'a ElectionRec> {
let latest = |scoped: bool| -> Option<&'a ElectionRec> {
elections
.iter()
.filter(|e| e.effective_from <= date)
.filter(|e| {
if scoped {
e.wallet.as_ref() == Some(wallet)
} else {
e.wallet.is_none()
}
})
.max_by(|a, b| {
a.effective_from
.cmp(&b.effective_from)
.then(a.decision_seq.cmp(&b.decision_seq))
})
};
latest(true).or_else(|| latest(false))
}
pub struct Resolution {
pub timeline: Vec<Eff>,
pub transition: TransitionMode,
pub blockers: Vec<Blocker>,
pub elections: Vec<ElectionRec>,
pub selections: BTreeMap<EventId, Vec<crate::event::LotPick>>,
pub pseudo_decisions: Vec<PseudoDefault>,
pub promotes: PromoteSet,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PseudoKind {
SelfTransferInbound,
RawInbound,
AcceptConflict,
PseudoFmv,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PseudoDefault {
pub target: EventId,
pub decision: EventPayload,
pub kind: PseudoKind,
}
enum Resolved {
Accept(Box<EventPayload>, EventId),
Reject,
}
#[allow(clippy::too_many_arguments)]
fn build_op(
id: &EventId,
payload: &EventPayload,
manual_fmv: &BTreeMap<EventId, Usd>,
links: &BTreeMap<EventId, TransferTarget>,
consumed_ins: &BTreeSet<EventId>,
inbound_class: &BTreeMap<EventId, InboundClass>,
outflow_class: &BTreeMap<EventId, ReclassifyOutflow>,
income_reclassify: &BTreeMap<EventId, ReclassifyIncome>,
passthrough_skip: &BTreeSet<EventId>,
by_id: &BTreeMap<EventId, &LedgerEvent>,
) -> Op {
if passthrough_skip.contains(id) {
return Op::Skip;
}
match payload {
EventPayload::Acquire(a) => Op::Acquire(a.clone()),
EventPayload::Dispose(d) => Op::Dispose {
sat: d.sat,
proceeds: d.usd_proceeds,
fee_usd: d.fee_usd,
fee_sat: None, kind: d.kind,
},
EventPayload::Income(x) => {
let fmv_override = manual_fmv.get(id).copied();
let fmv =
fmv_override.or_else(|| x.usd_fmv.filter(|_| x.fmv_status != FmvStatus::Missing));
let (business, kind) = if let Some(o) = income_reclassify.get(id) {
(o.business, o.kind.unwrap_or(x.kind))
} else {
(x.business, x.kind)
};
Op::Income {
sat: x.sat,
fmv,
kind,
business,
}
}
EventPayload::TransferOut(t) => {
if let Some(target) = links.get(id) {
let dest = match target {
TransferTarget::InEvent(in_id) => {
by_id.get(in_id).and_then(|e| e.wallet.clone())
}
TransferTarget::Wallet(w) => Some(w.clone()),
};
if let Some(dest_wallet) = dest {
return Op::SelfTransfer {
sat: t.sat,
fee_sat: t.fee_sat,
dest: dest_wallet,
};
}
}
if let Some(ro) = outflow_class.get(id) {
return match &ro.as_ {
OutflowClass::GiftOut => Op::GiftOut {
sat: t.sat,
fmv: ro.principal_proceeds_or_fmv,
fee_sat: t.fee_sat,
fee_usd: ro.fee_usd,
donee: ro.donee.clone(),
},
OutflowClass::Donate { appraisal_required } => Op::Donate {
sat: t.sat,
fmv: ro.principal_proceeds_or_fmv,
appraisal_required: *appraisal_required,
fee_sat: t.fee_sat,
fee_usd: ro.fee_usd,
donee: ro.donee.clone(),
},
OutflowClass::Dispose { kind } => Op::Dispose {
sat: t.sat,
proceeds: ro.principal_proceeds_or_fmv,
fee_usd: ro.fee_usd.unwrap_or(Usd::ZERO),
fee_sat: t.fee_sat, kind: *kind,
},
};
}
Op::PendingOut {
sat: t.sat,
fee_sat: t.fee_sat,
}
}
EventPayload::TransferIn(t) => {
if consumed_ins.contains(id) {
Op::Skip
} else if let Some(cls) = inbound_class.get(id) {
match cls {
InboundClass::Income {
kind,
fmv,
business,
} => Op::IncomeInbound {
sat: t.sat,
fmv: *fmv,
kind: *kind,
business: *business,
},
InboundClass::GiftReceived {
donor_basis,
donor_acquired_at,
fmv_at_gift,
} => Op::GiftReceived {
sat: t.sat,
donor_basis: *donor_basis,
donor_acquired_at: *donor_acquired_at,
fmv_at_gift: *fmv_at_gift,
},
InboundClass::SelfTransferMine { basis, acquired_at } => {
Op::SelfTransferInbound {
sat: t.sat,
basis: *basis,
acquired_at: *acquired_at,
}
}
}
} else {
Op::UnknownInbound { sat: t.sat }
}
}
EventPayload::Unclassified(_) => Op::Unclassified,
EventPayload::DeclareTranche(t) if matches!(id, EventId::Decision { .. }) && t.sat > 0 => {
Op::Acquire(Acquire {
sat: t.sat,
usd_cost: Usd::ZERO,
fee_usd: Usd::ZERO,
basis_source: BasisSource::EstimatedConservative,
})
}
EventPayload::PromoteTranche(_) => Op::Skip,
_ => Op::Skip,
}
}
const CONFLICT_HINT: &str = "see `btctax events list` for event refs + decision status";
fn live_promotes(
events: &[LedgerEvent],
voided: &BTreeSet<EventId>,
blockers: &mut Vec<Blocker>,
) -> PromoteSet {
let by_id: BTreeMap<EventId, &LedgerEvent> = events.iter().map(|e| (e.id.clone(), e)).collect();
let mut decisions: Vec<(u64, &LedgerEvent)> = events
.iter()
.filter_map(|e| match e.id {
EventId::Decision { seq } => Some((seq, e)),
_ => None,
})
.collect();
decisions.sort_by_key(|(s, _)| *s);
let mut promote_count: BTreeMap<EventId, usize> = BTreeMap::new();
for (_seq, d) in &decisions {
if voided.contains(&d.id) {
continue;
}
if let EventPayload::PromoteTranche(p) = &d.payload {
*promote_count.entry(p.target.clone()).or_insert(0) += 1;
}
}
let mut promotes = PromoteSet::new();
for (_seq, d) in &decisions {
if voided.contains(&d.id) {
continue; }
let EventPayload::PromoteTranche(p) = &d.payload else {
continue;
};
if promote_count.get(&p.target).copied().unwrap_or(0) >= 2 {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"multiple live PromoteTranche decisions name the same tranche {} — none applies; \
void all but one to choose a floor — {CONFLICT_HINT}",
p.target.canonical()
),
});
continue;
}
let live_target = match by_id.get(&p.target).map(|e| &e.payload) {
Some(EventPayload::DeclareTranche(t)) if !voided.contains(&p.target) => Some(t),
_ => None,
};
match live_target {
Some(t) => {
promotes.insert(
p.target.clone(),
PromoteEntry {
filed_basis: p.filed_basis,
tranche_sat: t.sat,
},
);
}
None => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"PromoteTranche targets {} which is not a live DeclareTranche (absent, wrong \
type, or voided) — {CONFLICT_HINT}",
p.target.canonical()
),
});
}
}
}
promotes
}
pub fn resolve(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
) -> Resolution {
let by_id: BTreeMap<EventId, &LedgerEvent> = events.iter().map(|e| (e.id.clone(), e)).collect();
let mut blockers: Vec<Blocker> = Vec::new();
let pseudo_on = config.pseudo_reconcile;
let mut pseudo_ids: BTreeSet<EventId> = BTreeSet::new();
let mut pseudo_decisions: Vec<PseudoDefault> = Vec::new();
let mut voided: BTreeSet<EventId> = BTreeSet::new();
let mut allocation_voids: Vec<AllocationVoid> = Vec::new();
let mut tranche_voids: Vec<TrancheVoid> = Vec::new();
let promote_targets: BTreeSet<EventId> = events
.iter()
.filter_map(|e| match &e.payload {
EventPayload::PromoteTranche(p) => Some(p.target.clone()),
_ => None,
})
.collect();
for e in events {
if let EventPayload::VoidDecisionEvent(v) = &e.payload {
match by_id.get(&v.target_event_id).map(|t| &t.payload) {
Some(EventPayload::SupersedeImport(_))
| Some(EventPayload::RejectImport(_))
| Some(EventPayload::VoidDecisionEvent(_)) => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(e.id.clone()),
detail: format!(
"void targets a non-revocable decision (accept/reject-conflict and \
void are permanent) — {CONFLICT_HINT}"
),
});
}
Some(EventPayload::SafeHarborAllocation(_)) => {
allocation_voids.push(AllocationVoid {
void_id: e.id.clone(),
target: v.target_event_id.clone(),
});
}
Some(EventPayload::PromoteTranche(_)) => {
voided.insert(v.target_event_id.clone());
}
Some(EventPayload::DeclareTranche(_)) => {
if promote_targets.contains(&v.target_event_id) {
tranche_voids.push(TrancheVoid {
void_id: e.id.clone(),
target: v.target_event_id.clone(),
});
} else {
voided.insert(v.target_event_id.clone());
}
}
Some(_) => {
voided.insert(v.target_event_id.clone());
}
None => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(e.id.clone()),
detail: format!("void targets unknown event — {CONFLICT_HINT}"),
});
}
}
}
}
let mut applied: BTreeMap<EventId, EventPayload> = BTreeMap::new();
let mut decisions: Vec<(u64, &LedgerEvent)> = events
.iter()
.filter_map(|e| match e.id {
EventId::Decision { seq } => Some((seq, e)),
_ => None,
})
.collect();
decisions.sort_by_key(|(s, _)| *s);
let mut conflict_res: BTreeMap<EventId, Resolved> = BTreeMap::new();
for (_seq, d) in &decisions {
if voided.contains(&d.id) {
continue;
}
match &d.payload {
EventPayload::SupersedeImport(s) => {
if let Some(EventPayload::ImportConflict(c)) =
by_id.get(&s.conflict_event).map(|e| &e.payload)
{
conflict_res.insert(
s.conflict_event.clone(),
Resolved::Accept(c.new_payload.clone(), c.target.clone()),
);
}
}
EventPayload::RejectImport(r) => {
if let Some(EventPayload::ImportConflict(_)) =
by_id.get(&r.conflict_event).map(|e| &e.payload)
{
conflict_res.insert(r.conflict_event.clone(), Resolved::Reject);
}
}
_ => {}
}
}
for e in events {
if let EventPayload::ImportConflict(c) = &e.payload {
match conflict_res.get(&e.id) {
Some(Resolved::Accept(payload, target)) => {
applied.insert(target.clone(), (**payload).clone());
}
Some(Resolved::Reject) => {} None => {
if pseudo_on && !applied.contains_key(&c.target) {
applied.insert(c.target.clone(), (*c.new_payload).clone());
pseudo_ids.insert(c.target.clone()); pseudo_decisions.push(PseudoDefault {
target: e.id.clone(), decision: EventPayload::SupersedeImport(SupersedeImport {
conflict_event: e.id.clone(),
}),
kind: PseudoKind::AcceptConflict,
});
} else {
blockers.push(Blocker {
kind: BlockerKind::ImportConflict,
event: Some(e.id.clone()),
detail: "unresolved import conflict".into(),
});
}
}
}
}
}
for (_seq, d) in &decisions {
if voided.contains(&d.id) {
continue;
}
if let EventPayload::ClassifyRaw(cr) = &d.payload {
if applied.contains_key(&cr.target) {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"duplicate classify-raw: {} is already classified \
— {CONFLICT_HINT}; if the prior decision is revocable, void it to re-decide",
cr.target.canonical()
),
});
} else {
applied.insert(cr.target.clone(), (*cr.as_).clone());
}
}
}
let mut manual_fmv: BTreeMap<EventId, Usd> = BTreeMap::new();
for (_seq, d) in &decisions {
if voided.contains(&d.id) {
continue;
}
if let EventPayload::ManualFmv(m) = &d.payload {
let target = &m.event;
let effective_payload = by_id
.get(target)
.map(|raw| applied.get(target).unwrap_or(&raw.payload));
match effective_payload {
None => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"ManualFmv targets unknown event {} — {CONFLICT_HINT}",
target.canonical()
),
});
}
Some(EventPayload::Income(_)) => {
manual_fmv.insert(target.clone(), m.usd_fmv);
}
Some(_) => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"ManualFmv targets non-Income event {} \
— for a TransferIn classified as income, set the FMV via \
classify-inbound-income (its own `fmv` field); {CONFLICT_HINT}",
target.canonical()
),
});
}
}
}
}
let mut links: BTreeMap<EventId, TransferTarget> = BTreeMap::new();
let mut consumed_ins: BTreeSet<EventId> = BTreeSet::new();
let mut inbound_class: BTreeMap<EventId, InboundClass> = BTreeMap::new();
let mut outflow_class: BTreeMap<EventId, ReclassifyOutflow> = BTreeMap::new();
let mut income_reclassify: BTreeMap<EventId, ReclassifyIncome> = BTreeMap::new();
let mut passthrough_skip: BTreeSet<EventId> = BTreeSet::new();
let mut passthroughs: Vec<(EventId, EventId, EventId)> = Vec::new();
for (_seq, d) in &decisions {
if voided.contains(&d.id) {
continue;
}
match &d.payload {
EventPayload::TransferLink(tl) => {
if links.contains_key(&tl.out_event) {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: "duplicate TransferLink for the same out_event".into(),
});
} else {
let mut link_ok = true;
if let TransferTarget::InEvent(in_id) = &tl.in_event_or_wallet {
if consumed_ins.contains(in_id) {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: "duplicate TransferLink targeting the same in_event".into(),
});
link_ok = false;
} else if by_id.get(in_id).and_then(|e| e.wallet.as_ref()).is_none() {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail:
"TransferLink in-event has no resolvable destination wallet"
.into(),
});
link_ok = false;
} else {
consumed_ins.insert(in_id.clone());
}
}
if link_ok {
links.insert(tl.out_event.clone(), tl.in_event_or_wallet.clone());
}
}
}
EventPayload::ClassifyInbound(ci) => {
let target = &ci.transfer_in_event;
let effective_payload = by_id
.get(target)
.map(|raw| applied.get(target).unwrap_or(&raw.payload));
match effective_payload {
None => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"ClassifyInbound targets unknown event {} — {CONFLICT_HINT}",
target.canonical()
),
});
}
Some(EventPayload::TransferIn(_)) => {
if inbound_class.contains_key(target) {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"duplicate ClassifyInbound: {} is already classified \
— {CONFLICT_HINT}; void the prior decision to re-decide",
target.canonical()
),
});
} else {
inbound_class.insert(target.clone(), ci.as_.clone());
}
}
Some(_) => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"ClassifyInbound targets non-TransferIn event {} \
— only a deposit (TransferIn) can be classified inbound; {CONFLICT_HINT}",
target.canonical()
),
});
}
}
}
EventPayload::ReclassifyOutflow(ro) => {
let target = &ro.transfer_out_event;
let effective_payload = by_id
.get(target)
.map(|raw| applied.get(target).unwrap_or(&raw.payload));
match effective_payload {
None => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"ReclassifyOutflow targets unknown event {} — {CONFLICT_HINT}",
target.canonical()
),
});
}
Some(EventPayload::TransferOut(_)) => {
if outflow_class.contains_key(target) {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"duplicate ReclassifyOutflow: {} is already reclassified \
— {CONFLICT_HINT}; void the prior decision to re-decide",
target.canonical()
),
});
} else {
outflow_class.insert(target.clone(), ro.clone());
}
}
Some(_) => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"ReclassifyOutflow targets non-TransferOut event {} \
— for Income corrections use reclassify-income; {CONFLICT_HINT}",
target.canonical()
),
});
}
}
}
EventPayload::ReclassifyIncome(ri) => {
let target = &ri.income_event;
let effective_payload = by_id
.get(target)
.map(|raw| applied.get(target).unwrap_or(&raw.payload));
match effective_payload {
None => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"ReclassifyIncome targets unknown event {} \
— for TransferIn rows use classify-inbound-income; {CONFLICT_HINT}",
target.canonical()
),
});
}
Some(EventPayload::Income(_)) => {
if income_reclassify.contains_key(target) {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"duplicate ReclassifyIncome: {} is already reclassified \
— {CONFLICT_HINT}; void the prior decision to re-decide",
target.canonical()
),
});
} else {
income_reclassify.insert(target.clone(), ri.clone());
}
}
Some(_) => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"ReclassifyIncome targets non-Income event {} \
— for TransferIn rows use classify-inbound-income; {CONFLICT_HINT}",
target.canonical()
),
});
}
}
}
EventPayload::SelfTransferPassthrough(stp) => {
let in_target = &stp.in_event;
let out_target = &stp.out_event;
let in_payload = by_id
.get(in_target)
.map(|raw| applied.get(in_target).unwrap_or(&raw.payload));
let out_payload = by_id
.get(out_target)
.map(|raw| applied.get(out_target).unwrap_or(&raw.payload));
let in_ok = matches!(in_payload, Some(EventPayload::TransferIn(_)));
let out_ok = matches!(out_payload, Some(EventPayload::TransferOut(_)));
if !in_ok || !out_ok {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"SelfTransferPassthrough requires in_event {} to be a TransferIn and \
out_event {} to be a TransferOut — {CONFLICT_HINT}",
in_target.canonical(),
out_target.canonical()
),
});
} else if passthrough_skip.contains(in_target)
|| passthrough_skip.contains(out_target)
{
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: format!(
"duplicate SelfTransferPassthrough claims a leg already in another \
passthrough — {CONFLICT_HINT}; void the prior decision to re-decide"
),
});
} else {
passthrough_skip.insert(in_target.clone());
passthrough_skip.insert(out_target.clone());
passthroughs.push((d.id.clone(), in_target.clone(), out_target.clone()));
}
}
_ => {}
}
}
for (dec_id, in_ev, out_ev) in &passthroughs {
let out_overlaps = outflow_class.contains_key(out_ev) || links.contains_key(out_ev);
let in_overlaps = inbound_class.contains_key(in_ev) || consumed_ins.contains(in_ev);
if out_overlaps || in_overlaps {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(dec_id.clone()),
detail:
"SelfTransferPassthrough leg also carries a taxable classification \
(ReclassifyOutflow/TransferLink on the out-leg, or ClassifyInbound/TransferLink on \
the in-leg) — the passthrough is EXCLUDED so the taxable event is recognized; \
void the conflicting decision if the passthrough is correct"
.into(),
});
passthrough_skip.remove(in_ev);
passthrough_skip.remove(out_ev);
}
}
if pseudo_on {
for e in events {
if !matches!(e.id, EventId::Import { .. }) || e.wallet.is_none() {
continue;
}
if applied.contains_key(&e.id) {
continue; }
if matches!(&e.payload, EventPayload::Unclassified(_)) {
let placeholder = EventPayload::Acquire(Acquire {
sat: 0,
usd_cost: Usd::ZERO,
fee_usd: Usd::ZERO,
basis_source: BasisSource::SelfTransferInbound,
});
applied.insert(e.id.clone(), placeholder.clone());
pseudo_ids.insert(e.id.clone());
pseudo_decisions.push(PseudoDefault {
target: e.id.clone(),
decision: EventPayload::ClassifyRaw(ClassifyRaw {
target: e.id.clone(),
as_: Box::new(placeholder),
}),
kind: PseudoKind::RawInbound,
});
}
}
for e in events {
if !matches!(e.id, EventId::Import { .. }) || e.wallet.is_none() {
continue;
}
let eff_payload = applied.get(&e.id).unwrap_or(&e.payload);
let is_unresolved_transfer_in = matches!(eff_payload, EventPayload::TransferIn(_))
&& !inbound_class.contains_key(&e.id)
&& !consumed_ins.contains(&e.id)
&& !passthrough_skip.contains(&e.id);
if is_unresolved_transfer_in {
let as_ = InboundClass::SelfTransferMine {
basis: None, acquired_at: None, };
inbound_class.insert(e.id.clone(), as_.clone());
pseudo_ids.insert(e.id.clone());
pseudo_decisions.push(PseudoDefault {
target: e.id.clone(),
decision: EventPayload::ClassifyInbound(ClassifyInbound {
transfer_in_event: e.id.clone(),
as_,
}),
kind: PseudoKind::SelfTransferInbound,
});
}
}
for e in events {
if !matches!(e.id, EventId::Import { .. }) || e.wallet.is_none() {
continue;
}
let eff_payload = applied.get(&e.id).unwrap_or(&e.payload);
let EventPayload::Income(x) = eff_payload else {
continue;
};
if manual_fmv.contains_key(&e.id)
|| (x.usd_fmv.is_some() && x.fmv_status != FmvStatus::Missing)
{
continue;
}
let date = tax_date(e.utc_timestamp, e.original_tz);
let Some(synth) = fmv_of(prices, date, x.sat) else {
continue; };
manual_fmv.insert(e.id.clone(), synth);
pseudo_ids.insert(e.id.clone());
pseudo_decisions.push(PseudoDefault {
target: e.id.clone(),
decision: EventPayload::ManualFmv(ManualFmv {
event: e.id.clone(),
usd_fmv: synth,
}),
kind: PseudoKind::PseudoFmv,
});
}
pseudo_decisions.sort_by(|a, b| {
a.target
.canonical()
.cmp(&b.target.canonical())
.then((a.kind as u8).cmp(&(b.kind as u8)))
});
}
let promotes = live_promotes(events, &voided, &mut blockers);
for tv in &tranche_voids {
if promotes.contains_key(&tv.target) {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(tv.void_id.clone()),
detail: format!(
"void targets a DeclareTranche held in force by a live PromoteTranche — the void is \
inert (never a dangling target); void the promote to revert the tranche to $0, or \
void both to drop it — {CONFLICT_HINT}"
),
});
} else {
voided.insert(tv.target.clone());
}
}
let mut timeline = Vec::new();
for e in events {
if let (EventId::Decision { .. }, EventPayload::DeclareTranche(t)) = (&e.id, &e.payload) {
if voided.contains(&e.id) {
continue; }
let op = build_op(
&e.id,
&e.payload,
&manual_fmv,
&links,
&consumed_ins,
&inbound_class,
&outflow_class,
&income_reclassify,
&passthrough_skip,
&by_id,
);
let op = match (op, promotes.get(&e.id)) {
(Op::Acquire(mut a), Some(entry))
if a.basis_source == BasisSource::EstimatedConservative =>
{
a.usd_cost = entry.filed_basis;
Op::Acquire(a)
}
(op, _) => op,
};
timeline.push(Eff {
id: e.id.clone(),
utc: t.window_end.midnight().assume_utc(), tz: UtcOffset::UTC,
src_priority: u8::MAX, src_ref: SourceRef::new(""),
wallet: Some(t.wallet.clone()),
op,
pseudo: false, });
continue;
}
let (src_priority, src_ref) = match &e.id {
EventId::Import { source, source_ref } => (source.priority(), source_ref.clone()),
_ => continue,
};
let effective_payload = applied.get(&e.id).unwrap_or(&e.payload);
let op = build_op(
&e.id,
effective_payload,
&manual_fmv,
&links,
&consumed_ins,
&inbound_class,
&outflow_class,
&income_reclassify,
&passthrough_skip,
&by_id,
);
timeline.push(Eff {
id: e.id.clone(),
utc: e.utc_timestamp,
tz: e.original_tz,
src_priority,
src_ref,
wallet: e.wallet.clone(),
op,
pseudo: pseudo_ids.contains(&e.id), });
}
let mut elections: Vec<ElectionRec> = Vec::new();
for (seq, d) in &decisions {
if voided.contains(&d.id) {
continue;
}
if let EventPayload::MethodElection(me) = &d.payload {
let made = tax_date(d.utc_timestamp, d.original_tz);
if !method_election_is_forward(me, made) {
blockers.push(Blocker {
kind: BlockerKind::MethodElectionBackdated,
event: Some(d.id.clone()),
detail: "MethodElection effective_from precedes its made-date or TRANSITION_DATE (2025-01-01) — a standing order cannot be back-dated".into(),
});
continue;
}
elections.push(ElectionRec {
effective_from: me.effective_from,
method: me.method,
decision_seq: *seq,
wallet: me.wallet.clone(), });
}
}
let honoring: BTreeMap<EventId, Sat> = timeline
.iter()
.filter_map(|e| honoring_principal(&e.op).map(|s| (e.id.clone(), s)))
.collect();
let mut selections: BTreeMap<EventId, Vec<crate::event::LotPick>> = BTreeMap::new();
let mut seen: BTreeSet<EventId> = BTreeSet::new(); let mut dup: BTreeSet<EventId> = BTreeSet::new();
for (_seq, d) in &decisions {
if voided.contains(&d.id) {
continue;
}
let EventPayload::LotSelection(ls) = &d.payload else {
continue;
};
if !seen.insert(ls.disposal_event.clone()) {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(d.id.clone()),
detail: "duplicate LotSelection for the same disposal_event".into(),
});
dup.insert(ls.disposal_event.clone());
continue;
}
selections.insert(ls.disposal_event.clone(), ls.lots.clone());
}
for id in &dup {
selections.remove(id); }
selections.retain(|disposal, picks| match honoring.get(disposal) {
None => {
blockers.push(Blocker {
kind: BlockerKind::LotSelectionInvalid,
event: Some(disposal.clone()),
detail: "LotSelection targets a non-honoring or unknown event (only Dispose/GiftOut/Donate/SelfTransfer — not PendingOut/fee legs)".into(),
});
false
}
Some(&principal) => {
let picked: Sat = picks.iter().map(|p| p.sat).sum();
if picked != principal {
blockers.push(Blocker {
kind: BlockerKind::LotSelectionInvalid,
event: Some(disposal.clone()),
detail: format!("LotSelection must conserve principal: picked {picked} sat != disposal principal {principal} sat (on-chain fee_sat is excluded and consumes FIFO from the remainder)"),
});
false
} else {
true
}
}
});
let first_2025_disposition: Option<TaxDate> = timeline
.iter()
.filter(|e| e.date() >= TRANSITION_DATE)
.filter(|e| is_disposition_op(&e.op, config))
.map(|e| e.date())
.min();
let due = TY2025_RETURN_DUE;
let mut effective: Vec<(EventId, Vec<Lot>, LotMethod)> = Vec::new();
for (_seq, d) in &decisions {
if voided.contains(&d.id) {
continue;
}
let EventPayload::SafeHarborAllocation(a) = &d.payload else {
continue;
};
let made = tax_date(d.utc_timestamp, d.original_tz);
let bar = match a.method {
AllocMethod::ActualPosition => min_opt(first_2025_disposition, Some(due)),
AllocMethod::ProRata => max_opt(first_2025_disposition, Some(due)),
};
let timebarred = !a.timely_allocation_attested
&& (bar.is_some_and(|b| made > b) || a.method == AllocMethod::ProRata);
let snap = crate::project::transition::universal_snapshot(
&timeline,
prices,
config,
a.pre2025_method,
&elections,
&selections,
&promotes,
);
let alloc_sat: Sat = a.lots.iter().map(|l| l.sat).sum();
let alloc_basis: Usd = a.lots.iter().map(|l| l.usd_basis).sum();
let has_tranche_residue = snap.estimated_conservative_remaining_sat > 0;
let unconservable =
has_tranche_residue || alloc_sat != snap.held_sat || alloc_basis != snap.basis;
if unconservable {
blockers.push(Blocker {
kind: BlockerKind::SafeHarborUnconservable,
event: Some(d.id.clone()),
detail: if has_tranche_residue {
"a conservative-filing tranche ($0 or a promoted floor, EstimatedConservative) \
remains in the pre-2025 residue — a safe-harbor allocation cannot conserve over it \
(v1 makes them mutually exclusive; unallocated pre-2025 units are a \
facts-and-circumstances matter)"
.into()
} else {
"allocation totals != Universal remainder at 2025-01-01".into()
},
});
continue; }
if timebarred {
blockers.push(Blocker {
kind: BlockerKind::SafeHarborTimebar,
event: Some(d.id.clone()),
detail: "allocation made past its method-keyed §5.02(4) bar".into(),
});
continue; }
let seed = a
.lots
.iter()
.enumerate()
.map(|(i, l)| Lot {
lot_id: LotId {
origin_event_id: d.id.clone(),
split_sequence: i as u32,
},
wallet: l.wallet.clone(),
acquired_at: l.acquired_at,
original_sat: l.sat,
remaining_sat: l.sat,
usd_basis: l.usd_basis,
basis_source: BasisSource::SafeHarborAllocated,
dual_loss_basis: l.dual_loss_basis,
donor_acquired_at: l.donor_acquired_at,
basis_pending: false,
pseudo: false, })
.collect();
effective.push((d.id.clone(), seed, a.pre2025_method));
}
for v in &allocation_voids {
if effective.iter().any(|(id, _, _)| id == &v.target) {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: Some(v.void_id.clone()),
detail: "void targets an effective SafeHarborAllocation (irrevocable, §7.4)".into(),
});
} else {
blockers.retain(|b| {
!(b.event.as_ref() == Some(&v.target)
&& b.kind == BlockerKind::SafeHarborUnconservable)
});
}
}
let transition = match effective.len() {
0 => TransitionMode::PathA,
1 => {
let (id, seed, recorded_method) = effective.into_iter().next().expect("len == 1");
if config.pre2025_method != recorded_method {
blockers.push(Blocker {
kind: BlockerKind::Pre2025MethodConflictsAllocation,
event: Some(id),
detail: format!(
"live pre2025_method ({:?}) differs from this allocation's recorded method ({:?}); revert the config to the recorded method (the irrevocable allocation pins it, §7.4)",
config.pre2025_method, recorded_method
),
});
}
TransitionMode::PathB { seed }
}
_ => {
blockers.push(Blocker {
kind: BlockerKind::DecisionConflict,
event: None,
detail: "multiple effective SafeHarborAllocations".into(),
});
TransitionMode::PathA
}
};
Resolution {
timeline,
transition,
blockers,
elections,
selections,
pseudo_decisions,
promotes,
}
}
fn min_opt(a: Option<TaxDate>, b: Option<TaxDate>) -> Option<TaxDate> {
match (a, b) {
(Some(x), Some(y)) => Some(x.min(y)),
(x, None) | (None, x) => x,
}
}
fn max_opt(a: Option<TaxDate>, b: Option<TaxDate>) -> Option<TaxDate> {
match (a, b) {
(Some(x), Some(y)) => Some(x.max(y)),
(x, None) | (None, x) => x,
}
}
fn is_disposition_op(op: &Op, config: &ProjectionConfig) -> bool {
match op {
Op::Dispose { .. } | Op::GiftOut { .. } | Op::Donate { .. } => true,
Op::SelfTransfer { fee_sat, .. } => {
config.self_transfer_fee == FeeTreatment::TreatmentB && fee_sat.unwrap_or(0) > 0
}
_ => false,
}
}
fn honoring_principal(op: &Op) -> Option<Sat> {
match op {
Op::Dispose { sat, .. }
| Op::GiftOut { sat, .. }
| Op::Donate { sat, .. }
| Op::SelfTransfer { sat, .. } => Some(*sat),
_ => None, }
}
pub fn sort_canonical(timeline: &mut [Eff]) {
timeline.sort_by(|a, b| {
a.utc
.cmp(&b.utc)
.then(a.src_priority.cmp(&b.src_priority))
.then(a.src_ref.cmp(&b.src_ref))
.then(a.id.cmp(&b.id))
});
}