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::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, PriceProvider, 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 /// The active price provider (§9.2 bundled daily-close dataset, cache-layered in Part C). The
332 /// projection and every priced plan read prices through THIS instance seam [R0-C1/r2 I-A] rather
333 /// than a hard-wired `BundledPrices::load()`, so tests inject a CONTROLLED synthetic dataset
334 /// (`set_prices`) — decoupling their asserted FMVs from the shipped data. Pure/deterministic; the
335 /// default is the layered bundled+cache provider (resolved at open time).
336 prices: Box<dyn PriceProvider>,
337}
338
339impl std::fmt::Debug for Session {
340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 f.debug_struct("Session").finish_non_exhaustive()
342 }
343}
344
345/// Build the DEFAULT price provider a freshly-opened `Session` carries: the bundled daily-close dataset
346/// (§9.2) with the local price cache layered OVER it (Part C — cache-over-bundled; cache absent ⇒
347/// byte-identical to bundled-only). Pure/deterministic; NO network — the cache is a documented LOCAL
348/// INPUT populated only by the separate `btctax-update-prices` binary. The cache PATH is resolved HERE
349/// (btctax-cli, via `dirs`), NOT in btctax-adapters.
350fn default_prices() -> Result<Box<dyn PriceProvider>, CliError> {
351 let cache_path = crate::price_cache::default_cache_path();
352 Ok(Box::new(btctax_adapters::LayeredPrices::load_with_cache(
353 cache_path.as_deref(),
354 )?))
355}
356
357impl Session {
358 /// Create a brand-new encrypted vault, then initialize the core event schema and the CLI config
359 /// table, and persist. (`Vault::create` already saved once; we re-save after the DDL.)
360 pub fn create(vault_path: &Path, pp: &Passphrase) -> Result<Session, CliError> {
361 Self::from_fresh_vault(Vault::create(vault_path, pp)?)
362 }
363
364 /// Like `create`, but first clears a half-created vault (orphan key, no pgp/bak) under
365 /// explicit `--repair` consent. Delegates to `Vault::repair` which refuses if a real or
366 /// recoverable vault is present (see `Vault::repair` safety invariant).
367 pub fn repair(vault_path: &Path, pp: &Passphrase) -> Result<Session, CliError> {
368 Self::from_fresh_vault(Vault::repair(vault_path, pp)?)
369 }
370
371 /// Initialize the core schema + CLI config + tax profile table on a freshly-created vault,
372 /// then persist.
373 fn from_fresh_vault(mut vault: Vault) -> Result<Session, CliError> {
374 init_schema(vault.conn())?;
375 config::init_config_table(vault.conn())?;
376 tax_profile::init_table(vault.conn())?;
377 optimize_attest::init_table(vault.conn())?;
378 donation_details::init_table(vault.conn())?;
379 bulk_estimated::init_table(vault.conn())?;
380 vault.save()?;
381 Ok(Session {
382 vault,
383 prices: default_prices()?,
384 })
385 }
386
387 /// Open an existing vault (acquires the store single-instance lock; NFR7).
388 pub fn open(vault_path: &Path, pp: &Passphrase) -> Result<Session, CliError> {
389 Ok(Session {
390 vault: Vault::open(vault_path, pp)?,
391 prices: default_prices()?,
392 })
393 }
394
395 /// Borrow the active price provider (§9.2). The projection and every priced plan read prices
396 /// through this instance seam [R0-C1].
397 pub fn prices(&self) -> &dyn PriceProvider {
398 self.prices.as_ref()
399 }
400
401 /// Test / advanced seam [R0-C1/r2 I-A]: replace the price provider on an OPEN session. A KAT injects
402 /// a CONTROLLED synthetic `BundledPrices::from_csv_str(...)` so its asserted FMVs are independent of
403 /// the shipped dataset; a caller may also inject a pre-resolved layered/cache provider. Pure; no I/O.
404 pub fn set_prices(&mut self, prices: Box<dyn PriceProvider>) {
405 self.prices = prices;
406 }
407
408 /// Borrow the live in-memory SQLite handle (core appenders use interior mutability over `&Connection`).
409 pub fn conn(&self) -> &Connection {
410 self.vault.conn()
411 }
412
413 /// Persist the current DB image (encrypted, atomic; NFR2/NFR3).
414 pub fn save(&mut self) -> Result<(), CliError> {
415 self.vault.save()?;
416 Ok(())
417 }
418
419 /// Snapshot the in-memory DB image (no disk I/O) for a possible `restore()` after a failed save.
420 pub fn snapshot(&self) -> Result<Vec<u8>, CliError> {
421 Ok(self.vault.snapshot()?)
422 }
423
424 /// Restore the in-memory DB from a prior `snapshot()` (no disk I/O). On `Err`, the in-memory DB
425 /// is UNCHANGED and unsaved residue may still be live — the caller MUST latch, never swallow.
426 pub fn restore(&mut self, image: &[u8]) -> Result<(), CliError> {
427 self.vault.restore(image)?;
428 Ok(())
429 }
430
431 /// Borrow the vault for store-level operations (`export_snapshot` / `backup_key`).
432 pub fn vault(&self) -> &Vault {
433 &self.vault
434 }
435
436 /// The persisted projection config (TP8 treatment + lot method); default = (c)+FIFO if unset.
437 pub fn config(&self) -> Result<CliConfig, CliError> {
438 config::read_config(self.conn())
439 }
440
441 /// The stored per-year `TaxProfile` for `year`, or `None` if none has been set.
442 /// Robust to older vaults (calls `tax_profile::init_table` as a defensive guard).
443 pub fn tax_profile(&self, year: i32) -> Result<Option<TaxProfile>, CliError> {
444 tax_profile::get(self.conn(), year)
445 }
446
447 /// All stored `TaxProfile`s, sorted by year ascending.
448 pub fn all_tax_profiles(
449 &self,
450 ) -> Result<std::collections::BTreeMap<i32, TaxProfile>, CliError> {
451 tax_profile::all(self.conn())
452 }
453
454 /// All attested disposal `EventId`s (NFR4-stable `BTreeSet`; feeds `compliance_overlay`).
455 /// Robust to older vaults (defensive `init_table` guard inside `attested_set`).
456 pub fn optimize_attested_set(
457 &self,
458 ) -> Result<std::collections::BTreeSet<btctax_core::EventId>, CliError> {
459 optimize_attest::attested_set(self.conn())
460 }
461
462 /// All stored `DonationDetails`, keyed by donation `EventId` (NFR4-stable `BTreeMap`).
463 /// Robust to older vaults (defensive `init_table` guard inside `donation_details::all`).
464 pub fn donation_details(
465 &self,
466 ) -> Result<std::collections::BTreeMap<EventId, DonationDetails>, CliError> {
467 donation_details::all(self.conn())
468 }
469
470 /// All disposals flagged as estimated-FMV proceeds by the bulk-reclassify-outflow path, keyed by
471 /// the `transfer_out_event` (== `Disposal.event`); value = the `date_marked` provenance stamp
472 /// (NFR4-stable `BTreeMap`). Robust to older vaults (defensive `init_table` guard inside
473 /// `bulk_estimated::all`). `build_snapshot` loads the `[est]` marker set via THIS accessor,
474 /// NEVER `conn()` directly [R0-M1].
475 pub fn bulk_estimated(&self) -> Result<std::collections::BTreeMap<EventId, String>, CliError> {
476 bulk_estimated::all(self.conn())
477 }
478
479 /// Load all events and run the pure deterministic projection (NFR4) over the bundled daily-close
480 /// dataset (§9.2). Returns the resolved `ProjectionConfig` too (so `verify` can display it).
481 pub fn project(&self) -> Result<(LedgerState, ProjectionConfig), CliError> {
482 let events = load_all(self.conn())?;
483 let cfg = self.config()?.to_projection();
484 let prices = self.prices();
485 let state = project(&events, prices, &cfg);
486 Ok((state, cfg))
487 }
488
489 /// Single-load variant: loads events ONCE and returns them alongside the projection. Callers
490 /// that need both the raw event log and the projected state (e.g. `verify`, `safe_harbor_attest`)
491 /// use this to avoid the double `load_all` call that the `project()` + separate `load_all()`
492 /// pattern incurs.
493 pub fn load_events_and_project(
494 &self,
495 ) -> Result<(Vec<LedgerEvent>, LedgerState, ProjectionConfig), CliError> {
496 let events = load_all(self.conn())?;
497 let cfg = self.config()?.to_projection();
498 let prices = self.prices();
499 let state = project(&events, prices, &cfg);
500 Ok((events, state, cfg))
501 }
502
503 /// §A.5(a): the distinct Exchange accounts in the vault, each with its currently-in-force
504 /// cost-basis method and whether that method is an explicit per-account election (`true`) vs
505 /// inherited from a global election / FIFO default (`false`), as of `date`. Feeds the
506 /// btctax-tui-edit method-election flow's account list. Uses the SHARED resolver via
507 /// `btctax_core::in_force_methods` (the sole precedence path). Sorted by `WalletId: Ord`.
508 pub fn exchange_method_election_rows(
509 &self,
510 date: TaxDate,
511 ) -> Result<Vec<(WalletId, LotMethod, bool)>, CliError> {
512 let events = load_all(self.conn())?;
513 let cfg = self.config()?.to_projection();
514 let prices = self.prices();
515 // Distinct Exchange wallets (BTreeSet dedups AND sorts — NFR4).
516 let wallets: Vec<WalletId> = events
517 .iter()
518 .filter_map(|e| e.wallet.clone())
519 .filter(|w| matches!(w, WalletId::Exchange { .. }))
520 .collect::<std::collections::BTreeSet<_>>()
521 .into_iter()
522 .collect();
523 let methods = btctax_core::in_force_methods(&events, prices, &cfg, date, &wallets);
524 Ok(wallets
525 .into_iter()
526 .zip(methods)
527 .map(|(w, m)| (w, m.method, m.scoped))
528 .collect())
529 }
530
531 /// Recompute the Mode-1 optimizer proposal for `year` on the HELD session. READ-ONLY: appends and
532 /// persists NOTHING (a clone-fold-discard recompute).
533 ///
534 /// The TUI editor's optimize-accept opener calls this to obtain a FRESH proposal (NFR4 — never
535 /// trusts a stale one) WITHOUT opening a second `Session` (a second open would deadlock on the
536 /// held VaultLock, and `cmd::optimize::accept` is forbidden to the editor for the same reason).
537 /// Assembles `optimize_year`'s inputs exactly as `cmd::optimize::run`/`accept` do — events + config
538 /// from this conn, bundled prices + tables, a FRESH `tax_profile(year)` read (not the cached snap),
539 /// the attested set, and `proposal_made = tax_date(now, UtcOffset::UTC)` — and maps `OptimizeError`
540 /// through the crate-internal `map_opt_err` (which is `pub(crate)` and not TUI-reachable). `now`
541 /// is injected by the caller for determinism.
542 pub fn optimize_proposal(
543 &self,
544 year: i32,
545 now: time::OffsetDateTime,
546 ) -> Result<btctax_core::OptimizeProposal, CliError> {
547 let (events, _state, cfg) = self.load_events_and_project()?;
548 let profile = self.tax_profile(year)?;
549 let prices = self.prices();
550 let tables = BundledTaxTables::load();
551 let attested = self.optimize_attested_set()?;
552 let proposal_made = tax_date(now, time::UtcOffset::UTC);
553 btctax_core::optimize_year(
554 &events,
555 prices,
556 &cfg,
557 year,
558 profile.as_ref(),
559 &tables,
560 &attested,
561 proposal_made,
562 )
563 .map_err(crate::cmd::optimize::map_opt_err)
564 }
565
566 /// READ-ONLY: the 2025-01-01 pre-2025 Universal residue as `AllocLot`s, plus the `pre2025_method`
567 /// (`LotMethod`) it was computed under. Appends/persists NOTHING. The single source of the pre-2025
568 /// subset, shared by `cmd::reconcile::safe_harbor_allocate` and the TUI allocate opener.
569 ///
570 /// Reads the config ONCE: `cfg.pre2025_method` is the recorded method returned to the caller, and
571 /// `cfg.to_projection()` is the projection the residue is computed under — the two are STRUCTURALLY
572 /// the same config read, so the returned method can never diverge from the residue's [R0-M1]. The
573 /// pre-2025 subset keeps only imports whose tax-date `< 2025-01-01` plus ALL reconciliation decisions
574 /// (which shape the residue), and DROPs any prior `SafeHarborAllocation` so the residue stays
575 /// allocation-INDEPENDENT (matches `transition::universal_snapshot`).
576 pub fn safe_harbor_residue(&self) -> Result<(Vec<AllocLot>, LotMethod), CliError> {
577 let cfg = self.config()?;
578 let pre2025_method = cfg.pre2025_method; // recorded field == the one used below
579 let proj = cfg.to_projection();
580 let pre2025: Vec<LedgerEvent> = load_all(self.conn())?
581 .into_iter()
582 .filter(|e| match &e.id {
583 EventId::Import { .. } => {
584 tax_date(e.utc_timestamp, e.original_tz) < TRANSITION_DATE
585 }
586 _ => !matches!(e.payload, EventPayload::SafeHarborAllocation(_)),
587 })
588 .collect();
589 let prices = self.prices();
590 let residue = project(&pre2025, prices, &proj);
591 let lots = residue
592 .lots
593 .iter()
594 .filter(|l| l.remaining_sat > 0)
595 .map(|l| AllocLot {
596 wallet: l.wallet.clone(),
597 sat: l.remaining_sat,
598 usd_basis: l.usd_basis,
599 acquired_at: l.acquired_at,
600 dual_loss_basis: l.dual_loss_basis,
601 donor_acquired_at: l.donor_acquired_at,
602 })
603 .collect();
604 Ok((lots, pre2025_method))
605 }
606
607 /// READ-ONLY: compute the bulk link-transfer plan (bulk-link-transfer D1). Selects over the
608 /// PROJECTED `pending_reconciliation` (which already excludes already-decided / already-linked
609 /// outs), enriches each with date / source wallet / principal / advisory USD value / carried
610 /// basis, applies the frame + `from_wallet` filters, and routes `source == dest` rows to
611 /// `skipped_same_wallet`. Appends and persists NOTHING; mirrors `safe_harbor_residue`.
612 ///
613 /// The USD value is `btctax_core::price::fmv_of(prices, date, principal_sat)` [R0-M1] — the
614 /// vetted checked helper (round_cents + overflow→`None`), NOT a hand-rolled `principal × price`.
615 /// The total is the HONEST FLOOR [R0-I2]: `total_usd_value_floor` is Σ of the PRICED rows only,
616 /// and `missing_price_count` records how many rows lacked a price, so the caller renders exact
617 /// `$X` (when 0) or `≥ $X (N unavailable)`.
618 pub fn bulk_link_transfer_plan(
619 &self,
620 filter: BulkFilter,
621 dest: WalletId,
622 ) -> Result<BulkLinkPlan, CliError> {
623 let (events, state, _cfg) = self.load_events_and_project()?;
624 let prices = self.prices();
625 let index: std::collections::HashMap<EventId, &LedgerEvent> =
626 events.iter().map(|e| (e.id.clone(), e)).collect();
627
628 let enrich = |pt: &PendingTransfer| -> BulkLinkRow {
629 let ev = index.get(&pt.event).copied();
630 let date = ev
631 .map(|e| tax_date(e.utc_timestamp, e.original_tz))
632 .unwrap_or_else(|| {
633 // Defensive: a pending out always has an indexed source event; fall back to the
634 // epoch date rather than panic (mirrors the single link-transfer opener).
635 tax_date(
636 time::OffsetDateTime::from_unix_timestamp(0).unwrap(),
637 time::UtcOffset::UTC,
638 )
639 });
640 let source_wallet = ev.and_then(|e| e.wallet.clone());
641 let usd_value = btctax_core::price::fmv_of(prices, date, pt.principal_sat);
642 let basis_usd: Usd = pt.legs.iter().map(|l| l.usd_basis).sum();
643 BulkLinkRow {
644 out_event: pt.event.clone(),
645 date,
646 source_wallet,
647 principal_sat: pt.principal_sat,
648 usd_value,
649 basis_usd,
650 }
651 };
652
653 let in_frame = |date: TaxDate| match &filter.frame {
654 Frame::All => true,
655 Frame::Year(y) => date.year() == *y,
656 Frame::Range { from, to } => *from <= date && date <= *to,
657 };
658
659 let mut included: Vec<BulkLinkRow> = Vec::new();
660 let mut skipped_same_wallet: Vec<BulkLinkRow> = Vec::new();
661 for pt in &state.pending_reconciliation {
662 let row = enrich(pt);
663 if !in_frame(row.date) {
664 continue;
665 }
666 if let Some(w) = &filter.from_wallet {
667 if row.source_wallet.as_ref() != Some(w) {
668 continue;
669 }
670 }
671 // Same-wallet guard: a self-link to the SAME wallet is meaningless — report, never link.
672 if row.source_wallet.as_ref() == Some(&dest) {
673 skipped_same_wallet.push(row);
674 } else {
675 included.push(row);
676 }
677 }
678 included.sort_by_key(|r| r.date);
679
680 let total_sat: Sat = included.iter().map(|r| r.principal_sat).sum();
681 let total_usd_value_floor: Usd = included.iter().filter_map(|r| r.usd_value).sum();
682 let missing_price_count = included.iter().filter(|r| r.usd_value.is_none()).count();
683 let total_basis_usd: Usd = included.iter().map(|r| r.basis_usd).sum();
684
685 Ok(BulkLinkPlan {
686 dest,
687 included,
688 skipped_same_wallet,
689 total_sat,
690 total_usd_value_floor,
691 missing_price_count,
692 total_basis_usd,
693 })
694 }
695
696 /// READ-ONLY: compute the bulk classify-inbound-self-transfer plan
697 /// (bulk-classify-inbound-self-transfer D1). A close MIRROR of `bulk_link_transfer_plan` applied to
698 /// Cycle A's inbound `SelfTransferMine` ($0 conservative basis, non-taxable). Appends/persists
699 /// NOTHING (clone-fold-discard recompute); KAT-G1-clean at the TUI call site.
700 ///
701 /// **Selection (structural false-classify safety) [R0-I1]:** candidates are `TransferIn` events
702 /// still flagged `UnknownBasisInbound` (blocker set joined to the raw event via the index, as
703 /// `self_transfer_match_plan` does) **MINUS** any already targeted by a NON-VOIDED `ClassifyInbound`
704 /// (mirror `open_classify_inbound_flow`'s filter 3 — appending a second fires a return-blocking Hard
705 /// `DecisionConflict`; `UnknownBasisInbound` is RE-EMITTED for gift-basis-unknown states, so
706 /// "flagged" ≠ "unclassified") **MINUS** wallet-less inbounds [R0-M2] (create no lot). The USD is
707 /// `fmv_of` [G4]; the total is the HONEST FLOOR (`total_usd_fmv_floor` + `missing_price_count`).
708 pub fn bulk_self_transfer_in_plan(
709 &self,
710 filter: BulkStiFilter,
711 ) -> Result<BulkStiPlan, CliError> {
712 let (events, state, _cfg) = self.load_events_and_project()?;
713 let prices = self.prices();
714 let index: std::collections::HashMap<EventId, &LedgerEvent> =
715 events.iter().map(|e| (e.id.clone(), e)).collect();
716
717 // [R0-I1] filter-3, mirroring `open_classify_inbound_flow`: the set of TransferIn event ids
718 // already targeted by a NON-VOIDED `ClassifyInbound`. Build the voided-decision-id set first,
719 // then keep only ClassifyInbounds whose OWN id is not voided [R0-M-r2-1: decision-id space and
720 // TransferIn-id space are disjoint; we intersect a decision's own id against `voided`, and map
721 // the survivor to its `transfer_in_event`].
722 let voided: std::collections::BTreeSet<EventId> = events
723 .iter()
724 .filter_map(|e| match &e.payload {
725 EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
726 _ => None,
727 })
728 .collect();
729 let already_classified: std::collections::BTreeSet<EventId> = events
730 .iter()
731 .filter(|e| !voided.contains(&e.id))
732 .filter_map(|e| match &e.payload {
733 EventPayload::ClassifyInbound(ci) => Some(ci.transfer_in_event.clone()),
734 _ => None,
735 })
736 .collect();
737
738 let in_frame = |date: TaxDate| match &filter.frame {
739 Frame::All => true,
740 Frame::Year(y) => date.year() == *y,
741 Frame::Range { from, to } => *from <= date && date <= *to,
742 };
743
744 let mut included: Vec<BulkStiRow> = Vec::new();
745 for b in &state.blockers {
746 if b.kind != BlockerKind::UnknownBasisInbound {
747 continue;
748 }
749 let Some(id) = &b.event else { continue };
750 // [R0-I1] EXCLUDE any inbound that already carries a live ClassifyInbound (else the bulk
751 // append duplicates it → Hard DecisionConflict blocks compute_tax_year).
752 if already_classified.contains(id) {
753 continue;
754 }
755 let Some(ev) = index.get(id) else { continue };
756 let EventPayload::TransferIn(ti) = &ev.payload else {
757 continue;
758 };
759 // [R0-M2] EXCLUDE wallet-less inbounds — a self-transfer-in creates no lot and re-fires
760 // the blocker (matcher skips them too).
761 let Some(wallet) = ev.wallet.clone() else {
762 continue;
763 };
764 let date = tax_date(ev.utc_timestamp, ev.original_tz);
765 if !in_frame(date) {
766 continue;
767 }
768 if let Some(w) = &filter.wallet {
769 if &wallet != w {
770 continue;
771 }
772 }
773 let sat = ti.sat;
774 let usd_fmv = btctax_core::price::fmv_of(prices, date, sat);
775 included.push(BulkStiRow {
776 in_event: id.clone(),
777 date,
778 wallet: Some(wallet),
779 sat,
780 usd_fmv,
781 });
782 }
783 included.sort_by_key(|r| r.date);
784
785 let total_sat: Sat = included.iter().map(|r| r.sat).sum();
786 let total_usd_fmv_floor: Usd = included.iter().filter_map(|r| r.usd_fmv).sum();
787 let missing_price_count = included.iter().filter(|r| r.usd_fmv.is_none()).count();
788
789 Ok(BulkStiPlan {
790 included,
791 total_sat,
792 total_usd_fmv_floor,
793 missing_price_count,
794 })
795 }
796
797 /// READ-ONLY: compute the bulk classify-inbound-income plan (bulk-classify-inbound-income, Cycle 4).
798 /// A NEAR-CLONE of `bulk_self_transfer_in_plan`: candidates are `TransferIn`s still flagged
799 /// `UnknownBasisInbound` MINUS any already targeted by a NON-VOIDED `ClassifyInbound` (filter-3;
800 /// a second `ClassifyInbound` fires a Hard `DecisionConflict`) MINUS wallet-less inbounds (create no
801 /// lot; also a Hard-`FmvMissing` vector). Then the **Cycle-4 tax-safety difference [#a]**: any
802 /// candidate whose `fmv_of(date, sat)` is `None` (missing daily-close price OR overflow) is EXCLUDED
803 /// from `included` and counted in `excluded_missing_price` — a persisted `Income{fmv:None}` projects
804 /// to a Hard `FmvMissing` year-gate (NOT clearable by `ManualFmv` on the inbound path). `included`
805 /// therefore carries a RESOLVED `fmv: Usd` (non-Option). Appends/persists NOTHING.
806 pub fn bulk_classify_income_plan(
807 &self,
808 filter: BulkIncomeFilter,
809 ) -> Result<BulkIncomePlan, CliError> {
810 let (events, state, _cfg) = self.load_events_and_project()?;
811 let prices = self.prices();
812 let index: std::collections::HashMap<EventId, &LedgerEvent> =
813 events.iter().map(|e| (e.id.clone(), e)).collect();
814
815 // filter-3, mirroring `bulk_self_transfer_in_plan`: the set of TransferIn ids already targeted
816 // by a NON-VOIDED `ClassifyInbound` (build the voided-decision-id set first, keep only
817 // ClassifyInbounds whose OWN id is not voided, map the survivor to its `transfer_in_event`).
818 let voided: std::collections::BTreeSet<EventId> = events
819 .iter()
820 .filter_map(|e| match &e.payload {
821 EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
822 _ => None,
823 })
824 .collect();
825 let already_classified: std::collections::BTreeSet<EventId> = events
826 .iter()
827 .filter(|e| !voided.contains(&e.id))
828 .filter_map(|e| match &e.payload {
829 EventPayload::ClassifyInbound(ci) => Some(ci.transfer_in_event.clone()),
830 _ => None,
831 })
832 .collect();
833
834 let in_frame = |date: TaxDate| match &filter.frame {
835 Frame::All => true,
836 Frame::Year(y) => date.year() == *y,
837 Frame::Range { from, to } => *from <= date && date <= *to,
838 };
839
840 let mut included: Vec<BulkIncomeRow> = Vec::new();
841 let mut excluded_missing_price = 0usize;
842 for b in &state.blockers {
843 if b.kind != BlockerKind::UnknownBasisInbound {
844 continue;
845 }
846 let Some(id) = &b.event else { continue };
847 // filter-3: EXCLUDE any inbound already carrying a live ClassifyInbound (a second one →
848 // Hard DecisionConflict blocks compute_tax_year).
849 if already_classified.contains(id) {
850 continue;
851 }
852 let Some(ev) = index.get(id) else { continue };
853 let EventPayload::TransferIn(ti) = &ev.payload else {
854 continue;
855 };
856 // EXCLUDE wallet-less inbounds — an income inbound without a wallet creates no lot and
857 // itself raises a Hard FmvMissing (fold.rs); the matcher skips them too.
858 let Some(wallet) = ev.wallet.clone() else {
859 continue;
860 };
861 let date = tax_date(ev.utc_timestamp, ev.original_tz);
862 if !in_frame(date) {
863 continue;
864 }
865 if let Some(w) = &filter.wallet {
866 if &wallet != w {
867 continue;
868 }
869 }
870 let sat = ti.sat;
871 // [#a tax-safety — the whole cycle] a candidate with no price is EXCLUDED (counted), NEVER
872 // classified as income: an Income{fmv:None} would trade UnknownBasisInbound for a Hard
873 // FmvMissing year-gate. bulk-sti INCLUDES these rows ($0-basis needs no FMV); bulk-income
874 // must NOT. `included` carries the resolved non-Option fmv.
875 match btctax_core::price::fmv_of(prices, date, sat) {
876 Some(fmv) => included.push(BulkIncomeRow {
877 in_event: id.clone(),
878 date,
879 sat,
880 fmv,
881 }),
882 None => excluded_missing_price += 1,
883 }
884 }
885 included.sort_by_key(|r| r.date);
886
887 let total_sat: Sat = included.iter().map(|r| r.sat).sum();
888 let total_income_usd: Usd = included.iter().map(|r| r.fmv).sum();
889
890 Ok(BulkIncomePlan {
891 included,
892 excluded_missing_price,
893 total_sat,
894 total_income_usd,
895 })
896 }
897
898 /// READ-ONLY: compute the bulk reclassify-outflow plan (bulk-reclassify-outflow, Cycle 5). Selects
899 /// over the PROJECTED `pending_reconciliation` (which already excludes already-reclassified / linked
900 /// outs, and never contains wallet-less outflows), enriches each with date / source wallet /
901 /// principal / RESOLVED auto-FMV / carried basis / estimated gain, and applies the frame +
902 /// `from_wallet` filters. Appends/persists NOTHING (clone-fold-discard recompute); mirrors
903 /// `bulk_link_transfer_plan` + the `bulk_classify_income_plan` `#a` missing-price exclusion.
904 ///
905 /// **[#a tax-safety — the whole cycle]** a candidate with no price is EXCLUDED (counted in
906 /// `excluded_missing_price`), NEVER reclassified: `ReclassifyOutflow.principal_proceeds_or_fmv` is
907 /// `Usd` (NOT Option), so the row could only be built by FABRICATING a `0`/bogus proceeds — a SILENT
908 /// misreport (unlike bulk-income's LOUD `FmvMissing`). `included` therefore carries a RESOLVED
909 /// `fmv: Usd` (non-Option), making a fabricated-proceeds Sell structurally unrepresentable here.
910 ///
911 /// **Estimated gain [Q3]:** `basis_usd = Σ pt.legs.usd_basis` — computed by the fold's SINGLE
912 /// chronological pass with ALL candidate PendingOuts pending, so `Σ` over multiple rows is NEVER
913 /// double-counted (an earlier-dated out drew the pool down before a later one folded). `estimated_gain
914 /// = round_cents(fmv − basis_usd)` per row. The persisted Form-8949 numbers always run the ordinary
915 /// fold (exact); only this preview carries the FIFO-vs-method / fee-treatment residual (label it).
916 pub fn bulk_reclassify_outflow_plan(
917 &self,
918 filter: BulkFilter,
919 ) -> Result<BulkReclassifyOutflowPlan, CliError> {
920 let (events, state, _cfg) = self.load_events_and_project()?;
921 let prices = self.prices();
922 let index: std::collections::HashMap<EventId, &LedgerEvent> =
923 events.iter().map(|e| (e.id.clone(), e)).collect();
924
925 let in_frame = |date: TaxDate| match &filter.frame {
926 Frame::All => true,
927 Frame::Year(y) => date.year() == *y,
928 Frame::Range { from, to } => *from <= date && date <= *to,
929 };
930
931 let mut included: Vec<BulkReclassifyOutflowRow> = Vec::new();
932 let mut excluded_missing_price = 0usize;
933 for pt in &state.pending_reconciliation {
934 let ev = index.get(&pt.event).copied();
935 let date = ev
936 .map(|e| tax_date(e.utc_timestamp, e.original_tz))
937 .unwrap_or_else(|| {
938 // Defensive: a pending out always has an indexed source event; fall back to the
939 // epoch date rather than panic (mirrors `bulk_link_transfer_plan`).
940 tax_date(
941 time::OffsetDateTime::from_unix_timestamp(0).unwrap(),
942 time::UtcOffset::UTC,
943 )
944 });
945 if !in_frame(date) {
946 continue;
947 }
948 let wallet = ev.and_then(|e| e.wallet.clone());
949 if let Some(w) = &filter.from_wallet {
950 if wallet.as_ref() != Some(w) {
951 continue;
952 }
953 }
954 // [#a] EXCLUDE (count) a missing-price row — a Sell with fabricated proceeds is a SILENT
955 // misreport. `included` carries the resolved non-Option fmv.
956 let fmv = match btctax_core::price::fmv_of(prices, date, pt.principal_sat) {
957 Some(v) => v,
958 None => {
959 excluded_missing_price += 1;
960 continue;
961 }
962 };
963 let basis_usd: Usd = pt.legs.iter().map(|l| l.usd_basis).sum();
964 let estimated_gain = round_cents(fmv - basis_usd);
965 included.push(BulkReclassifyOutflowRow {
966 out_event: pt.event.clone(),
967 date,
968 wallet,
969 principal_sat: pt.principal_sat,
970 fmv,
971 basis_usd,
972 estimated_gain,
973 });
974 }
975 included.sort_by_key(|r| r.date);
976
977 let total_sat: Sat = included.iter().map(|r| r.principal_sat).sum();
978 let total_proceeds_usd: Usd = included.iter().map(|r| r.fmv).sum();
979 let total_basis_usd: Usd = included.iter().map(|r| r.basis_usd).sum();
980 let total_estimated_gain: Usd = included.iter().map(|r| r.estimated_gain).sum();
981
982 Ok(BulkReclassifyOutflowPlan {
983 included,
984 excluded_missing_price,
985 total_sat,
986 total_proceeds_usd,
987 total_basis_usd,
988 total_estimated_gain,
989 })
990 }
991
992 /// READ-ONLY: compute the bulk resolve-conflict plan (bulk-resolve-conflict D1). Candidate set =
993 /// live `ImportConflict` blockers only (engine post-filtered — an accepted/rejected conflict is no
994 /// longer flagged, so re-running never double-resolves; structural idempotence). Joins each blocker
995 /// (whose `.event` is the `ImportConflict` event id) to the event index to build a STRUCTURED row:
996 /// the conflict's `target` + `new_payload` + `new_fingerprint`, plus the `current_payload` read from
997 /// the TARGET event (a SEPARATE event — accept adopts `new_payload`, reject keeps `current_payload`).
998 /// Appends/persists NOTHING; mirrors `bulk_self_transfer_in_plan`.
999 pub fn bulk_resolve_conflict_plan(&self) -> Result<BulkResolvePlan, CliError> {
1000 let (events, state, _cfg) = self.load_events_and_project()?;
1001 let index: std::collections::HashMap<EventId, &LedgerEvent> =
1002 events.iter().map(|e| (e.id.clone(), e)).collect();
1003
1004 let mut rows: Vec<BulkResolveRow> = Vec::new();
1005 for b in &state.blockers {
1006 if b.kind != BlockerKind::ImportConflict {
1007 continue;
1008 }
1009 let Some(conflict_id) = &b.event else {
1010 continue;
1011 };
1012 let Some(conflict_ev) = index.get(conflict_id) else {
1013 continue;
1014 };
1015 let EventPayload::ImportConflict(c) = &conflict_ev.payload else {
1016 continue;
1017 };
1018 // CURRENT payload lives at the TARGET id (a separate event, conflict_event != target).
1019 let Some(target_ev) = index.get(&c.target) else {
1020 continue;
1021 };
1022 let date = tax_date(conflict_ev.utc_timestamp, conflict_ev.original_tz);
1023 rows.push(BulkResolveRow {
1024 conflict_event: conflict_id.clone(),
1025 date,
1026 target: c.target.clone(),
1027 current_payload: target_ev.payload.clone(),
1028 new_payload: (*c.new_payload).clone(),
1029 new_fingerprint: c.new_fingerprint.0.chars().take(8).collect::<String>(),
1030 });
1031 }
1032 rows.sort_by_key(|r| r.date);
1033 Ok(BulkResolvePlan { rows })
1034 }
1035
1036 /// READ-ONLY: compute the bulk-void plan (bulk-void D1). Candidate set = the SINGLE shared
1037 /// predicate `btctax_core::voidable_decisions` over the projected events + blockers (Decision-id ∧
1038 /// not-voided ∧ `is_revocable_payload` ∧ #7 `!effective_alloc`) — the ONLY defense against sweeping
1039 /// an EFFECTIVE `SafeHarborAllocation` into a Hard `DecisionConflict`. Each row precomputes its
1040 /// `disposal_to_clear` ONCE from the same event set (a `LotSelection` target → `ls.disposal_event`)
1041 /// so `apply_bulk_void`/`persist_bulk_void` never re-load the log per row. Appends/persists NOTHING;
1042 /// mirrors `bulk_resolve_conflict_plan`. Sorted by `seq` for deterministic display.
1043 pub fn bulk_void_plan(&self) -> Result<BulkVoidPlan, CliError> {
1044 let (events, state, _cfg) = self.load_events_and_project()?;
1045 let mut rows: Vec<BulkVoidRow> = btctax_core::voidable_decisions(&events, &state.blockers)
1046 .into_iter()
1047 .map(|e| {
1048 let seq = match &e.id {
1049 EventId::Decision { seq } => *seq,
1050 _ => 0,
1051 };
1052 let disposal_to_clear = match &e.payload {
1053 EventPayload::LotSelection(ls) => Some(ls.disposal_event.clone()),
1054 _ => None,
1055 };
1056 BulkVoidRow {
1057 target_event_id: e.id.clone(),
1058 seq,
1059 date: tax_date(e.utc_timestamp, e.original_tz),
1060 payload: e.payload.clone(),
1061 disposal_to_clear,
1062 }
1063 })
1064 .collect();
1065 rows.sort_by_key(|r| r.seq);
1066 Ok(BulkVoidPlan { rows })
1067 }
1068
1069 /// READ-ONLY: propose self-transfer matches (self-transfer-passthrough C2). Appends/persists NOTHING
1070 /// (a clone-fold-discard recompute, like `bulk_link_transfer_plan`). Pairs ONLY unreconciled legs —
1071 /// candidate ins are `TransferIn`s flagged `UnknownBasisInbound` [R0-M2] (enumerated from the blocker
1072 /// set + joined to the raw event via the event index), candidate outs are `pending_reconciliation`.
1073 ///
1074 /// A pair is proposed iff ALL criteria pass: amount within `tol = max(out.fee_sat, ceil(0.005 ×
1075 /// out.principal))` (a `txid` EXACT match relaxes the amount check), a ±2-day window consistent with
1076 /// the direction (passthrough: in on/before out; relocate: in on/after out), and BOTH wallets present.
1077 /// The suggested `action` is wallet topology (same-wallet ⇒ Drop, cross-tracked-wallet ⇒ Relocate).
1078 /// A leg matching >1 counterpart is flagged `ambiguous` (surfaced, NEVER auto-picked — G-FALSE-MATCH).
1079 pub fn self_transfer_match_plan(&self) -> Result<Vec<MatchProposal>, CliError> {
1080 let (events, state, _cfg) = self.load_events_and_project()?;
1081 let prices = self.prices();
1082 let index: std::collections::HashMap<EventId, &LedgerEvent> =
1083 events.iter().map(|e| (e.id.clone(), e)).collect();
1084
1085 // Candidate ins: TransferIn events still flagged UnknownBasisInbound (unreconciled), joined to the
1086 // raw event via the index (the exact pattern `bulk_link_transfer_plan` uses for pending outs).
1087 struct CandIn {
1088 id: EventId,
1089 sat: Sat,
1090 txid: Option<String>,
1091 date: TaxDate,
1092 wallet: Option<WalletId>,
1093 }
1094 let mut ins: Vec<CandIn> = Vec::new();
1095 for b in &state.blockers {
1096 if b.kind != BlockerKind::UnknownBasisInbound {
1097 continue;
1098 }
1099 let Some(id) = &b.event else { continue };
1100 let Some(ev) = index.get(id) else { continue };
1101 let EventPayload::TransferIn(ti) = &ev.payload else {
1102 continue;
1103 };
1104 ins.push(CandIn {
1105 id: id.clone(),
1106 sat: ti.sat,
1107 txid: ti.txid.clone(),
1108 date: tax_date(ev.utc_timestamp, ev.original_tz),
1109 wallet: ev.wallet.clone(),
1110 });
1111 }
1112
1113 // Candidate outs: pending_reconciliation entries (already Op::PendingOut — unreconciled).
1114 struct CandOut {
1115 id: EventId,
1116 principal: Sat,
1117 fee: Option<Sat>,
1118 txid: Option<String>,
1119 date: TaxDate,
1120 wallet: Option<WalletId>,
1121 }
1122 let mut outs: Vec<CandOut> = Vec::new();
1123 for pt in &state.pending_reconciliation {
1124 let ev = index.get(&pt.event).copied();
1125 let date = ev
1126 .map(|e| tax_date(e.utc_timestamp, e.original_tz))
1127 .unwrap_or_else(|| {
1128 tax_date(
1129 time::OffsetDateTime::from_unix_timestamp(0).unwrap(),
1130 time::UtcOffset::UTC,
1131 )
1132 });
1133 let txid = ev.and_then(|e| match &e.payload {
1134 EventPayload::TransferOut(t) => t.txid.clone(),
1135 _ => None,
1136 });
1137 outs.push(CandOut {
1138 id: pt.event.clone(),
1139 principal: pt.principal_sat,
1140 fee: pt.fee_sat,
1141 txid,
1142 date,
1143 wallet: ev.and_then(|e| e.wallet.clone()),
1144 });
1145 }
1146
1147 // Passing (in_idx, out_idx, action, txid_match) tuples.
1148 let mut passing: Vec<(usize, usize, MatchAction, bool)> = Vec::new();
1149 for (i, ci) in ins.iter().enumerate() {
1150 // A wallet-less in can be neither a same-wallet DROP nor a RELOCATE destination.
1151 let Some(in_wallet) = ci.wallet.as_ref() else {
1152 continue;
1153 };
1154 for (j, co) in outs.iter().enumerate() {
1155 let Some(out_wallet) = co.wallet.as_ref() else {
1156 continue;
1157 };
1158 let action = if in_wallet == out_wallet {
1159 MatchAction::Drop
1160 } else {
1161 MatchAction::Relocate
1162 };
1163 // Amount: tol = max(fee, ceil(0.005 × principal)). ceil(p/200) = (p + 199) / 200 (p ≥ 0).
1164 let slack = if co.principal > 0 {
1165 (co.principal + 199) / 200
1166 } else {
1167 0
1168 };
1169 let tol = co.fee.unwrap_or(0).max(slack);
1170 let txid_match = ci.txid.is_some() && ci.txid == co.txid;
1171 let amount_ok = txid_match || (ci.sat - co.principal).abs() <= tol;
1172 if !amount_ok {
1173 continue;
1174 }
1175 // ±2-day window, direction keyed to the topology (exchange timestamp drift tolerated).
1176 let window_ok = match action {
1177 // Passthrough: the deposit precedes the withdrawal (in on/before out).
1178 MatchAction::Drop => {
1179 let d = (co.date - ci.date).whole_days();
1180 (0..=2).contains(&d)
1181 }
1182 // Relocate: the withdrawal precedes the arrival (in on/after out).
1183 MatchAction::Relocate => {
1184 let d = (ci.date - co.date).whole_days();
1185 (0..=2).contains(&d)
1186 }
1187 };
1188 if !window_ok {
1189 continue;
1190 }
1191 passing.push((i, j, action, txid_match));
1192 }
1193 }
1194
1195 // Ambiguity: a leg (in OR out) appearing in >1 passing pair is flagged, never silently picked.
1196 let mut in_count: std::collections::HashMap<usize, usize> =
1197 std::collections::HashMap::new();
1198 let mut out_count: std::collections::HashMap<usize, usize> =
1199 std::collections::HashMap::new();
1200 for (i, j, _, _) in &passing {
1201 *in_count.entry(*i).or_insert(0) += 1;
1202 *out_count.entry(*j).or_insert(0) += 1;
1203 }
1204
1205 let mut proposals: Vec<MatchProposal> = passing
1206 .iter()
1207 .map(|(i, j, action, txid_match)| {
1208 let ci = &ins[*i];
1209 let co = &outs[*j];
1210 let ambiguous = in_count[i] > 1 || out_count[j] > 1;
1211 MatchProposal {
1212 in_event: ci.id.clone(),
1213 out_event: co.id.clone(),
1214 in_date: ci.date,
1215 out_date: co.date,
1216 in_wallet: ci.wallet.clone(),
1217 out_wallet: co.wallet.clone(),
1218 in_sat: ci.sat,
1219 out_principal_sat: co.principal,
1220 usd_value: btctax_core::price::fmv_of(prices, co.date, co.principal),
1221 action: *action,
1222 ambiguous,
1223 txid_match: *txid_match,
1224 }
1225 })
1226 .collect();
1227 // Deterministic order (NFR4): by out date, then the two ids.
1228 proposals.sort_by(|a, b| {
1229 a.out_date
1230 .cmp(&b.out_date)
1231 .then(a.out_event.cmp(&b.out_event))
1232 .then(a.in_event.cmp(&b.in_event))
1233 });
1234 Ok(proposals)
1235 }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use super::*;
1241 use btctax_store::Passphrase;
1242
1243 fn pp() -> Passphrase {
1244 Passphrase::new("test-pass".into())
1245 }
1246
1247 #[test]
1248 fn create_then_open_round_trips_over_a_temp_vault() {
1249 let dir = tempfile::tempdir().unwrap();
1250 let vault = dir.path().join("vault.pgp");
1251 {
1252 let _s = Session::create(&vault, &pp()).unwrap(); // schema + config table initialized + saved
1253 }
1254 // Re-open with the same passphrase: an empty ledger projects cleanly.
1255 let s = Session::open(&vault, &pp()).unwrap();
1256 let (state, _cfg) = s.project().unwrap();
1257 assert!(state.lots.is_empty());
1258 assert!(state.blockers.is_empty());
1259 }
1260
1261 /// `Session::snapshot`/`restore` delegate to the vault and revert an in-memory mutation
1262 /// without touching disk (the wrapper the persist layer uses for save-rollback).
1263 #[test]
1264 fn session_snapshot_restore_reverts_in_memory_mutation() {
1265 let dir = tempfile::tempdir().unwrap();
1266 let vault = dir.path().join("vault.pgp");
1267 let mut s = Session::create(&vault, &pp()).unwrap();
1268 s.conn().execute("CREATE TABLE t (x INTEGER)", []).unwrap();
1269 s.save().unwrap();
1270
1271 let snap = s.snapshot().unwrap();
1272 s.conn().execute("INSERT INTO t VALUES (7)", []).unwrap();
1273 let n: i64 = s
1274 .conn()
1275 .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
1276 .unwrap();
1277 assert_eq!(n, 1, "pre-restore: the inserted row is present in memory");
1278
1279 let before = std::fs::read(&vault).unwrap();
1280 s.restore(&snap).unwrap();
1281 let after = std::fs::read(&vault).unwrap();
1282 assert_eq!(before, after, "restore must not write the vault file");
1283
1284 let n: i64 = s
1285 .conn()
1286 .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
1287 .unwrap();
1288 assert_eq!(n, 0, "restore must revert the in-memory insert");
1289 }
1290
1291 #[test]
1292 fn wrong_passphrase_is_surfaced_not_a_panic() {
1293 let dir = tempfile::tempdir().unwrap();
1294 let vault = dir.path().join("vault.pgp");
1295 Session::create(&vault, &pp()).unwrap();
1296 let err = Session::open(&vault, &Passphrase::new("nope".into())).unwrap_err();
1297 assert!(matches!(
1298 err,
1299 CliError::Store(btctax_store::StoreError::WrongPassphrase)
1300 ));
1301 }
1302
1303 /// `load_events_and_project` must return the same (events, state, config) triple as calling
1304 /// `load_all` + `project` separately. Verifies the single-load contract (no double DB round-trip).
1305 #[test]
1306 fn load_events_and_project_matches_separate_calls() {
1307 let dir = tempfile::tempdir().unwrap();
1308 let vault = dir.path().join("vault.pgp");
1309 Session::create(&vault, &pp()).unwrap();
1310 let s = Session::open(&vault, &pp()).unwrap();
1311
1312 let (events, state, cfg) = s.load_events_and_project().unwrap();
1313
1314 // Reference path: separate load_all + project calls.
1315 let events2 = btctax_core::persistence::load_all(s.conn()).unwrap();
1316 let (state2, cfg2) = s.project().unwrap();
1317
1318 assert_eq!(events.len(), events2.len(), "event count must match");
1319 assert_eq!(state.lots.len(), state2.lots.len(), "lots count must match");
1320 assert_eq!(
1321 state.blockers.len(),
1322 state2.blockers.len(),
1323 "blocker count must match"
1324 );
1325 assert_eq!(cfg, cfg2, "ProjectionConfig must match");
1326 }
1327
1328 /// `Session::optimize_proposal` recomputes the Mode-1 proposal on the HELD session (READ-ONLY,
1329 /// no second open). For a 2025 year with two same-wallet lots and a 500k sale, the FIFO baseline
1330 /// consumes the cheaper lot A (higher gain); the optimizer prefers the dearer lot B → a
1331 /// per-disposal row whose proposed_selection differs from current_selection, with `delta ≤ 0`.
1332 #[test]
1333 fn optimize_proposal_recomputes_a_persistable_proposal_on_held_session() {
1334 use btctax_core::event::{
1335 Acquire, BasisSource, DisposeKind, EventPayload, LedgerEvent, MethodElection,
1336 OutflowClass, ReclassifyOutflow, TransferOut,
1337 };
1338 use btctax_core::identity::{Source, SourceRef};
1339 use btctax_core::persistence::{append_decision, append_import_batch};
1340 use btctax_core::{Carryforward, EventId, FilingStatus, LotMethod, TaxProfile, WalletId};
1341 use rust_decimal_macros::dec;
1342 use time::{OffsetDateTime, UtcOffset};
1343
1344 let dir = tempfile::tempdir().unwrap();
1345 let vault = dir.path().join("vault.pgp");
1346 Session::create(&vault, &pp()).unwrap();
1347 let mut s = Session::open(&vault, &pp()).unwrap();
1348
1349 let wallet = Some(WalletId::Exchange {
1350 provider: "River".to_string(),
1351 account: "main".to_string(),
1352 });
1353 let lot_a = EventId::import(Source::River, SourceRef::new("op-lot-a"));
1354 let lot_b = EventId::import(Source::River, SourceRef::new("op-lot-b"));
1355 let to_id = EventId::import(Source::River, SourceRef::new("op-sell"));
1356 let ta = OffsetDateTime::from_unix_timestamp(1_739_000_000).unwrap();
1357 let tb = OffsetDateTime::from_unix_timestamp(1_741_000_000).unwrap();
1358 let tc = OffsetDateTime::from_unix_timestamp(1_748_000_000).unwrap();
1359 let td = OffsetDateTime::from_unix_timestamp(1_748_100_000).unwrap();
1360 let batch = vec![
1361 LedgerEvent {
1362 id: lot_a.clone(),
1363 utc_timestamp: ta,
1364 original_tz: UtcOffset::UTC,
1365 wallet: wallet.clone(),
1366 payload: EventPayload::Acquire(Acquire {
1367 sat: 1_000_000,
1368 usd_cost: dec!(30000),
1369 fee_usd: dec!(0),
1370 basis_source: BasisSource::ExchangeProvided,
1371 }),
1372 },
1373 LedgerEvent {
1374 id: lot_b.clone(),
1375 utc_timestamp: tb,
1376 original_tz: UtcOffset::UTC,
1377 wallet: wallet.clone(),
1378 payload: EventPayload::Acquire(Acquire {
1379 sat: 1_000_000,
1380 usd_cost: dec!(50000),
1381 fee_usd: dec!(0),
1382 basis_source: BasisSource::ExchangeProvided,
1383 }),
1384 },
1385 LedgerEvent {
1386 id: to_id.clone(),
1387 utc_timestamp: tc,
1388 original_tz: UtcOffset::UTC,
1389 wallet: wallet.clone(),
1390 payload: EventPayload::TransferOut(TransferOut {
1391 sat: 500_000,
1392 fee_sat: None,
1393 dest_addr: None,
1394 txid: None,
1395 }),
1396 },
1397 ];
1398 append_import_batch(s.conn(), &batch).unwrap();
1399 let ro = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
1400 transfer_out_event: to_id.clone(),
1401 as_: OutflowClass::Dispose {
1402 kind: DisposeKind::Sell,
1403 },
1404 principal_proceeds_or_fmv: dec!(30000),
1405 fee_usd: None,
1406 donee: None,
1407 });
1408 append_decision(s.conn(), ro, td, UtcOffset::UTC, None).unwrap();
1409 // [reconcile-defaults] pin a global FIFO standing order so the BASELINE picks the older lot_a
1410 // (the app default is now HIFO, which would already pick the dearer lot_b → nothing to propose).
1411 // Made-date 2024-12-24 ≤ effective 2025-01-01 → not backdated.
1412 append_decision(
1413 s.conn(),
1414 EventPayload::MethodElection(MethodElection {
1415 effective_from: time::macros::date!(2025 - 01 - 01),
1416 method: LotMethod::Fifo,
1417 wallet: None,
1418 }),
1419 OffsetDateTime::from_unix_timestamp(1_735_000_000).unwrap(),
1420 UtcOffset::UTC,
1421 None,
1422 )
1423 .unwrap();
1424 let profile = TaxProfile {
1425 filing_status: FilingStatus::Single,
1426 ordinary_taxable_income: dec!(100000),
1427 magi_excluding_crypto: dec!(100000),
1428 qualified_dividends_and_other_pref_income: dec!(0),
1429 other_net_capital_gain: dec!(0),
1430 capital_loss_carryforward_in: Carryforward::default(),
1431 w2_ss_wages: dec!(0),
1432 w2_medicare_wages: dec!(0),
1433 schedule_c_expenses: dec!(0),
1434 };
1435 crate::tax_profile::set(s.conn(), 2025, &profile).unwrap();
1436 s.save().unwrap();
1437
1438 let now = OffsetDateTime::from_unix_timestamp(1_752_000_000).unwrap();
1439 let proposal = s.optimize_proposal(2025, now).unwrap();
1440 assert!(
1441 proposal.delta <= dec!(0),
1442 "delta must be ≤ 0 (baseline-seeded)"
1443 );
1444 let row = proposal
1445 .per_disposal
1446 .iter()
1447 .find(|d| d.disposal == to_id)
1448 .expect("the 2025 sale must be in the proposal");
1449 assert_ne!(
1450 row.proposed_selection, row.current_selection,
1451 "the optimizer must propose the dearer lot (a change from FIFO)"
1452 );
1453 }
1454}