btctax_core/project/mod.rs
1pub mod compliance;
2pub mod conservation;
3pub mod evaluate;
4pub mod fold;
5pub mod pools;
6pub mod resolve;
7pub mod transition;
8
9pub use compliance::{disposal_compliance, ComplianceStatus, DisposalCompliance};
10pub use conservation::{conservation_report, ConservationReport};
11pub use evaluate::{evaluate_disposal, CandidateDisposal, EvaluateError, EvaluateOutcome};
12pub use resolve::{PseudoDefault, PseudoKind};
13
14use crate::event::LedgerEvent;
15use crate::price::PriceProvider;
16use crate::state::LedgerState;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FeeTreatment {
20 /// TP8 DEFAULT: fee_sat consumed at zero proceeds (non-taxable); full basis carries. USER-MANDATED default.
21 TreatmentC,
22 /// TP8 config: taxable mini-disposition of fee-sats (recognition record only; not a 2nd conservation entry).
23 TreatmentB,
24}
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
26pub enum LotMethod {
27 #[default]
28 Fifo,
29 Lifo,
30 Hifo,
31}
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct ProjectionConfig {
34 pub self_transfer_fee: FeeTreatment,
35 /// Historical identification method for pre-2025 lots (attested via `CliConfig`).
36 pub pre2025_method: LotMethod,
37 /// Whether the taxpayer has declared + attested their filed pre-2025 lot method.
38 /// `false` (the default) makes the advisory louder and actionable; `true` produces an
39 /// informational acknowledgment. Neither value gates `compute_tax_year` (§D1).
40 pub pre2025_method_attested: bool,
41 /// Pseudo-reconcile mode (sub-project 2). When `true`, `resolve` synthesizes DELIBERATELY-FICTIONAL
42 /// default decisions at PROJECTION time (never persisted) to clear the Hard *classification* blockers,
43 /// producing a loudly-flagged on-screen estimate the user corrects toward truth. Default `false` [N1];
44 /// mode-off ⇒ projection is byte-identical to today (no synthetics injected). Real decisions always
45 /// supersede synthetics. Synthetics are NEVER written to the ledger by projection — only
46 /// `reconcile pseudo approve` promotes chosen defaults to real (attested) decisions.
47 pub pseudo_reconcile: bool,
48}
49impl Default for ProjectionConfig {
50 fn default() -> Self {
51 // DO NOT change: TP8 default is (c); the spec/memory forbid flipping it to (b).
52 ProjectionConfig {
53 self_transfer_fee: FeeTreatment::TreatmentC,
54 // Realistic no-election default: HIFO (kept in sync with `CliConfig::default`, §reconcile-defaults).
55 pre2025_method: LotMethod::Hifo,
56 pre2025_method_attested: false,
57 pseudo_reconcile: false,
58 }
59 }
60}
61
62/// The projection contract (§7.1): pure, deterministic, no I/O, total (never panics).
63pub fn project(
64 events: &[LedgerEvent],
65 prices: &dyn PriceProvider,
66 config: &ProjectionConfig,
67) -> LedgerState {
68 // I-2: `resolve` takes (events, prices, config) — Task-12 transition effectiveness needs both.
69 let resolution = resolve::resolve(events, prices, config);
70 fold::fold(resolution, prices, config)
71}
72
73/// Pseudo-reconcile (sub-project 2): the ordered list of synthetic default decisions the projection WOULD
74/// inject in pseudo mode — the SAME `PseudoDefault`s carried on the `Resolution` (so "what you see == what
75/// you approve"). Pure/deterministic (NFR4). Pseudo mode is FORCED on for this computation, so `approve`
76/// can enumerate the defaults independent of the stored flag; each `PseudoDefault.decision` is a
77/// materializable REAL decision. NEVER writes anything — approve persists via the CLI `apply_bulk_*` loop.
78pub fn pseudo_plan(
79 events: &[LedgerEvent],
80 prices: &dyn PriceProvider,
81 config: &ProjectionConfig,
82) -> Vec<PseudoDefault> {
83 let mut cfg = *config;
84 cfg.pseudo_reconcile = true;
85 resolve::resolve(events, prices, &cfg).pseudo_decisions
86}
87
88/// UX-P4-3 record-time validation, DEFINITIONALLY the resolver. Answers "would appending `incoming`
89/// (a reconcile decision payload, e.g. `ClassifyInbound`/`ManualFmv`/`VoidDecisionEvent`) introduce a
90/// NEW `DecisionConflict`?" — returning the offending blocker's `detail` if so, else `None`.
91///
92/// It does NOT hand-rebuild the resolver's `applied` map (a subset view drifts — a prior draft was one
93/// writer short and both false-refused and false-accepted). Instead it RUNS the real projection twice
94/// and diffs the `DecisionConflict` set: baseline (`events`) vs `events` + the candidate appended as
95/// the resolver would append it (the next decision seq — the highest, so it is the LOSING side of any
96/// first-wins race). A conflict present WITH the candidate but not in the baseline is one the candidate
97/// introduced. Baseline-diff (not a candidate-id match) is required because the passthrough-overlap
98/// guard keys its blocker to the EXISTING decision, not the newcomer.
99///
100/// **Pseudo is forced OFF** so the shadow is the real (non-synthetic) projection — this keeps
101/// void→re-decide and the FIRST real classify of a pseudo-defaulted target working, and honors an
102/// accepted-conflict `SupersedeImport` override the resolver sees. Every per-verb rule (first-wins for
103/// ClassifyInbound/ReclassifyOutflow/ReclassifyIncome/ClassifyRaw; `ManualFmv` last-wins so `set-fmv`
104/// is duplicate-exempt yet still existence/type-validated; wrong-type / unknown-target) falls out for
105/// free because this IS the resolver. Pure/total (NFR4); two `project` calls, cheap for infrequent
106/// record-time use. Never sees the stored pseudo cfg's taint.
107pub fn would_conflict(
108 events: &[LedgerEvent],
109 prices: &dyn PriceProvider,
110 config: &ProjectionConfig,
111 incoming: &crate::event::EventPayload,
112 now: time::OffsetDateTime,
113) -> Option<String> {
114 use crate::identity::EventId;
115 use crate::state::BlockerKind;
116 use std::collections::BTreeSet;
117
118 let mut cfg = *config;
119 cfg.pseudo_reconcile = false;
120
121 let conflicts = |evs: &[LedgerEvent]| -> Vec<(Option<EventId>, String)> {
122 project(evs, prices, &cfg)
123 .blockers
124 .into_iter()
125 .filter(|b| b.kind == BlockerKind::DecisionConflict)
126 .map(|b| (b.event, b.detail))
127 .collect()
128 };
129
130 let baseline: BTreeSet<(Option<EventId>, String)> = conflicts(events).into_iter().collect();
131
132 let next_seq = events
133 .iter()
134 .filter_map(|e| match &e.id {
135 EventId::Decision { seq } => Some(*seq),
136 _ => None,
137 })
138 .max()
139 .unwrap_or(0)
140 + 1;
141 let candidate = LedgerEvent {
142 id: EventId::decision(next_seq),
143 utc_timestamp: now,
144 original_tz: time::UtcOffset::UTC,
145 wallet: None,
146 payload: incoming.clone(),
147 };
148 let mut with_candidate = events.to_vec();
149 with_candidate.push(candidate);
150
151 conflicts(&with_candidate)
152 .into_iter()
153 .find(|c| !baseline.contains(c))
154 .map(|(_, detail)| detail)
155}
156
157/// The cost-basis method currently in force for a wallet, plus its provenance — the UI-facing answer
158/// to "what method governs this account, and is it an explicit per-account election or inherited?"
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub struct InForceMethod {
161 pub method: LotMethod,
162 /// `true` ⇒ the method came from a PER-ACCOUNT (scoped) election for this wallet; `false` ⇒ it is
163 /// inherited from a GLOBAL election or the HIFO default (pre-2025 dates: the `pre2025_method`).
164 pub scoped: bool,
165}
166
167/// §A.5(a) UI helper: the in-force method for each of `wallets` as of `date`, resolved by the SAME
168/// shared two-tier resolver (`resolve::resolve_election`) the fold and compliance use — scoped
169/// election → global election → HIFO default — never a re-implementation of the precedence. Pre-2025 `date`s
170/// report `pre2025_method` (inherited). Runs `resolve` ONCE; the returned Vec is aligned with
171/// `wallets`. Used by the btctax-tui-edit method-election flow to show each account's resolved method.
172pub fn in_force_methods(
173 events: &[LedgerEvent],
174 prices: &dyn PriceProvider,
175 config: &ProjectionConfig,
176 date: crate::conventions::TaxDate,
177 wallets: &[crate::identity::WalletId],
178) -> Vec<InForceMethod> {
179 let res = resolve::resolve(events, prices, config);
180 wallets
181 .iter()
182 .map(|w| {
183 if date < crate::conventions::TRANSITION_DATE {
184 InForceMethod {
185 method: config.pre2025_method,
186 scoped: false,
187 }
188 } else {
189 match resolve::resolve_election(date, w, &res.elections) {
190 Some(e) => InForceMethod {
191 method: e.method,
192 scoped: e.wallet.is_some(),
193 },
194 None => InForceMethod {
195 // No election on file → the HIFO app default (§reconcile-defaults); reported so
196 // the UI matches the computation's `applicable_method` fall-through exactly.
197 method: LotMethod::Hifo,
198 scoped: false,
199 },
200 }
201 }
202 })
203 .collect()
204}