Skip to main content

btctax_cli/cmd/
inspect.rs

1//! `verify` (FR9) + `report`/`show` (FR4) — read-only inspection of the pure projection. `verify`
2//! arrives in Task 6; this file starts with `report`.
3use crate::render::{build_verify, EventRow, VerifyReport};
4use crate::{CliError, Session};
5use btctax_core::LedgerState;
6use btctax_store::Passphrase;
7use std::path::Path;
8
9/// UX-P4-11: enumerate every DECIDABLE event (the imported rows a `reconcile` verb can act on) with
10/// its ref, kind, date, amount, and decision status, in event-sequence (insertion) order. Read-only.
11///
12/// The decidable universe = imported `TransferIn` / `TransferOut` / `Unclassified` / `ImportConflict`
13/// / `Income` rows — the reconciliation-CLASSIFICATION surface (the verbs UX-P4-3's refuse-hint
14/// names). An `Acquire` is a fully-determined import that no verb retargets; a `Dispose` is excluded
15/// deliberately — its only decision is specific-ID `select-lots` (`LotSelection`), a distinct flow
16/// whose refs come from the `disposals.csv` `event` column, out of this surface (§3.6 `[G-M3]`;
17/// review r1 M2). Decision status is derived ONLY from PERSISTED decisions (reverse-mapped from the
18/// raw log, minus voided ones): a pseudo-defaulted event mints no persisted decision, so it correctly
19/// lists as **decidable**. Ordering is the raw insertion order (`load_all_ordered`) — "event
20/// sequence" (§3.6).
21pub fn events_list(vault_path: &Path, pp: &Passphrase) -> Result<Vec<EventRow>, CliError> {
22    use btctax_core::conventions::tax_date;
23    use btctax_core::persistence::{load_all, load_all_ordered};
24    use btctax_core::{EventId, EventPayload, LedgerEvent, Sat, TransferTarget};
25    use std::collections::{BTreeSet, HashMap};
26
27    let session = Session::open(vault_path, pp)?;
28    let conn = session.conn();
29    let prices = session.prices();
30    let decoded = load_all(conn)?;
31    let ordered = load_all_ordered(conn)?; // insertion order = event sequence
32
33    // Decoded events keyed by canonical id (for O(1) lookup while walking the ordered raw log).
34    let by_id: HashMap<String, &LedgerEvent> =
35        decoded.iter().map(|e| (e.id.canonical(), e)).collect();
36
37    // A voided decision does not count as "decided" — collect the voided decision ids first.
38    let voided: BTreeSet<EventId> = decoded
39        .iter()
40        .filter_map(|e| match &e.payload {
41            EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
42            _ => None,
43        })
44        .collect();
45
46    // Reverse-map: decidable SOURCE event id -> its live (non-voided) decision id. Each decision
47    // payload names its target; `SelfTransferPassthrough` decides BOTH legs. Later live decisions
48    // win (a void→re-decide leaves only the survivor here).
49    let mut decided: HashMap<EventId, EventId> = HashMap::new();
50    for e in &decoded {
51        if voided.contains(&e.id) {
52            continue;
53        }
54        match &e.payload {
55            EventPayload::ClassifyInbound(d) => {
56                decided.insert(d.transfer_in_event.clone(), e.id.clone());
57            }
58            EventPayload::ReclassifyOutflow(d) => {
59                decided.insert(d.transfer_out_event.clone(), e.id.clone());
60            }
61            EventPayload::ManualFmv(d) => {
62                decided.insert(d.event.clone(), e.id.clone());
63            }
64            EventPayload::ReclassifyIncome(d) => {
65                decided.insert(d.income_event.clone(), e.id.clone());
66            }
67            EventPayload::ClassifyRaw(d) => {
68                decided.insert(d.target.clone(), e.id.clone());
69            }
70            EventPayload::SupersedeImport(d) => {
71                decided.insert(d.conflict_event.clone(), e.id.clone());
72            }
73            EventPayload::RejectImport(d) => {
74                decided.insert(d.conflict_event.clone(), e.id.clone());
75            }
76            EventPayload::TransferLink(d) => {
77                // A link decides its outbound leg, AND — when `--to-event` was used — the inbound
78                // leg it relocates onto (resolve.rs consumes that TransferIn). A `--to-wallet` link
79                // has no in-event to decide. Mirrors the two-leg SelfTransferPassthrough below.
80                decided.insert(d.out_event.clone(), e.id.clone());
81                if let TransferTarget::InEvent(in_id) = &d.in_event_or_wallet {
82                    decided.insert(in_id.clone(), e.id.clone());
83                }
84            }
85            EventPayload::SelfTransferPassthrough(d) => {
86                decided.insert(d.in_event.clone(), e.id.clone());
87                decided.insert(d.out_event.clone(), e.id.clone());
88            }
89            _ => {}
90        }
91    }
92
93    let mut rows = Vec::new();
94    for raw in &ordered {
95        let Some(ev) = by_id.get(&raw.event_id) else {
96            continue;
97        };
98        let (kind, sat): (&'static str, Option<Sat>) = match &ev.payload {
99            EventPayload::TransferIn(ti) => ("transfer-in", Some(ti.sat)),
100            EventPayload::TransferOut(to) => ("transfer-out", Some(to.sat)),
101            EventPayload::Unclassified(_) => ("unclassified", None),
102            EventPayload::ImportConflict(_) => ("import-conflict", None),
103            EventPayload::Income(inc) => ("income", Some(inc.sat)),
104            // Acquire / Dispose (determined imports), decisions, and other system events are not rows.
105            _ => continue,
106        };
107        let date = tax_date(ev.utc_timestamp, ev.original_tz);
108        let usd = match &ev.payload {
109            EventPayload::Income(inc) => inc
110                .usd_fmv
111                .or_else(|| sat.and_then(|s| btctax_core::price::fmv_of(prices, date, s))),
112            _ => sat.and_then(|s| btctax_core::price::fmv_of(prices, date, s)),
113        };
114        rows.push(EventRow {
115            reff: ev.id.canonical(),
116            kind,
117            date,
118            sat,
119            usd,
120            decision_ref: decided.get(&ev.id).map(|d| d.canonical()),
121        });
122    }
123    Ok(rows)
124}
125
126/// FR4: project the ledger for display. `year` filters realized sections in the renderer; holdings are
127/// always the current per-lot position.
128pub fn report(
129    vault_path: &Path,
130    pp: &Passphrase,
131    _year: Option<i32>,
132) -> Result<LedgerState, CliError> {
133    let session = Session::open(vault_path, pp)?;
134    let (state, _cfg) = session.project()?;
135    Ok(state)
136}
137
138/// FR9: project the ledger → compute the sat-conservation report, partition blockers by severity, and
139/// summarize pending reconciliation + safe-harbor status. The binary maps `has_hard_blockers()` to a
140/// non-zero exit (a hard blocker gates downstream tax computation, §7.1).
141///
142/// Uses `Session::load_events_and_project` to load the event log exactly once (avoiding the
143/// double `load_all` that the old `project()` + separate `load_all(conn)` pattern incurred).
144/// Task 8: also reads the CLI config (declared `pre2025_method` + attestation flag) and passes
145/// it to `build_verify` so that `render_verify` can surface them.
146pub fn verify(vault_path: &Path, pp: &Passphrase) -> Result<VerifyReport, CliError> {
147    let session = Session::open(vault_path, pp)?;
148    let (events, state, _cfg) = session.load_events_and_project()?;
149    let cli = session.config()?;
150    // Task 11 (BG-D3): the verify-drift advisory recomputes each stored promote floor against the active
151    // price provider, so thread the session's prices into `build_verify`.
152    Ok(build_verify(&state, &events, session.prices(), &cli))
153}