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}