Skip to main content

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}