Skip to main content

btctax_cli/
session.rs

1//! `Session` wraps one open `btctax_store::Vault` and is the single seam every command opens. The
2//! passphrase is ALWAYS a parameter — production resolves it in `main` (prompt/env); tests inject a
3//! constructed `Passphrase`. `project()` runs the pure core projection over the bundled price dataset.
4use crate::bulk_estimated;
5use crate::config::{self, CliConfig};
6use crate::donation_details;
7use crate::optimize_attest;
8use crate::tax_profile;
9use crate::CliError;
10use btctax_adapters::{BundledPrices, BundledTaxTables};
11use btctax_core::conventions::{round_cents, tax_date, TRANSITION_DATE};
12use btctax_core::persistence::{init_schema, load_all};
13use btctax_core::{project, LedgerEvent, LedgerState, ProjectionConfig};
14use btctax_core::{
15    AllocLot, BlockerKind, DonationDetails, EventId, EventPayload, LotMethod, PendingTransfer, Sat,
16    TaxDate, TaxProfile, Usd, WalletId,
17};
18use btctax_store::{Passphrase, Vault};
19use rusqlite::Connection;
20use std::path::Path;
21
22// ── Bulk link-transfer plan (bulk-link-transfer D1) ──────────────────────────
23//
24// The shared, READ-ONLY plan both the CLI (`cmd::reconcile::bulk_link_plan`) and the TUI priced
25// preview compute from the HELD session. Modeled on `optimize_proposal`/`safe_harbor_residue`: a
26// `&self` read helper that appends and persists NOTHING.
27
28/// Time-frame selector for a bulk link-transfer plan. `Range` bounds are INCLUSIVE.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Frame {
31    All,
32    Year(i32),
33    Range { from: TaxDate, to: TaxDate },
34}
35
36/// Filter narrowing which pending outbound transfers a bulk plan selects.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct BulkFilter {
39    pub frame: Frame,
40    pub from_wallet: Option<WalletId>,
41}
42
43/// One enriched pending outbound transfer in a bulk link-transfer plan.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct BulkLinkRow {
46    pub out_event: EventId,
47    pub date: TaxDate,
48    /// [R0-N2] ALWAYS `Some` for a pending out (a wallet-less TransferOut never reaches
49    /// `pending_reconciliation`); `Option` kept defensively.
50    pub source_wallet: Option<WalletId>,
51    pub principal_sat: Sat,
52    /// `fmv_of(&prices, date, principal_sat)` [R0-M1]; advisory, `None` on missing price / overflow.
53    pub usd_value: Option<Usd>,
54    /// Σ leg `usd_basis` carried (over the principal+fee sats the legs cover); non-taxable → carries.
55    pub basis_usd: Usd,
56}
57
58/// The read-only plan a bulk link-transfer would execute: the eligible/in-frame `included` rows, the
59/// `skipped_same_wallet` rows (source == dest — a meaningless self-link), and the preview totals.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct BulkLinkPlan {
62    pub dest: WalletId,
63    /// Eligible + in-frame + passes `from_wallet` + source != dest. Sorted by `date`.
64    pub included: Vec<BulkLinkRow>,
65    /// Source wallet == dest → cannot self-link to itself.
66    pub skipped_same_wallet: Vec<BulkLinkRow>,
67    pub total_sat: Sat,
68    /// [R0-I2] Σ of the priced `usd_value`s — a FLOOR, always a real number.
69    pub total_usd_value_floor: Usd,
70    /// [R0-I2] rows priced `None` → render "≥ $X (N unavailable)" vs exact "$X".
71    pub missing_price_count: usize,
72    /// Σ `basis_usd` over `included`.
73    pub total_basis_usd: Usd,
74}
75
76// ── Bulk classify-inbound-self-transfer plan (bulk-classify-inbound-self-transfer D1) ─
77//
78// The shared, READ-ONLY plan both the CLI (`cmd::reconcile::bulk_self_transfer_in_plan`) and the TUI
79// priced preview compute from the HELD session. A close MIRROR of `bulk_link_transfer_plan` applied to
80// Cycle A's `InboundClass::SelfTransferMine` ($0 conservative basis, non-taxable). Appends and
81// persists NOTHING.
82
83/// Filter narrowing which pending unknown-basis inbound deposits a bulk STI plan selects. `wallet`
84/// filters the RECEIVING wallet (the inbound has no "destination" — it IS the receiving leg).
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct BulkStiFilter {
87    pub frame: Frame,
88    pub wallet: Option<WalletId>,
89}
90
91/// One enriched pending unknown-basis inbound deposit in a bulk STI plan.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct BulkStiRow {
94    pub in_event: EventId,
95    pub date: TaxDate,
96    /// [R0-M2] ALWAYS `Some` (wallet-less inbounds are excluded — they create no lot); `Option` kept
97    /// defensively / for display symmetry with `BulkLinkRow`.
98    pub wallet: Option<WalletId>,
99    pub sat: Sat,
100    /// `fmv_of(&prices, date, sat)` — the market value being given $0 basis; `None` on missing price.
101    pub usd_fmv: Option<Usd>,
102}
103
104/// The read-only plan a bulk STI would execute: the eligible/in-frame `included` rows + preview totals.
105/// No `skipped_*` bucket (an inbound has no self-destination to skip).
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct BulkStiPlan {
108    /// Eligible + in-frame + passes `wallet` filter. Sorted by `date`.
109    pub included: Vec<BulkStiRow>,
110    pub total_sat: Sat,
111    /// Σ of the priced `usd_fmv`s — the HONEST FLOOR (the over-tax exposure), always a real number.
112    pub total_usd_fmv_floor: Usd,
113    /// Rows priced `None` → render "≥ $X (N unavailable)" vs exact "$X".
114    pub missing_price_count: usize,
115}
116
117// ── Bulk classify-inbound-income plan (bulk-classify-inbound-income, Cycle 4) ─
118//
119// The shared, READ-ONLY plan both the CLI (`cmd::reconcile::bulk_classify_income_plan`) and the TUI
120// `I` flow compute from the HELD session. A NEAR-CLONE of `bulk_self_transfer_in_plan` with ONE
121// load-bearing difference [#a tax-safety]: a candidate whose `fmv_of(date, sat)` is `None` (no bundled
122// price / overflow) is EXCLUDED from `included` (counted in `excluded_missing_price`), NOT included —
123// because a persisted `InboundClass::Income { fmv: None }` projects to a Hard `FmvMissing` year-gate
124// (and on the inbound path that is NOT clearable by `ManualFmv` — the sole escape is void + reclassify).
125// So `included` carries a RESOLVED `fmv: Usd` (non-Option), making `Income{fmv:None}` structurally
126// unrepresentable from the bulk path. Appends and persists NOTHING.
127
128/// Filter narrowing which pending unknown-basis inbound deposits a bulk classify-income plan selects.
129/// Mirrors `BulkStiFilter`; `wallet` filters the RECEIVING wallet (the inbound IS the receiving leg).
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct BulkIncomeFilter {
132    pub frame: Frame,
133    pub wallet: Option<WalletId>,
134}
135
136/// One enriched pending unknown-basis inbound in a bulk classify-income plan, carrying a RESOLVED FMV.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct BulkIncomeRow {
139    pub in_event: EventId,
140    pub date: TaxDate,
141    pub sat: Sat,
142    /// [#a] The RESOLVED auto-FMV `fmv_of(&prices, date, sat)` — ALWAYS a real number (the `None`
143    /// rows are EXCLUDED upstream). This is the income recognized AND the lot basis.
144    pub fmv: Usd,
145}
146
147/// The read-only plan a bulk classify-income would execute: the eligible/in-frame `included` rows (each
148/// with a resolved `fmv`) + the count of candidates dropped for a MISSING price + preview totals.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct BulkIncomePlan {
151    /// Eligible + in-frame + passes `wallet` filter + HAS a price. Sorted by `date`.
152    pub included: Vec<BulkIncomeRow>,
153    /// [#a] Candidates dropped because `fmv_of == None` (surfaced, NOT silently dropped — the user
154    /// learns N inbounds could not be auto-valued as income; they stay pending).
155    pub excluded_missing_price: usize,
156    pub total_sat: Sat,
157    /// Σ `fmv` over `included` — the total income being recognized (always a real number).
158    pub total_income_usd: Usd,
159}
160
161// ── Bulk reclassify-outflow plan (bulk-reclassify-outflow, Cycle 5 — the LAST) ─
162//
163// The shared, READ-ONLY plan both the CLI (`cmd::reconcile::bulk_reclassify_outflow_plan`) and the TUI
164// `O` flow compute from the HELD session. The bulk analog of the single `o` reclassify-outflow: it
165// sweeps MANY `pending_reconciliation` outflows to a `Dispose{Sell|Spend}` with the auto-FMV as
166// ESTIMATED proceeds. Enriches over `pending_reconciliation` exactly as `bulk_link_transfer_plan`
167// (session.rs) does. Appends/persists NOTHING.
168//
169// Load-bearing tax-safety [#a]: a candidate whose `fmv_of(date, principal_sat)` is `None` is EXCLUDED
170// (counted in `excluded_missing_price`), NOT included — `ReclassifyOutflow.principal_proceeds_or_fmv`
171// is `Usd` (NOT Option), so a missing-price row cannot be constructed WITHOUT fabricating a number, and
172// (unlike bulk-income's LOUD `FmvMissing`) a fabricated/`0` proceeds would be SILENT (gates nothing,
173// misreports gain/loss). So `included` carries a RESOLVED `fmv: Usd` (non-Option) — the load-bearing
174// structural defense, mirroring `BulkIncomeRow.fmv: Usd`.
175//
176// Estimated gain [Q3]: `basis_usd = Σ pt.legs.usd_basis` (already computed by the fold's SINGLE
177// chronological pass with all N candidate PendingOuts pending, so `Σ` over multiple rows' legs is NEVER
178// double-counted — an earlier-dated PendingOut has already drawn the pool down before a later one
179// folds). `estimated_gain = round_cents(fmv − basis_usd)` per row. Precedent: `bulk_link_transfer_plan`
180// already sums `pt.legs.usd_basis`.
181
182/// One enriched pending outbound transfer in a bulk reclassify-outflow plan, carrying a RESOLVED FMV.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct BulkReclassifyOutflowRow {
185    pub out_event: EventId,
186    pub date: TaxDate,
187    /// [R0-N1] ALWAYS `Some` for a pending out (a wallet-less TransferOut never reaches
188    /// `pending_reconciliation`); `Option` kept defensively (mirror `BulkLinkRow.source_wallet`).
189    pub wallet: Option<WalletId>,
190    pub principal_sat: Sat,
191    /// [#a] The RESOLVED auto-FMV `fmv_of(&prices, date, principal_sat)` — ALWAYS a real number (the
192    /// `None` rows are EXCLUDED upstream). This is the ESTIMATED proceeds a `Dispose` recognizes.
193    pub fmv: Usd,
194    /// Σ leg `usd_basis` carried by the fold's PendingOut consumption (the disposal's basis).
195    pub basis_usd: Usd,
196    /// `round_cents(fmv − basis_usd)` — the per-row ESTIMATED gain (never double-counted; see above).
197    pub estimated_gain: Usd,
198}
199
200/// The read-only plan a bulk reclassify-outflow would execute: the eligible/in-frame `included` rows
201/// (each with a resolved `fmv`), the count of candidates dropped for a MISSING price, and preview
202/// totals. Mirrors `BulkIncomePlan`.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct BulkReclassifyOutflowPlan {
205    /// Eligible + in-frame + passes `from_wallet` filter + HAS a price. Sorted by `date`.
206    pub included: Vec<BulkReclassifyOutflowRow>,
207    /// [#a] Candidates dropped because `fmv_of == None` (surfaced, NOT silently dropped — a Sell with
208    /// fabricated proceeds would be a SILENT misreport; they stay pending).
209    pub excluded_missing_price: usize,
210    pub total_sat: Sat,
211    /// Σ `fmv` over `included` — the total ESTIMATED proceeds (always a real number).
212    pub total_proceeds_usd: Usd,
213    /// Σ `basis_usd` over `included`.
214    pub total_basis_usd: Usd,
215    /// Σ `estimated_gain` over `included` — the total ESTIMATED gain shown in the preview.
216    pub total_estimated_gain: Usd,
217}
218
219// ── Bulk resolve-conflict plan (bulk-resolve-conflict D1) ────────────────────
220//
221// The shared, READ-ONLY plan both the CLI (`cmd::reconcile::bulk_resolve_conflict_plan`) and the TUI
222// `C` flow compute from the HELD session. Candidate set = live `ImportConflict` blockers only (engine
223// post-filtered — an accepted/rejected conflict is no longer flagged → structural idempotence). Each
224// row carries the STRUCTURED current/new payloads (NOT pre-rendered strings) so each front-end renders
225// its own summary (CLI table formatter; TUI reuses `import_payload_summary`). Appends/persists NOTHING.
226
227/// One flagged import conflict in a bulk resolve-conflict plan. STRUCTURED (front-ends render summaries).
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct BulkResolveRow {
230    /// The `ImportConflict` event id — the resolution target (`SupersedeImport`/`RejectImport` carry
231    /// this as `conflict_event`).
232    pub conflict_event: EventId,
233    /// Calendar date (tax tz) of the conflict event.
234    pub date: TaxDate,
235    /// The TARGET import event id whose payload the conflict proposes to supersede (≠ conflict_event).
236    pub target: EventId,
237    /// Payload currently at the target (front-end renders "current"; KEPT on reject).
238    pub current_payload: EventPayload,
239    /// `ImportConflict.new_payload` (front-end renders "→ new"; ADOPTED on accept).
240    pub new_payload: EventPayload,
241    /// The 8-char `new_fingerprint` disambiguator (front-end shows it).
242    pub new_fingerprint: String,
243}
244
245/// The read-only plan a bulk resolve-conflict would execute: the live `ImportConflict` rows (sorted by
246/// date). No $ number (a conflict resolution recognizes no gain); no time/wallet filter (per-row
247/// exclude is the precision tool at the front-end).
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct BulkResolvePlan {
250    pub rows: Vec<BulkResolveRow>,
251}
252
253// ── Bulk void plan (bulk-void D1) ────────────────────────────────────────────
254//
255// The shared, READ-ONLY plan both the CLI (`cmd::reconcile::bulk_void_plan`) and the TUI `V` sweep
256// compute from the HELD session. Candidate set = the SINGLE shared predicate `voidable_decisions`
257// (btctax-core) over the projected events + blockers — the ONLY defense against voiding an EFFECTIVE
258// `SafeHarborAllocation` (#7 → Hard `DecisionConflict`). Each row carries the STRUCTURED decision
259// `payload` (front-ends render `summarize_void_payload`) + the precomputed `disposal_to_clear` (a
260// `LotSelection` target → `ls.disposal_event`) so the persist path never re-loads the log per row.
261// Appends/persists NOTHING; mirrors `bulk_resolve_conflict_plan`.
262
263/// One voidable reconcile decision in a bulk-void plan. STRUCTURED (front-ends render summaries).
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct BulkVoidRow {
266    /// The Decision event id to void (`VoidDecisionEvent.target_event_id`).
267    pub target_event_id: EventId,
268    /// `decision|seq` sequence number (display + deterministic sort).
269    pub seq: u64,
270    /// Calendar date (tax tz) of the decision event.
271    pub date: TaxDate,
272    /// The decision's payload — front-ends render `summarize_void_payload` (tag + what the void undoes).
273    pub payload: EventPayload,
274    /// Precomputed side-effect target: a `LotSelection` target → `Some(ls.disposal_event)` (whose
275    /// optimizer attestation the void clears); every other decision → `None`.
276    pub disposal_to_clear: Option<EventId>,
277}
278
279/// The read-only plan a bulk-void would execute: the voidable decisions (shared predicate), sorted by
280/// `seq`. No $ number (a void recognizes no gain); no time/wallet filter (per-row exclude is the
281/// front-end precision tool).
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct BulkVoidPlan {
284    pub rows: Vec<BulkVoidRow>,
285}
286
287// ── Self-transfer matcher (self-transfer-passthrough C2) ─────────────────────
288//
289// A READ-ONLY proposal helper (mirrors `bulk_link_transfer_plan`/`safe_harbor_residue`): it appends and
290// persists NOTHING. It pairs ONLY UNRECONCILED legs — candidate ins are `TransferIn`s still flagged
291// `UnknownBasisInbound` (an already-classified income / self-transfer-in is no longer flagged, so it is
292// structurally excluded), candidate outs are `pending_reconciliation` entries (an already-reclassified
293// sale is no longer pending). The user CONFIRMS every match; nothing is written by this helper.
294
295/// The confirm action a matched self-transfer pair resolves to.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum MatchAction {
298    /// Same tracked wallet on both legs (both counterparties external): DROP → `SelfTransferPassthrough`.
299    Drop,
300    /// Different tracked wallets (out from X, in to Y, X≠Y): RELOCATE → the EXISTING `TransferLink` out→in.
301    Relocate,
302}
303
304/// One proposed self-transfer match (C2). A PROPOSAL — never applied until the user confirms it.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct MatchProposal {
307    pub in_event: EventId,
308    pub out_event: EventId,
309    pub in_date: TaxDate,
310    pub out_date: TaxDate,
311    /// The in-leg's wallet (the RELOCATE destination). `None` legs are never proposed (can't relocate /
312    /// can't be same-wallet), so this is always `Some` for a real proposal; `Option` kept defensively.
313    pub in_wallet: Option<WalletId>,
314    /// The out-leg's (source) wallet. Always `Some` for a pending out; `Option` kept defensively.
315    pub out_wallet: Option<WalletId>,
316    pub in_sat: Sat,
317    pub out_principal_sat: Sat,
318    /// `fmv_of(prices, out_date, out_principal_sat)` — advisory, `None` on missing price / overflow.
319    pub usd_value: Option<Usd>,
320    /// Suggested action from wallet topology (same-wallet ⇒ Drop, cross-tracked-wallet ⇒ Relocate).
321    pub action: MatchAction,
322    /// True when this in OR this out matches >1 counterpart — surfaced FLAGGED, NEVER auto-picked (G-FALSE-MATCH).
323    pub ambiguous: bool,
324    /// True when `in.txid == out.txid` (both `Some`) — decisive cross-wallet corroboration (relaxes the
325    /// amount check, but NOT the ambiguity guard).
326    pub txid_match: bool,
327}
328
329pub struct Session {
330    vault: Vault,
331}
332
333impl std::fmt::Debug for Session {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        f.debug_struct("Session").finish_non_exhaustive()
336    }
337}
338
339impl Session {
340    /// Create a brand-new encrypted vault, then initialize the core event schema and the CLI config
341    /// table, and persist. (`Vault::create` already saved once; we re-save after the DDL.)
342    pub fn create(vault_path: &Path, pp: &Passphrase) -> Result<Session, CliError> {
343        Self::from_fresh_vault(Vault::create(vault_path, pp)?)
344    }
345
346    /// Like `create`, but first clears a half-created vault (orphan key, no pgp/bak) under
347    /// explicit `--repair` consent. Delegates to `Vault::repair` which refuses if a real or
348    /// recoverable vault is present (see `Vault::repair` safety invariant).
349    pub fn repair(vault_path: &Path, pp: &Passphrase) -> Result<Session, CliError> {
350        Self::from_fresh_vault(Vault::repair(vault_path, pp)?)
351    }
352
353    /// Initialize the core schema + CLI config + tax profile table on a freshly-created vault,
354    /// then persist.
355    fn from_fresh_vault(mut vault: Vault) -> Result<Session, CliError> {
356        init_schema(vault.conn())?;
357        config::init_config_table(vault.conn())?;
358        tax_profile::init_table(vault.conn())?;
359        optimize_attest::init_table(vault.conn())?;
360        donation_details::init_table(vault.conn())?;
361        bulk_estimated::init_table(vault.conn())?;
362        vault.save()?;
363        Ok(Session { vault })
364    }
365
366    /// Open an existing vault (acquires the store single-instance lock; NFR7).
367    pub fn open(vault_path: &Path, pp: &Passphrase) -> Result<Session, CliError> {
368        Ok(Session {
369            vault: Vault::open(vault_path, pp)?,
370        })
371    }
372
373    /// Borrow the live in-memory SQLite handle (core appenders use interior mutability over `&Connection`).
374    pub fn conn(&self) -> &Connection {
375        self.vault.conn()
376    }
377
378    /// Persist the current DB image (encrypted, atomic; NFR2/NFR3).
379    pub fn save(&mut self) -> Result<(), CliError> {
380        self.vault.save()?;
381        Ok(())
382    }
383
384    /// Snapshot the in-memory DB image (no disk I/O) for a possible `restore()` after a failed save.
385    pub fn snapshot(&self) -> Result<Vec<u8>, CliError> {
386        Ok(self.vault.snapshot()?)
387    }
388
389    /// Restore the in-memory DB from a prior `snapshot()` (no disk I/O). On `Err`, the in-memory DB
390    /// is UNCHANGED and unsaved residue may still be live — the caller MUST latch, never swallow.
391    pub fn restore(&mut self, image: &[u8]) -> Result<(), CliError> {
392        self.vault.restore(image)?;
393        Ok(())
394    }
395
396    /// Borrow the vault for store-level operations (`export_snapshot` / `backup_key`).
397    pub fn vault(&self) -> &Vault {
398        &self.vault
399    }
400
401    /// The persisted projection config (TP8 treatment + lot method); default = (c)+FIFO if unset.
402    pub fn config(&self) -> Result<CliConfig, CliError> {
403        config::read_config(self.conn())
404    }
405
406    /// The stored per-year `TaxProfile` for `year`, or `None` if none has been set.
407    /// Robust to older vaults (calls `tax_profile::init_table` as a defensive guard).
408    pub fn tax_profile(&self, year: i32) -> Result<Option<TaxProfile>, CliError> {
409        tax_profile::get(self.conn(), year)
410    }
411
412    /// All stored `TaxProfile`s, sorted by year ascending.
413    pub fn all_tax_profiles(
414        &self,
415    ) -> Result<std::collections::BTreeMap<i32, TaxProfile>, CliError> {
416        tax_profile::all(self.conn())
417    }
418
419    /// All attested disposal `EventId`s (NFR4-stable `BTreeSet`; feeds `compliance_overlay`).
420    /// Robust to older vaults (defensive `init_table` guard inside `attested_set`).
421    pub fn optimize_attested_set(
422        &self,
423    ) -> Result<std::collections::BTreeSet<btctax_core::EventId>, CliError> {
424        optimize_attest::attested_set(self.conn())
425    }
426
427    /// All stored `DonationDetails`, keyed by donation `EventId` (NFR4-stable `BTreeMap`).
428    /// Robust to older vaults (defensive `init_table` guard inside `donation_details::all`).
429    pub fn donation_details(
430        &self,
431    ) -> Result<std::collections::BTreeMap<EventId, DonationDetails>, CliError> {
432        donation_details::all(self.conn())
433    }
434
435    /// All disposals flagged as estimated-FMV proceeds by the bulk-reclassify-outflow path, keyed by
436    /// the `transfer_out_event` (== `Disposal.event`); value = the `date_marked` provenance stamp
437    /// (NFR4-stable `BTreeMap`). Robust to older vaults (defensive `init_table` guard inside
438    /// `bulk_estimated::all`). `build_snapshot` loads the `[est]` marker set via THIS accessor,
439    /// NEVER `conn()` directly [R0-M1].
440    pub fn bulk_estimated(&self) -> Result<std::collections::BTreeMap<EventId, String>, CliError> {
441        bulk_estimated::all(self.conn())
442    }
443
444    /// Load all events and run the pure deterministic projection (NFR4) over the bundled daily-close
445    /// dataset (§9.2). Returns the resolved `ProjectionConfig` too (so `verify` can display it).
446    pub fn project(&self) -> Result<(LedgerState, ProjectionConfig), CliError> {
447        let events = load_all(self.conn())?;
448        let cfg = self.config()?.to_projection();
449        let prices = BundledPrices::load()?;
450        let state = project(&events, &prices, &cfg);
451        Ok((state, cfg))
452    }
453
454    /// Single-load variant: loads events ONCE and returns them alongside the projection. Callers
455    /// that need both the raw event log and the projected state (e.g. `verify`, `safe_harbor_attest`)
456    /// use this to avoid the double `load_all` call that the `project()` + separate `load_all()`
457    /// pattern incurs.
458    pub fn load_events_and_project(
459        &self,
460    ) -> Result<(Vec<LedgerEvent>, LedgerState, ProjectionConfig), CliError> {
461        let events = load_all(self.conn())?;
462        let cfg = self.config()?.to_projection();
463        let prices = BundledPrices::load()?;
464        let state = project(&events, &prices, &cfg);
465        Ok((events, state, cfg))
466    }
467
468    /// Recompute the Mode-1 optimizer proposal for `year` on the HELD session. READ-ONLY: appends and
469    /// persists NOTHING (a clone-fold-discard recompute).
470    ///
471    /// The TUI editor's optimize-accept opener calls this to obtain a FRESH proposal (NFR4 — never
472    /// trusts a stale one) WITHOUT opening a second `Session` (a second open would deadlock on the
473    /// held VaultLock, and `cmd::optimize::accept` is forbidden to the editor for the same reason).
474    /// Assembles `optimize_year`'s inputs exactly as `cmd::optimize::run`/`accept` do — events + config
475    /// from this conn, bundled prices + tables, a FRESH `tax_profile(year)` read (not the cached snap),
476    /// the attested set, and `proposal_made = tax_date(now, UtcOffset::UTC)` — and maps `OptimizeError`
477    /// through the crate-internal `map_opt_err` (which is `pub(crate)` and not TUI-reachable). `now`
478    /// is injected by the caller for determinism.
479    pub fn optimize_proposal(
480        &self,
481        year: i32,
482        now: time::OffsetDateTime,
483    ) -> Result<btctax_core::OptimizeProposal, CliError> {
484        let (events, _state, cfg) = self.load_events_and_project()?;
485        let profile = self.tax_profile(year)?;
486        let prices = BundledPrices::load()?;
487        let tables = BundledTaxTables::load();
488        let attested = self.optimize_attested_set()?;
489        let proposal_made = tax_date(now, time::UtcOffset::UTC);
490        btctax_core::optimize_year(
491            &events,
492            &prices,
493            &cfg,
494            year,
495            profile.as_ref(),
496            &tables,
497            &attested,
498            proposal_made,
499        )
500        .map_err(crate::cmd::optimize::map_opt_err)
501    }
502
503    /// READ-ONLY: the 2025-01-01 pre-2025 Universal residue as `AllocLot`s, plus the `pre2025_method`
504    /// (`LotMethod`) it was computed under. Appends/persists NOTHING. The single source of the pre-2025
505    /// subset, shared by `cmd::reconcile::safe_harbor_allocate` and the TUI allocate opener.
506    ///
507    /// Reads the config ONCE: `cfg.pre2025_method` is the recorded method returned to the caller, and
508    /// `cfg.to_projection()` is the projection the residue is computed under — the two are STRUCTURALLY
509    /// the same config read, so the returned method can never diverge from the residue's [R0-M1]. The
510    /// pre-2025 subset keeps only imports whose tax-date `< 2025-01-01` plus ALL reconciliation decisions
511    /// (which shape the residue), and DROPs any prior `SafeHarborAllocation` so the residue stays
512    /// allocation-INDEPENDENT (matches `transition::universal_snapshot`).
513    pub fn safe_harbor_residue(&self) -> Result<(Vec<AllocLot>, LotMethod), CliError> {
514        let cfg = self.config()?;
515        let pre2025_method = cfg.pre2025_method; // recorded field == the one used below
516        let proj = cfg.to_projection();
517        let pre2025: Vec<LedgerEvent> = load_all(self.conn())?
518            .into_iter()
519            .filter(|e| match &e.id {
520                EventId::Import { .. } => {
521                    tax_date(e.utc_timestamp, e.original_tz) < TRANSITION_DATE
522                }
523                _ => !matches!(e.payload, EventPayload::SafeHarborAllocation(_)),
524            })
525            .collect();
526        let prices = BundledPrices::load()?;
527        let residue = project(&pre2025, &prices, &proj);
528        let lots = residue
529            .lots
530            .iter()
531            .filter(|l| l.remaining_sat > 0)
532            .map(|l| AllocLot {
533                wallet: l.wallet.clone(),
534                sat: l.remaining_sat,
535                usd_basis: l.usd_basis,
536                acquired_at: l.acquired_at,
537                dual_loss_basis: l.dual_loss_basis,
538                donor_acquired_at: l.donor_acquired_at,
539            })
540            .collect();
541        Ok((lots, pre2025_method))
542    }
543
544    /// READ-ONLY: compute the bulk link-transfer plan (bulk-link-transfer D1). Selects over the
545    /// PROJECTED `pending_reconciliation` (which already excludes already-decided / already-linked
546    /// outs), enriches each with date / source wallet / principal / advisory USD value / carried
547    /// basis, applies the frame + `from_wallet` filters, and routes `source == dest` rows to
548    /// `skipped_same_wallet`. Appends and persists NOTHING; mirrors `safe_harbor_residue`.
549    ///
550    /// The USD value is `btctax_core::price::fmv_of(&prices, date, principal_sat)` [R0-M1] — the
551    /// vetted checked helper (round_cents + overflow→`None`), NOT a hand-rolled `principal × price`.
552    /// The total is the HONEST FLOOR [R0-I2]: `total_usd_value_floor` is Σ of the PRICED rows only,
553    /// and `missing_price_count` records how many rows lacked a price, so the caller renders exact
554    /// `$X` (when 0) or `≥ $X (N unavailable)`.
555    pub fn bulk_link_transfer_plan(
556        &self,
557        filter: BulkFilter,
558        dest: WalletId,
559    ) -> Result<BulkLinkPlan, CliError> {
560        let (events, state, _cfg) = self.load_events_and_project()?;
561        let prices = BundledPrices::load()?;
562        let index: std::collections::HashMap<EventId, &LedgerEvent> =
563            events.iter().map(|e| (e.id.clone(), e)).collect();
564
565        let enrich = |pt: &PendingTransfer| -> BulkLinkRow {
566            let ev = index.get(&pt.event).copied();
567            let date = ev
568                .map(|e| tax_date(e.utc_timestamp, e.original_tz))
569                .unwrap_or_else(|| {
570                    // Defensive: a pending out always has an indexed source event; fall back to the
571                    // epoch date rather than panic (mirrors the single link-transfer opener).
572                    tax_date(
573                        time::OffsetDateTime::from_unix_timestamp(0).unwrap(),
574                        time::UtcOffset::UTC,
575                    )
576                });
577            let source_wallet = ev.and_then(|e| e.wallet.clone());
578            let usd_value = btctax_core::price::fmv_of(&prices, date, pt.principal_sat);
579            let basis_usd: Usd = pt.legs.iter().map(|l| l.usd_basis).sum();
580            BulkLinkRow {
581                out_event: pt.event.clone(),
582                date,
583                source_wallet,
584                principal_sat: pt.principal_sat,
585                usd_value,
586                basis_usd,
587            }
588        };
589
590        let in_frame = |date: TaxDate| match &filter.frame {
591            Frame::All => true,
592            Frame::Year(y) => date.year() == *y,
593            Frame::Range { from, to } => *from <= date && date <= *to,
594        };
595
596        let mut included: Vec<BulkLinkRow> = Vec::new();
597        let mut skipped_same_wallet: Vec<BulkLinkRow> = Vec::new();
598        for pt in &state.pending_reconciliation {
599            let row = enrich(pt);
600            if !in_frame(row.date) {
601                continue;
602            }
603            if let Some(w) = &filter.from_wallet {
604                if row.source_wallet.as_ref() != Some(w) {
605                    continue;
606                }
607            }
608            // Same-wallet guard: a self-link to the SAME wallet is meaningless — report, never link.
609            if row.source_wallet.as_ref() == Some(&dest) {
610                skipped_same_wallet.push(row);
611            } else {
612                included.push(row);
613            }
614        }
615        included.sort_by_key(|r| r.date);
616
617        let total_sat: Sat = included.iter().map(|r| r.principal_sat).sum();
618        let total_usd_value_floor: Usd = included.iter().filter_map(|r| r.usd_value).sum();
619        let missing_price_count = included.iter().filter(|r| r.usd_value.is_none()).count();
620        let total_basis_usd: Usd = included.iter().map(|r| r.basis_usd).sum();
621
622        Ok(BulkLinkPlan {
623            dest,
624            included,
625            skipped_same_wallet,
626            total_sat,
627            total_usd_value_floor,
628            missing_price_count,
629            total_basis_usd,
630        })
631    }
632
633    /// READ-ONLY: compute the bulk classify-inbound-self-transfer plan
634    /// (bulk-classify-inbound-self-transfer D1). A close MIRROR of `bulk_link_transfer_plan` applied to
635    /// Cycle A's inbound `SelfTransferMine` ($0 conservative basis, non-taxable). Appends/persists
636    /// NOTHING (clone-fold-discard recompute); KAT-G1-clean at the TUI call site.
637    ///
638    /// **Selection (structural false-classify safety) [R0-I1]:** candidates are `TransferIn` events
639    /// still flagged `UnknownBasisInbound` (blocker set joined to the raw event via the index, as
640    /// `self_transfer_match_plan` does) **MINUS** any already targeted by a NON-VOIDED `ClassifyInbound`
641    /// (mirror `open_classify_inbound_flow`'s filter 3 — appending a second fires a return-blocking Hard
642    /// `DecisionConflict`; `UnknownBasisInbound` is RE-EMITTED for gift-basis-unknown states, so
643    /// "flagged" ≠ "unclassified") **MINUS** wallet-less inbounds [R0-M2] (create no lot). The USD is
644    /// `fmv_of` [G4]; the total is the HONEST FLOOR (`total_usd_fmv_floor` + `missing_price_count`).
645    pub fn bulk_self_transfer_in_plan(
646        &self,
647        filter: BulkStiFilter,
648    ) -> Result<BulkStiPlan, CliError> {
649        let (events, state, _cfg) = self.load_events_and_project()?;
650        let prices = BundledPrices::load()?;
651        let index: std::collections::HashMap<EventId, &LedgerEvent> =
652            events.iter().map(|e| (e.id.clone(), e)).collect();
653
654        // [R0-I1] filter-3, mirroring `open_classify_inbound_flow`: the set of TransferIn event ids
655        // already targeted by a NON-VOIDED `ClassifyInbound`. Build the voided-decision-id set first,
656        // then keep only ClassifyInbounds whose OWN id is not voided [R0-M-r2-1: decision-id space and
657        // TransferIn-id space are disjoint; we intersect a decision's own id against `voided`, and map
658        // the survivor to its `transfer_in_event`].
659        let voided: std::collections::BTreeSet<EventId> = events
660            .iter()
661            .filter_map(|e| match &e.payload {
662                EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
663                _ => None,
664            })
665            .collect();
666        let already_classified: std::collections::BTreeSet<EventId> = events
667            .iter()
668            .filter(|e| !voided.contains(&e.id))
669            .filter_map(|e| match &e.payload {
670                EventPayload::ClassifyInbound(ci) => Some(ci.transfer_in_event.clone()),
671                _ => None,
672            })
673            .collect();
674
675        let in_frame = |date: TaxDate| match &filter.frame {
676            Frame::All => true,
677            Frame::Year(y) => date.year() == *y,
678            Frame::Range { from, to } => *from <= date && date <= *to,
679        };
680
681        let mut included: Vec<BulkStiRow> = Vec::new();
682        for b in &state.blockers {
683            if b.kind != BlockerKind::UnknownBasisInbound {
684                continue;
685            }
686            let Some(id) = &b.event else { continue };
687            // [R0-I1] EXCLUDE any inbound that already carries a live ClassifyInbound (else the bulk
688            // append duplicates it → Hard DecisionConflict blocks compute_tax_year).
689            if already_classified.contains(id) {
690                continue;
691            }
692            let Some(ev) = index.get(id) else { continue };
693            let EventPayload::TransferIn(ti) = &ev.payload else {
694                continue;
695            };
696            // [R0-M2] EXCLUDE wallet-less inbounds — a self-transfer-in creates no lot and re-fires
697            // the blocker (matcher skips them too).
698            let Some(wallet) = ev.wallet.clone() else {
699                continue;
700            };
701            let date = tax_date(ev.utc_timestamp, ev.original_tz);
702            if !in_frame(date) {
703                continue;
704            }
705            if let Some(w) = &filter.wallet {
706                if &wallet != w {
707                    continue;
708                }
709            }
710            let sat = ti.sat;
711            let usd_fmv = btctax_core::price::fmv_of(&prices, date, sat);
712            included.push(BulkStiRow {
713                in_event: id.clone(),
714                date,
715                wallet: Some(wallet),
716                sat,
717                usd_fmv,
718            });
719        }
720        included.sort_by_key(|r| r.date);
721
722        let total_sat: Sat = included.iter().map(|r| r.sat).sum();
723        let total_usd_fmv_floor: Usd = included.iter().filter_map(|r| r.usd_fmv).sum();
724        let missing_price_count = included.iter().filter(|r| r.usd_fmv.is_none()).count();
725
726        Ok(BulkStiPlan {
727            included,
728            total_sat,
729            total_usd_fmv_floor,
730            missing_price_count,
731        })
732    }
733
734    /// READ-ONLY: compute the bulk classify-inbound-income plan (bulk-classify-inbound-income, Cycle 4).
735    /// A NEAR-CLONE of `bulk_self_transfer_in_plan`: candidates are `TransferIn`s still flagged
736    /// `UnknownBasisInbound` MINUS any already targeted by a NON-VOIDED `ClassifyInbound` (filter-3;
737    /// a second `ClassifyInbound` fires a Hard `DecisionConflict`) MINUS wallet-less inbounds (create no
738    /// lot; also a Hard-`FmvMissing` vector). Then the **Cycle-4 tax-safety difference [#a]**: any
739    /// candidate whose `fmv_of(date, sat)` is `None` (missing daily-close price OR overflow) is EXCLUDED
740    /// from `included` and counted in `excluded_missing_price` — a persisted `Income{fmv:None}` projects
741    /// to a Hard `FmvMissing` year-gate (NOT clearable by `ManualFmv` on the inbound path). `included`
742    /// therefore carries a RESOLVED `fmv: Usd` (non-Option). Appends/persists NOTHING.
743    pub fn bulk_classify_income_plan(
744        &self,
745        filter: BulkIncomeFilter,
746    ) -> Result<BulkIncomePlan, CliError> {
747        let (events, state, _cfg) = self.load_events_and_project()?;
748        let prices = BundledPrices::load()?;
749        let index: std::collections::HashMap<EventId, &LedgerEvent> =
750            events.iter().map(|e| (e.id.clone(), e)).collect();
751
752        // filter-3, mirroring `bulk_self_transfer_in_plan`: the set of TransferIn ids already targeted
753        // by a NON-VOIDED `ClassifyInbound` (build the voided-decision-id set first, keep only
754        // ClassifyInbounds whose OWN id is not voided, map the survivor to its `transfer_in_event`).
755        let voided: std::collections::BTreeSet<EventId> = events
756            .iter()
757            .filter_map(|e| match &e.payload {
758                EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
759                _ => None,
760            })
761            .collect();
762        let already_classified: std::collections::BTreeSet<EventId> = events
763            .iter()
764            .filter(|e| !voided.contains(&e.id))
765            .filter_map(|e| match &e.payload {
766                EventPayload::ClassifyInbound(ci) => Some(ci.transfer_in_event.clone()),
767                _ => None,
768            })
769            .collect();
770
771        let in_frame = |date: TaxDate| match &filter.frame {
772            Frame::All => true,
773            Frame::Year(y) => date.year() == *y,
774            Frame::Range { from, to } => *from <= date && date <= *to,
775        };
776
777        let mut included: Vec<BulkIncomeRow> = Vec::new();
778        let mut excluded_missing_price = 0usize;
779        for b in &state.blockers {
780            if b.kind != BlockerKind::UnknownBasisInbound {
781                continue;
782            }
783            let Some(id) = &b.event else { continue };
784            // filter-3: EXCLUDE any inbound already carrying a live ClassifyInbound (a second one →
785            // Hard DecisionConflict blocks compute_tax_year).
786            if already_classified.contains(id) {
787                continue;
788            }
789            let Some(ev) = index.get(id) else { continue };
790            let EventPayload::TransferIn(ti) = &ev.payload else {
791                continue;
792            };
793            // EXCLUDE wallet-less inbounds — an income inbound without a wallet creates no lot and
794            // itself raises a Hard FmvMissing (fold.rs); the matcher skips them too.
795            let Some(wallet) = ev.wallet.clone() else {
796                continue;
797            };
798            let date = tax_date(ev.utc_timestamp, ev.original_tz);
799            if !in_frame(date) {
800                continue;
801            }
802            if let Some(w) = &filter.wallet {
803                if &wallet != w {
804                    continue;
805                }
806            }
807            let sat = ti.sat;
808            // [#a tax-safety — the whole cycle] a candidate with no price is EXCLUDED (counted), NEVER
809            // classified as income: an Income{fmv:None} would trade UnknownBasisInbound for a Hard
810            // FmvMissing year-gate. bulk-sti INCLUDES these rows ($0-basis needs no FMV); bulk-income
811            // must NOT. `included` carries the resolved non-Option fmv.
812            match btctax_core::price::fmv_of(&prices, date, sat) {
813                Some(fmv) => included.push(BulkIncomeRow {
814                    in_event: id.clone(),
815                    date,
816                    sat,
817                    fmv,
818                }),
819                None => excluded_missing_price += 1,
820            }
821        }
822        included.sort_by_key(|r| r.date);
823
824        let total_sat: Sat = included.iter().map(|r| r.sat).sum();
825        let total_income_usd: Usd = included.iter().map(|r| r.fmv).sum();
826
827        Ok(BulkIncomePlan {
828            included,
829            excluded_missing_price,
830            total_sat,
831            total_income_usd,
832        })
833    }
834
835    /// READ-ONLY: compute the bulk reclassify-outflow plan (bulk-reclassify-outflow, Cycle 5). Selects
836    /// over the PROJECTED `pending_reconciliation` (which already excludes already-reclassified / linked
837    /// outs, and never contains wallet-less outflows), enriches each with date / source wallet /
838    /// principal / RESOLVED auto-FMV / carried basis / estimated gain, and applies the frame +
839    /// `from_wallet` filters. Appends/persists NOTHING (clone-fold-discard recompute); mirrors
840    /// `bulk_link_transfer_plan` + the `bulk_classify_income_plan` `#a` missing-price exclusion.
841    ///
842    /// **[#a tax-safety — the whole cycle]** a candidate with no price is EXCLUDED (counted in
843    /// `excluded_missing_price`), NEVER reclassified: `ReclassifyOutflow.principal_proceeds_or_fmv` is
844    /// `Usd` (NOT Option), so the row could only be built by FABRICATING a `0`/bogus proceeds — a SILENT
845    /// misreport (unlike bulk-income's LOUD `FmvMissing`). `included` therefore carries a RESOLVED
846    /// `fmv: Usd` (non-Option), making a fabricated-proceeds Sell structurally unrepresentable here.
847    ///
848    /// **Estimated gain [Q3]:** `basis_usd = Σ pt.legs.usd_basis` — computed by the fold's SINGLE
849    /// chronological pass with ALL candidate PendingOuts pending, so `Σ` over multiple rows is NEVER
850    /// double-counted (an earlier-dated out drew the pool down before a later one folded). `estimated_gain
851    /// = round_cents(fmv − basis_usd)` per row. The persisted Form-8949 numbers always run the ordinary
852    /// fold (exact); only this preview carries the FIFO-vs-method / fee-treatment residual (label it).
853    pub fn bulk_reclassify_outflow_plan(
854        &self,
855        filter: BulkFilter,
856    ) -> Result<BulkReclassifyOutflowPlan, CliError> {
857        let (events, state, _cfg) = self.load_events_and_project()?;
858        let prices = BundledPrices::load()?;
859        let index: std::collections::HashMap<EventId, &LedgerEvent> =
860            events.iter().map(|e| (e.id.clone(), e)).collect();
861
862        let in_frame = |date: TaxDate| match &filter.frame {
863            Frame::All => true,
864            Frame::Year(y) => date.year() == *y,
865            Frame::Range { from, to } => *from <= date && date <= *to,
866        };
867
868        let mut included: Vec<BulkReclassifyOutflowRow> = Vec::new();
869        let mut excluded_missing_price = 0usize;
870        for pt in &state.pending_reconciliation {
871            let ev = index.get(&pt.event).copied();
872            let date = ev
873                .map(|e| tax_date(e.utc_timestamp, e.original_tz))
874                .unwrap_or_else(|| {
875                    // Defensive: a pending out always has an indexed source event; fall back to the
876                    // epoch date rather than panic (mirrors `bulk_link_transfer_plan`).
877                    tax_date(
878                        time::OffsetDateTime::from_unix_timestamp(0).unwrap(),
879                        time::UtcOffset::UTC,
880                    )
881                });
882            if !in_frame(date) {
883                continue;
884            }
885            let wallet = ev.and_then(|e| e.wallet.clone());
886            if let Some(w) = &filter.from_wallet {
887                if wallet.as_ref() != Some(w) {
888                    continue;
889                }
890            }
891            // [#a] EXCLUDE (count) a missing-price row — a Sell with fabricated proceeds is a SILENT
892            // misreport. `included` carries the resolved non-Option fmv.
893            let fmv = match btctax_core::price::fmv_of(&prices, date, pt.principal_sat) {
894                Some(v) => v,
895                None => {
896                    excluded_missing_price += 1;
897                    continue;
898                }
899            };
900            let basis_usd: Usd = pt.legs.iter().map(|l| l.usd_basis).sum();
901            let estimated_gain = round_cents(fmv - basis_usd);
902            included.push(BulkReclassifyOutflowRow {
903                out_event: pt.event.clone(),
904                date,
905                wallet,
906                principal_sat: pt.principal_sat,
907                fmv,
908                basis_usd,
909                estimated_gain,
910            });
911        }
912        included.sort_by_key(|r| r.date);
913
914        let total_sat: Sat = included.iter().map(|r| r.principal_sat).sum();
915        let total_proceeds_usd: Usd = included.iter().map(|r| r.fmv).sum();
916        let total_basis_usd: Usd = included.iter().map(|r| r.basis_usd).sum();
917        let total_estimated_gain: Usd = included.iter().map(|r| r.estimated_gain).sum();
918
919        Ok(BulkReclassifyOutflowPlan {
920            included,
921            excluded_missing_price,
922            total_sat,
923            total_proceeds_usd,
924            total_basis_usd,
925            total_estimated_gain,
926        })
927    }
928
929    /// READ-ONLY: compute the bulk resolve-conflict plan (bulk-resolve-conflict D1). Candidate set =
930    /// live `ImportConflict` blockers only (engine post-filtered — an accepted/rejected conflict is no
931    /// longer flagged, so re-running never double-resolves; structural idempotence). Joins each blocker
932    /// (whose `.event` is the `ImportConflict` event id) to the event index to build a STRUCTURED row:
933    /// the conflict's `target` + `new_payload` + `new_fingerprint`, plus the `current_payload` read from
934    /// the TARGET event (a SEPARATE event — accept adopts `new_payload`, reject keeps `current_payload`).
935    /// Appends/persists NOTHING; mirrors `bulk_self_transfer_in_plan`.
936    pub fn bulk_resolve_conflict_plan(&self) -> Result<BulkResolvePlan, CliError> {
937        let (events, state, _cfg) = self.load_events_and_project()?;
938        let index: std::collections::HashMap<EventId, &LedgerEvent> =
939            events.iter().map(|e| (e.id.clone(), e)).collect();
940
941        let mut rows: Vec<BulkResolveRow> = Vec::new();
942        for b in &state.blockers {
943            if b.kind != BlockerKind::ImportConflict {
944                continue;
945            }
946            let Some(conflict_id) = &b.event else {
947                continue;
948            };
949            let Some(conflict_ev) = index.get(conflict_id) else {
950                continue;
951            };
952            let EventPayload::ImportConflict(c) = &conflict_ev.payload else {
953                continue;
954            };
955            // CURRENT payload lives at the TARGET id (a separate event, conflict_event != target).
956            let Some(target_ev) = index.get(&c.target) else {
957                continue;
958            };
959            let date = tax_date(conflict_ev.utc_timestamp, conflict_ev.original_tz);
960            rows.push(BulkResolveRow {
961                conflict_event: conflict_id.clone(),
962                date,
963                target: c.target.clone(),
964                current_payload: target_ev.payload.clone(),
965                new_payload: (*c.new_payload).clone(),
966                new_fingerprint: c.new_fingerprint.0.chars().take(8).collect::<String>(),
967            });
968        }
969        rows.sort_by_key(|r| r.date);
970        Ok(BulkResolvePlan { rows })
971    }
972
973    /// READ-ONLY: compute the bulk-void plan (bulk-void D1). Candidate set = the SINGLE shared
974    /// predicate `btctax_core::voidable_decisions` over the projected events + blockers (Decision-id ∧
975    /// not-voided ∧ `is_revocable_payload` ∧ #7 `!effective_alloc`) — the ONLY defense against sweeping
976    /// an EFFECTIVE `SafeHarborAllocation` into a Hard `DecisionConflict`. Each row precomputes its
977    /// `disposal_to_clear` ONCE from the same event set (a `LotSelection` target → `ls.disposal_event`)
978    /// so `apply_bulk_void`/`persist_bulk_void` never re-load the log per row. Appends/persists NOTHING;
979    /// mirrors `bulk_resolve_conflict_plan`. Sorted by `seq` for deterministic display.
980    pub fn bulk_void_plan(&self) -> Result<BulkVoidPlan, CliError> {
981        let (events, state, _cfg) = self.load_events_and_project()?;
982        let mut rows: Vec<BulkVoidRow> = btctax_core::voidable_decisions(&events, &state.blockers)
983            .into_iter()
984            .map(|e| {
985                let seq = match &e.id {
986                    EventId::Decision { seq } => *seq,
987                    _ => 0,
988                };
989                let disposal_to_clear = match &e.payload {
990                    EventPayload::LotSelection(ls) => Some(ls.disposal_event.clone()),
991                    _ => None,
992                };
993                BulkVoidRow {
994                    target_event_id: e.id.clone(),
995                    seq,
996                    date: tax_date(e.utc_timestamp, e.original_tz),
997                    payload: e.payload.clone(),
998                    disposal_to_clear,
999                }
1000            })
1001            .collect();
1002        rows.sort_by_key(|r| r.seq);
1003        Ok(BulkVoidPlan { rows })
1004    }
1005
1006    /// READ-ONLY: propose self-transfer matches (self-transfer-passthrough C2). Appends/persists NOTHING
1007    /// (a clone-fold-discard recompute, like `bulk_link_transfer_plan`). Pairs ONLY unreconciled legs —
1008    /// candidate ins are `TransferIn`s flagged `UnknownBasisInbound` [R0-M2] (enumerated from the blocker
1009    /// set + joined to the raw event via the event index), candidate outs are `pending_reconciliation`.
1010    ///
1011    /// A pair is proposed iff ALL criteria pass: amount within `tol = max(out.fee_sat, ceil(0.005 ×
1012    /// out.principal))` (a `txid` EXACT match relaxes the amount check), a ±2-day window consistent with
1013    /// the direction (passthrough: in on/before out; relocate: in on/after out), and BOTH wallets present.
1014    /// The suggested `action` is wallet topology (same-wallet ⇒ Drop, cross-tracked-wallet ⇒ Relocate).
1015    /// A leg matching >1 counterpart is flagged `ambiguous` (surfaced, NEVER auto-picked — G-FALSE-MATCH).
1016    pub fn self_transfer_match_plan(&self) -> Result<Vec<MatchProposal>, CliError> {
1017        let (events, state, _cfg) = self.load_events_and_project()?;
1018        let prices = BundledPrices::load()?;
1019        let index: std::collections::HashMap<EventId, &LedgerEvent> =
1020            events.iter().map(|e| (e.id.clone(), e)).collect();
1021
1022        // Candidate ins: TransferIn events still flagged UnknownBasisInbound (unreconciled), joined to the
1023        // raw event via the index (the exact pattern `bulk_link_transfer_plan` uses for pending outs).
1024        struct CandIn {
1025            id: EventId,
1026            sat: Sat,
1027            txid: Option<String>,
1028            date: TaxDate,
1029            wallet: Option<WalletId>,
1030        }
1031        let mut ins: Vec<CandIn> = Vec::new();
1032        for b in &state.blockers {
1033            if b.kind != BlockerKind::UnknownBasisInbound {
1034                continue;
1035            }
1036            let Some(id) = &b.event else { continue };
1037            let Some(ev) = index.get(id) else { continue };
1038            let EventPayload::TransferIn(ti) = &ev.payload else {
1039                continue;
1040            };
1041            ins.push(CandIn {
1042                id: id.clone(),
1043                sat: ti.sat,
1044                txid: ti.txid.clone(),
1045                date: tax_date(ev.utc_timestamp, ev.original_tz),
1046                wallet: ev.wallet.clone(),
1047            });
1048        }
1049
1050        // Candidate outs: pending_reconciliation entries (already Op::PendingOut — unreconciled).
1051        struct CandOut {
1052            id: EventId,
1053            principal: Sat,
1054            fee: Option<Sat>,
1055            txid: Option<String>,
1056            date: TaxDate,
1057            wallet: Option<WalletId>,
1058        }
1059        let mut outs: Vec<CandOut> = Vec::new();
1060        for pt in &state.pending_reconciliation {
1061            let ev = index.get(&pt.event).copied();
1062            let date = ev
1063                .map(|e| tax_date(e.utc_timestamp, e.original_tz))
1064                .unwrap_or_else(|| {
1065                    tax_date(
1066                        time::OffsetDateTime::from_unix_timestamp(0).unwrap(),
1067                        time::UtcOffset::UTC,
1068                    )
1069                });
1070            let txid = ev.and_then(|e| match &e.payload {
1071                EventPayload::TransferOut(t) => t.txid.clone(),
1072                _ => None,
1073            });
1074            outs.push(CandOut {
1075                id: pt.event.clone(),
1076                principal: pt.principal_sat,
1077                fee: pt.fee_sat,
1078                txid,
1079                date,
1080                wallet: ev.and_then(|e| e.wallet.clone()),
1081            });
1082        }
1083
1084        // Passing (in_idx, out_idx, action, txid_match) tuples.
1085        let mut passing: Vec<(usize, usize, MatchAction, bool)> = Vec::new();
1086        for (i, ci) in ins.iter().enumerate() {
1087            // A wallet-less in can be neither a same-wallet DROP nor a RELOCATE destination.
1088            let Some(in_wallet) = ci.wallet.as_ref() else {
1089                continue;
1090            };
1091            for (j, co) in outs.iter().enumerate() {
1092                let Some(out_wallet) = co.wallet.as_ref() else {
1093                    continue;
1094                };
1095                let action = if in_wallet == out_wallet {
1096                    MatchAction::Drop
1097                } else {
1098                    MatchAction::Relocate
1099                };
1100                // Amount: tol = max(fee, ceil(0.005 × principal)). ceil(p/200) = (p + 199) / 200 (p ≥ 0).
1101                let slack = if co.principal > 0 {
1102                    (co.principal + 199) / 200
1103                } else {
1104                    0
1105                };
1106                let tol = co.fee.unwrap_or(0).max(slack);
1107                let txid_match = ci.txid.is_some() && ci.txid == co.txid;
1108                let amount_ok = txid_match || (ci.sat - co.principal).abs() <= tol;
1109                if !amount_ok {
1110                    continue;
1111                }
1112                // ±2-day window, direction keyed to the topology (exchange timestamp drift tolerated).
1113                let window_ok = match action {
1114                    // Passthrough: the deposit precedes the withdrawal (in on/before out).
1115                    MatchAction::Drop => {
1116                        let d = (co.date - ci.date).whole_days();
1117                        (0..=2).contains(&d)
1118                    }
1119                    // Relocate: the withdrawal precedes the arrival (in on/after out).
1120                    MatchAction::Relocate => {
1121                        let d = (ci.date - co.date).whole_days();
1122                        (0..=2).contains(&d)
1123                    }
1124                };
1125                if !window_ok {
1126                    continue;
1127                }
1128                passing.push((i, j, action, txid_match));
1129            }
1130        }
1131
1132        // Ambiguity: a leg (in OR out) appearing in >1 passing pair is flagged, never silently picked.
1133        let mut in_count: std::collections::HashMap<usize, usize> =
1134            std::collections::HashMap::new();
1135        let mut out_count: std::collections::HashMap<usize, usize> =
1136            std::collections::HashMap::new();
1137        for (i, j, _, _) in &passing {
1138            *in_count.entry(*i).or_insert(0) += 1;
1139            *out_count.entry(*j).or_insert(0) += 1;
1140        }
1141
1142        let mut proposals: Vec<MatchProposal> = passing
1143            .iter()
1144            .map(|(i, j, action, txid_match)| {
1145                let ci = &ins[*i];
1146                let co = &outs[*j];
1147                let ambiguous = in_count[i] > 1 || out_count[j] > 1;
1148                MatchProposal {
1149                    in_event: ci.id.clone(),
1150                    out_event: co.id.clone(),
1151                    in_date: ci.date,
1152                    out_date: co.date,
1153                    in_wallet: ci.wallet.clone(),
1154                    out_wallet: co.wallet.clone(),
1155                    in_sat: ci.sat,
1156                    out_principal_sat: co.principal,
1157                    usd_value: btctax_core::price::fmv_of(&prices, co.date, co.principal),
1158                    action: *action,
1159                    ambiguous,
1160                    txid_match: *txid_match,
1161                }
1162            })
1163            .collect();
1164        // Deterministic order (NFR4): by out date, then the two ids.
1165        proposals.sort_by(|a, b| {
1166            a.out_date
1167                .cmp(&b.out_date)
1168                .then(a.out_event.cmp(&b.out_event))
1169                .then(a.in_event.cmp(&b.in_event))
1170        });
1171        Ok(proposals)
1172    }
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::*;
1178    use btctax_store::Passphrase;
1179
1180    fn pp() -> Passphrase {
1181        Passphrase::new("test-pass".into())
1182    }
1183
1184    #[test]
1185    fn create_then_open_round_trips_over_a_temp_vault() {
1186        let dir = tempfile::tempdir().unwrap();
1187        let vault = dir.path().join("vault.pgp");
1188        {
1189            let _s = Session::create(&vault, &pp()).unwrap(); // schema + config table initialized + saved
1190        }
1191        // Re-open with the same passphrase: an empty ledger projects cleanly.
1192        let s = Session::open(&vault, &pp()).unwrap();
1193        let (state, _cfg) = s.project().unwrap();
1194        assert!(state.lots.is_empty());
1195        assert!(state.blockers.is_empty());
1196    }
1197
1198    /// `Session::snapshot`/`restore` delegate to the vault and revert an in-memory mutation
1199    /// without touching disk (the wrapper the persist layer uses for save-rollback).
1200    #[test]
1201    fn session_snapshot_restore_reverts_in_memory_mutation() {
1202        let dir = tempfile::tempdir().unwrap();
1203        let vault = dir.path().join("vault.pgp");
1204        let mut s = Session::create(&vault, &pp()).unwrap();
1205        s.conn().execute("CREATE TABLE t (x INTEGER)", []).unwrap();
1206        s.save().unwrap();
1207
1208        let snap = s.snapshot().unwrap();
1209        s.conn().execute("INSERT INTO t VALUES (7)", []).unwrap();
1210        let n: i64 = s
1211            .conn()
1212            .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
1213            .unwrap();
1214        assert_eq!(n, 1, "pre-restore: the inserted row is present in memory");
1215
1216        let before = std::fs::read(&vault).unwrap();
1217        s.restore(&snap).unwrap();
1218        let after = std::fs::read(&vault).unwrap();
1219        assert_eq!(before, after, "restore must not write the vault file");
1220
1221        let n: i64 = s
1222            .conn()
1223            .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
1224            .unwrap();
1225        assert_eq!(n, 0, "restore must revert the in-memory insert");
1226    }
1227
1228    #[test]
1229    fn wrong_passphrase_is_surfaced_not_a_panic() {
1230        let dir = tempfile::tempdir().unwrap();
1231        let vault = dir.path().join("vault.pgp");
1232        Session::create(&vault, &pp()).unwrap();
1233        let err = Session::open(&vault, &Passphrase::new("nope".into())).unwrap_err();
1234        assert!(matches!(
1235            err,
1236            CliError::Store(btctax_store::StoreError::WrongPassphrase)
1237        ));
1238    }
1239
1240    /// `load_events_and_project` must return the same (events, state, config) triple as calling
1241    /// `load_all` + `project` separately. Verifies the single-load contract (no double DB round-trip).
1242    #[test]
1243    fn load_events_and_project_matches_separate_calls() {
1244        let dir = tempfile::tempdir().unwrap();
1245        let vault = dir.path().join("vault.pgp");
1246        Session::create(&vault, &pp()).unwrap();
1247        let s = Session::open(&vault, &pp()).unwrap();
1248
1249        let (events, state, cfg) = s.load_events_and_project().unwrap();
1250
1251        // Reference path: separate load_all + project calls.
1252        let events2 = btctax_core::persistence::load_all(s.conn()).unwrap();
1253        let (state2, cfg2) = s.project().unwrap();
1254
1255        assert_eq!(events.len(), events2.len(), "event count must match");
1256        assert_eq!(state.lots.len(), state2.lots.len(), "lots count must match");
1257        assert_eq!(
1258            state.blockers.len(),
1259            state2.blockers.len(),
1260            "blocker count must match"
1261        );
1262        assert_eq!(cfg, cfg2, "ProjectionConfig must match");
1263    }
1264
1265    /// `Session::optimize_proposal` recomputes the Mode-1 proposal on the HELD session (READ-ONLY,
1266    /// no second open). For a 2025 year with two same-wallet lots and a 500k sale, the FIFO baseline
1267    /// consumes the cheaper lot A (higher gain); the optimizer prefers the dearer lot B → a
1268    /// per-disposal row whose proposed_selection differs from current_selection, with `delta ≤ 0`.
1269    #[test]
1270    fn optimize_proposal_recomputes_a_persistable_proposal_on_held_session() {
1271        use btctax_core::event::{
1272            Acquire, BasisSource, DisposeKind, EventPayload, LedgerEvent, OutflowClass,
1273            ReclassifyOutflow, TransferOut,
1274        };
1275        use btctax_core::identity::{Source, SourceRef};
1276        use btctax_core::persistence::{append_decision, append_import_batch};
1277        use btctax_core::{Carryforward, EventId, FilingStatus, TaxProfile, WalletId};
1278        use rust_decimal_macros::dec;
1279        use time::{OffsetDateTime, UtcOffset};
1280
1281        let dir = tempfile::tempdir().unwrap();
1282        let vault = dir.path().join("vault.pgp");
1283        Session::create(&vault, &pp()).unwrap();
1284        let mut s = Session::open(&vault, &pp()).unwrap();
1285
1286        let wallet = Some(WalletId::Exchange {
1287            provider: "River".to_string(),
1288            account: "main".to_string(),
1289        });
1290        let lot_a = EventId::import(Source::River, SourceRef::new("op-lot-a"));
1291        let lot_b = EventId::import(Source::River, SourceRef::new("op-lot-b"));
1292        let to_id = EventId::import(Source::River, SourceRef::new("op-sell"));
1293        let ta = OffsetDateTime::from_unix_timestamp(1_739_000_000).unwrap();
1294        let tb = OffsetDateTime::from_unix_timestamp(1_741_000_000).unwrap();
1295        let tc = OffsetDateTime::from_unix_timestamp(1_748_000_000).unwrap();
1296        let td = OffsetDateTime::from_unix_timestamp(1_748_100_000).unwrap();
1297        let batch = vec![
1298            LedgerEvent {
1299                id: lot_a.clone(),
1300                utc_timestamp: ta,
1301                original_tz: UtcOffset::UTC,
1302                wallet: wallet.clone(),
1303                payload: EventPayload::Acquire(Acquire {
1304                    sat: 1_000_000,
1305                    usd_cost: dec!(30000),
1306                    fee_usd: dec!(0),
1307                    basis_source: BasisSource::ExchangeProvided,
1308                }),
1309            },
1310            LedgerEvent {
1311                id: lot_b.clone(),
1312                utc_timestamp: tb,
1313                original_tz: UtcOffset::UTC,
1314                wallet: wallet.clone(),
1315                payload: EventPayload::Acquire(Acquire {
1316                    sat: 1_000_000,
1317                    usd_cost: dec!(50000),
1318                    fee_usd: dec!(0),
1319                    basis_source: BasisSource::ExchangeProvided,
1320                }),
1321            },
1322            LedgerEvent {
1323                id: to_id.clone(),
1324                utc_timestamp: tc,
1325                original_tz: UtcOffset::UTC,
1326                wallet: wallet.clone(),
1327                payload: EventPayload::TransferOut(TransferOut {
1328                    sat: 500_000,
1329                    fee_sat: None,
1330                    dest_addr: None,
1331                    txid: None,
1332                }),
1333            },
1334        ];
1335        append_import_batch(s.conn(), &batch).unwrap();
1336        let ro = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
1337            transfer_out_event: to_id.clone(),
1338            as_: OutflowClass::Dispose {
1339                kind: DisposeKind::Sell,
1340            },
1341            principal_proceeds_or_fmv: dec!(30000),
1342            fee_usd: None,
1343            donee: None,
1344        });
1345        append_decision(s.conn(), ro, td, UtcOffset::UTC, None).unwrap();
1346        let profile = TaxProfile {
1347            filing_status: FilingStatus::Single,
1348            ordinary_taxable_income: dec!(100000),
1349            magi_excluding_crypto: dec!(100000),
1350            qualified_dividends_and_other_pref_income: dec!(0),
1351            other_net_capital_gain: dec!(0),
1352            capital_loss_carryforward_in: Carryforward::default(),
1353            w2_ss_wages: dec!(0),
1354            w2_medicare_wages: dec!(0),
1355            schedule_c_expenses: dec!(0),
1356        };
1357        crate::tax_profile::set(s.conn(), 2025, &profile).unwrap();
1358        s.save().unwrap();
1359
1360        let now = OffsetDateTime::from_unix_timestamp(1_752_000_000).unwrap();
1361        let proposal = s.optimize_proposal(2025, now).unwrap();
1362        assert!(
1363            proposal.delta <= dec!(0),
1364            "delta must be ≤ 0 (baseline-seeded)"
1365        );
1366        let row = proposal
1367            .per_disposal
1368            .iter()
1369            .find(|d| d.disposal == to_id)
1370            .expect("the 2025 sale must be in the proposal");
1371        assert_ne!(
1372            row.proposed_selection, row.current_selection,
1373            "the optimizer must propose the dearer lot (a change from FIFO)"
1374        );
1375    }
1376}