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