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