btctax_cli/cmd/reconcile.rs
1//! reconcile decision emitters (FR6/FR7/FR8, §7.2). Each fn builds exactly ONE `EventPayload` decision
2//! variant and appends it via `append_decision` (monotonic `decision_seq`), then saves. Decisions are
3//! append-only and re-projectable; the engine resolves precedence (latest-`decision_seq`, Void-first).
4//! `now` is the injected decision creation-time / safe-harbor made-date (§6.2) — deterministic in tests.
5//!
6//! Also contains the `set-donation-details` / `show-donation-details` side-table commands (no decision
7//! append — these write to the `donation_details` side-table directly, like `tax-profile set`).
8use crate::{
9 BulkFilter, BulkLinkPlan, BulkReclassifyOutflowPlan, BulkResolvePlan, BulkStiFilter,
10 BulkStiPlan, BulkVoidPlan, CliError, MatchProposal, Session,
11};
12use btctax_core::conventions::{tax_date, TRANSITION_DATE};
13use btctax_core::persistence::{append_decision, load_all};
14use btctax_core::{
15 AllocMethod, BlockerKind, ClassifyInbound, ClassifyRaw, DisposeKind, DonationDetails, EventId,
16 EventPayload, InboundClass, IncomeKind, LotId, LotMethod, LotPick, LotSelection, ManualFmv,
17 MethodElection, OutflowClass, ReclassifyIncome, ReclassifyOutflow, RejectImport, RemovalKind,
18 SafeHarborAllocation, SelfTransferPassthrough, SupersedeImport, TaxDate, TransferLink,
19 TransferTarget, Usd, VoidDecisionEvent, WalletId,
20};
21use btctax_store::Passphrase;
22use std::path::Path;
23use time::{OffsetDateTime, UtcOffset};
24
25use crate::eventref::parse_event_id;
26
27/// Append one decision (creation tz = UTC; decisions are not wallet-scoped) and persist.
28fn append_and_save(
29 session: &mut Session,
30 payload: EventPayload,
31 now: OffsetDateTime,
32) -> Result<EventId, CliError> {
33 let id = append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
34 session.save()?;
35 Ok(id)
36}
37
38/// FR6: classify an externally-sourced inbound `TransferIn` as Income or a received Gift. For Income
39/// this supplies the FMV basis; for Gift it supplies donor basis/date + fmv_at_gift (TP11 dual-basis).
40/// This is the re-supply path for the §9.1 Swan `deposit` basis GAP.
41pub fn classify_inbound(
42 vault_path: &Path,
43 pp: &Passphrase,
44 in_ref: &str,
45 class: InboundClass,
46 now: OffsetDateTime,
47) -> Result<EventId, CliError> {
48 let transfer_in_event = parse_event_id(in_ref)?;
49 let mut session = Session::open(vault_path, pp)?;
50 let payload = EventPayload::ClassifyInbound(ClassifyInbound {
51 transfer_in_event,
52 as_: class,
53 });
54 append_and_save(&mut session, payload, now)
55}
56
57/// FR6: reclassify a pending `TransferOut` as a Sell/Spend disposition, a Gift out, or a Donation.
58/// `principal` is the gross proceeds (Dispose) or FMV-at-transfer (Gift/Donate); `fee_usd` is the
59/// optional disposition fee (TP8 / TP2). The engine applies the configured TP8 (c)/(b) fee treatment.
60/// `donee` is the optional free-form donee identifier (Chunk 2); `None` for disposals and legacy records.
61#[allow(clippy::too_many_arguments)]
62pub fn reclassify_outflow(
63 vault_path: &Path,
64 pp: &Passphrase,
65 out_ref: &str,
66 class: OutflowClass,
67 principal: Usd,
68 fee_usd: Option<Usd>,
69 donee: Option<String>,
70 now: OffsetDateTime,
71) -> Result<EventId, CliError> {
72 let transfer_out_event = parse_event_id(out_ref)?;
73 let mut session = Session::open(vault_path, pp)?;
74 let payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
75 transfer_out_event,
76 as_: class,
77 principal_proceeds_or_fmv: principal,
78 fee_usd,
79 donee,
80 });
81 append_and_save(&mut session, payload, now)
82}
83
84/// FR3: set a manual FMV on an event (`ManualEntry`), clearing its `fmv_missing` blocker.
85pub fn set_fmv(
86 vault_path: &Path,
87 pp: &Passphrase,
88 event_ref: &str,
89 usd_fmv: Usd,
90 now: OffsetDateTime,
91) -> Result<EventId, CliError> {
92 let event = parse_event_id(event_ref)?;
93 let mut session = Session::open(vault_path, pp)?;
94 append_and_save(
95 &mut session,
96 EventPayload::ManualFmv(ManualFmv { event, usd_fmv }),
97 now,
98 )
99}
100
101/// FR8: void a revocable decision. Voiding a non-revocable / effective-allocation target raises
102/// `decision_conflicts` in the projection (no effect) — the CLI only appends; the engine adjudicates.
103///
104/// When the voided decision is a `LotSelection`, also clears that disposal's `optimize_attestation`
105/// row ATOMICALLY (same in-memory DB, one `session.save()`). This closes the revocation-completeness
106/// edge: without the clear, a post-void re-run where the FIFO default equals the optimum
107/// (`proposed==current`, D ∈ unchanged) could mislabel D as `AttestedRecording` from a stale row
108/// the user never attested in this context. Non-LotSelection decisions are unaffected (the
109/// `optimize_attestation` table has no row to clear — the delete is a no-op).
110pub fn void(
111 vault_path: &Path,
112 pp: &Passphrase,
113 target_ref: &str,
114 now: OffsetDateTime,
115) -> Result<EventId, CliError> {
116 let target_event_id = parse_event_id(target_ref)?;
117 let mut session = Session::open(vault_path, pp)?;
118
119 // Determine if the target decision is a LotSelection so we can clear its attestation row.
120 // Load events first; the find is O(n) but n is small and void is infrequent.
121 let events = load_all(session.conn())?;
122 let disposal_to_clear: Option<EventId> = events
123 .iter()
124 .find(|e| e.id == target_event_id)
125 .and_then(|e| match &e.payload {
126 EventPayload::LotSelection(ls) => Some(ls.disposal_event.clone()),
127 _ => None,
128 });
129 // Symmetric side-effect [WB]: voiding a ReclassifyOutflow must clear its `bulk_estimated` `[est]`
130 // flag (keyed by transfer_out_event == Disposal.event) — else a stale marker survives a
131 // void→re-reclassify (the R0-I1 gap, closed on the CLI path too, mirroring optimize_attest above).
132 let reclass_out_to_clear: Option<EventId> = events
133 .iter()
134 .find(|e| e.id == target_event_id)
135 .and_then(|e| match &e.payload {
136 EventPayload::ReclassifyOutflow(ro) => Some(ro.transfer_out_event.clone()),
137 _ => None,
138 });
139
140 // Append the VoidDecisionEvent (no save yet — we batch with the attestation clear below).
141 let id = append_decision(
142 session.conn(),
143 EventPayload::VoidDecisionEvent(VoidDecisionEvent { target_event_id }),
144 now,
145 UtcOffset::UTC,
146 None,
147 )?;
148
149 // If the voided decision was a LotSelection, clear the attestation row for its disposal
150 // ATOMICALLY — both the void event and the row delete land in the same in-memory Connection
151 // and are flushed together by the single `session.save()` below (mirrors accept's atomic
152 // co-persist). Idempotent: clearing an absent row is Ok (no error).
153 if let Some(disposal) = disposal_to_clear {
154 crate::optimize_attest::clear(session.conn(), &disposal)?;
155 }
156 if let Some(out_event) = reclass_out_to_clear {
157 crate::bulk_estimated::clear(session.conn(), &out_event)?;
158 }
159
160 session.save()?;
161 Ok(id)
162}
163
164/// Pseudo-reconcile mode toggle (sub-project 2). Persists the `pseudo_reconcile` flag in `cli_config`
165/// (a projection input parameter, NOT ledger state — NFR6). `on = true` ⇒ projection synthesizes
166/// non-persisted default decisions to clear the Hard classification blockers; `off` reverts to real-only
167/// instantly (no fictional events were ever written). Returns the new flag value.
168pub fn pseudo_set_mode(vault_path: &Path, pp: &Passphrase, on: bool) -> Result<bool, CliError> {
169 let mut session = Session::open(vault_path, pp)?;
170 crate::config::set_pseudo_reconcile(session.conn(), on)?;
171 session.save()?;
172 Ok(on)
173}
174
175/// One row of a `reconcile pseudo approve` preview (sub-project 2, T5). `target` is the imported event the
176/// synthetic governs; `kind` is the default TYPE; `wallet`/`year` are the target's provenance (for the
177/// filter + preview). Deterministic order (mirrors `pseudo_plan`).
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct PseudoApproveRow {
180 pub target: EventId,
181 pub kind: btctax_core::PseudoKind,
182 pub wallet: Option<WalletId>,
183 pub year: Option<i32>,
184}
185
186/// The `--kind`/`--wallet`/`--year` filter for `reconcile pseudo approve`. `None` fields = no restriction.
187#[derive(Debug, Clone, Default)]
188pub struct PseudoApproveFilter {
189 pub kind: Option<btctax_core::PseudoKind>,
190 pub wallet: Option<WalletId>,
191 pub year: Option<i32>,
192}
193
194/// Compute the pseudo-default plan and KEEP only the entries matching `filter`, paired with the target's
195/// (wallet, tax-year) provenance. Shared by the read (`pseudo_approve_plan`) and write
196/// (`apply_bulk_pseudo_approve`) phases so the preview and the apply are guaranteed identical (the
197/// own-loop RE-derives — never trusts a threaded list). `plan` order is `pseudo_plan`'s stable order [N2].
198fn filtered_pseudo_plan<'a>(
199 plan: &'a [btctax_core::PseudoDefault],
200 index: &std::collections::HashMap<&EventId, &btctax_core::LedgerEvent>,
201 filter: &PseudoApproveFilter,
202) -> Vec<(
203 &'a btctax_core::PseudoDefault,
204 Option<WalletId>,
205 Option<i32>,
206)> {
207 plan.iter()
208 .filter_map(|pd| {
209 let ev = index.get(&pd.target);
210 let wallet = ev.and_then(|e| e.wallet.clone());
211 let year = ev.map(|e| tax_date(e.utc_timestamp, e.original_tz).year());
212 if let Some(k) = filter.kind {
213 if pd.kind != k {
214 return None;
215 }
216 }
217 if let Some(want) = &filter.wallet {
218 if wallet.as_ref() != Some(want) {
219 return None;
220 }
221 }
222 if let Some(y) = filter.year {
223 if year != Some(y) {
224 return None;
225 }
226 }
227 Some((pd, wallet, year))
228 })
229 .collect()
230}
231
232/// Pseudo-approve — Phase 1 (read): compute the filtered plan of pseudo defaults that WOULD be promoted to
233/// real decisions. Renders NOTHING to the vault (the confirmation stays a thin shell in `main.rs`).
234pub fn pseudo_approve_plan(
235 vault_path: &Path,
236 pp: &Passphrase,
237 filter: PseudoApproveFilter,
238) -> Result<Vec<PseudoApproveRow>, CliError> {
239 let session = Session::open(vault_path, pp)?;
240 let prices = btctax_adapters::BundledPrices::load()?;
241 let events = load_all(session.conn())?;
242 let cfg = session.config()?.to_projection();
243 let plan = btctax_core::pseudo_plan(&events, &prices, &cfg);
244 let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
245 events.iter().map(|e| (&e.id, e)).collect();
246 Ok(filtered_pseudo_plan(&plan, &index, &filter)
247 .into_iter()
248 .map(|(pd, wallet, year)| PseudoApproveRow {
249 target: pd.target.clone(),
250 kind: pd.kind,
251 wallet,
252 year,
253 })
254 .collect())
255}
256
257/// Pseudo-approve — Phase 2 (write): materialize the filtered pseudo defaults as REAL (attested) decisions
258/// via btctax-cli's OWN append-loop [R0-M4] (mirrors `apply_bulk_classify_inbound_income`; the CLI CANNOT
259/// reach the tui-edit `persist_bulk_decisions` — dependency cycle, Cargo.toml:19). Empty-guard (no matches
260/// ⇒ NO save); bare `?`-before-`save` (a mid-batch failure returns before `save`, discarding the in-memory
261/// session = CLI atomicity); a SINGLE `save`. Defense-in-depth: RE-derives the plan here (never trusts a
262/// threaded list). After approval these decisions are REAL → the next projection resolves them via the real
263/// decision path, so pseudo mode injects NO synthetic for them (no longer `[PSEUDO]`). Returns the count.
264pub fn apply_bulk_pseudo_approve(
265 vault_path: &Path,
266 pp: &Passphrase,
267 filter: PseudoApproveFilter,
268 now: OffsetDateTime,
269) -> Result<usize, CliError> {
270 let mut session = Session::open(vault_path, pp)?;
271 let prices = btctax_adapters::BundledPrices::load()?;
272 let events = load_all(session.conn())?;
273 let cfg = session.config()?.to_projection();
274 let plan = btctax_core::pseudo_plan(&events, &prices, &cfg);
275 let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
276 events.iter().map(|e| (&e.id, e)).collect();
277 let selected = filtered_pseudo_plan(&plan, &index, &filter);
278 // Empty-guard: nothing to approve ⇒ do NOT touch the vault.
279 if selected.is_empty() {
280 return Ok(0);
281 }
282 let mut n = 0usize;
283 for (pd, _wallet, _year) in &selected {
284 // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
285 // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK).
286 append_decision(
287 session.conn(),
288 pd.decision.clone(),
289 now,
290 UtcOffset::UTC,
291 None,
292 )?;
293 n += 1;
294 }
295 session.save()?;
296 Ok(n)
297}
298
299/// FR2/§7.3: resolve an `Unclassified` row to a real imported payload (preserving the target EventId).
300/// The payload is supplied as JSON (`EventPayload` is `Deserialize`) — e.g. `{"Acquire":{…}}`.
301pub fn classify_raw(
302 vault_path: &Path,
303 pp: &Passphrase,
304 target_ref: &str,
305 payload_json: &str,
306 now: OffsetDateTime,
307) -> Result<EventId, CliError> {
308 let target = parse_event_id(target_ref)?;
309 let as_: EventPayload = serde_json::from_str(payload_json)
310 .map_err(|e| CliError::Usage(format!("bad --payload-json: {e}")))?;
311 if !as_.is_imported() {
312 return Err(CliError::Usage(
313 "classify-raw payload must be an imported variant (Acquire/Income/Dispose/TransferOut/TransferIn/Unclassified)".into(),
314 ));
315 }
316 let mut session = Session::open(vault_path, pp)?;
317 append_and_save(
318 &mut session,
319 EventPayload::ClassifyRaw(ClassifyRaw {
320 target,
321 as_: Box::new(as_),
322 }),
323 now,
324 )
325}
326
327/// FR1/FR8: accept an `ImportConflict` (apply the new payload to the target, keeping its EventId).
328pub fn accept_conflict(
329 vault_path: &Path,
330 pp: &Passphrase,
331 conflict_ref: &str,
332 now: OffsetDateTime,
333) -> Result<EventId, CliError> {
334 let conflict_event = parse_event_id(conflict_ref)?;
335 let mut session = Session::open(vault_path, pp)?;
336 append_and_save(
337 &mut session,
338 EventPayload::SupersedeImport(SupersedeImport { conflict_event }),
339 now,
340 )
341}
342
343/// FR1/FR8: reject an `ImportConflict` (keep the original; clear the blocker).
344pub fn reject_conflict(
345 vault_path: &Path,
346 pp: &Passphrase,
347 conflict_ref: &str,
348 now: OffsetDateTime,
349) -> Result<EventId, CliError> {
350 let conflict_event = parse_event_id(conflict_ref)?;
351 let mut session = Session::open(vault_path, pp)?;
352 append_and_save(
353 &mut session,
354 EventPayload::RejectImport(RejectImport { conflict_event }),
355 now,
356 )
357}
358
359/// bulk-link-transfer D2 — Phase 1 (read): open the session and compute the bulk link-transfer plan.
360///
361/// Two-phase by design [R0-M2]: this read phase opens/renders NOTHING to the vault, so the
362/// interactive `y/N` confirmation stays a thin, untested shell in the `main.rs` dispatch. The plan is
363/// the shared read helper `Session::bulk_link_transfer_plan` (D1). The session (and its VaultLock) is
364/// dropped on return, before the confirmation prompt runs.
365pub fn bulk_link_plan(
366 vault_path: &Path,
367 pp: &Passphrase,
368 filter: BulkFilter,
369 dest: WalletId,
370) -> Result<BulkLinkPlan, CliError> {
371 let session = Session::open(vault_path, pp)?;
372 session.bulk_link_transfer_plan(filter, dest)
373}
374
375/// bulk-link-transfer D2 — Phase 2 (write): atomically link every `out_event` to `dest` as a
376/// self-transfer. Appends one `TransferLink { out_event, Wallet(dest) }` per row, then a SINGLE
377/// `save`. All-or-nothing: a mid-batch `append_decision` failure returns `Err` BEFORE the save, and
378/// the local `Session` is dropped with nothing written — the exact one-session / N-append / one-save
379/// atomicity of `import_selections`. Returns the number of outflows linked.
380pub fn apply_bulk_link_transfer(
381 vault_path: &Path,
382 pp: &Passphrase,
383 out_events: Vec<EventId>,
384 dest: WalletId,
385 now: OffsetDateTime,
386) -> Result<usize, CliError> {
387 let mut session = Session::open(vault_path, pp)?;
388 for out_event in &out_events {
389 let payload = EventPayload::TransferLink(TransferLink {
390 out_event: out_event.clone(),
391 in_event_or_wallet: TransferTarget::Wallet(dest.clone()),
392 });
393 // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
394 // nothing lands on disk (CLI atomicity; the TUI path must instead ROLL BACK, see D3 [R0-I1]).
395 append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
396 }
397 session.save()?;
398 Ok(out_events.len())
399}
400
401/// bulk-classify-inbound-self-transfer D2 — Phase 1 (read): open the session and compute the bulk STI
402/// plan. Two-phase by design (mirrors `bulk_link_plan`): this read phase renders NOTHING to the vault,
403/// so the interactive `y/N` confirmation stays a thin, untested shell in the `main.rs` dispatch. The
404/// plan is the shared read helper `Session::bulk_self_transfer_in_plan` (D1). The session (and its
405/// VaultLock) is dropped on return, before the confirmation prompt runs.
406pub fn bulk_self_transfer_in_plan(
407 vault_path: &Path,
408 pp: &Passphrase,
409 filter: BulkStiFilter,
410) -> Result<BulkStiPlan, CliError> {
411 let session = Session::open(vault_path, pp)?;
412 session.bulk_self_transfer_in_plan(filter)
413}
414
415/// bulk-classify-inbound-self-transfer D2 — Phase 2 (write): atomically classify every `in_event` as
416/// a `SelfTransferMine { basis: None, acquired_at: None }` ($0 conservative basis, non-taxable).
417/// Appends one `ClassifyInbound { transfer_in_event, SelfTransferMine{None, None} }` per row, then a
418/// SINGLE `save`. All-or-nothing: a mid-batch `append_decision` failure returns `Err` BEFORE the save,
419/// and the local `Session` is dropped with nothing written — the exact one-session / N-append /
420/// one-save atomicity of `apply_bulk_link_transfer`. Returns the number of inbounds classified.
421pub fn apply_bulk_self_transfer_in(
422 vault_path: &Path,
423 pp: &Passphrase,
424 in_events: Vec<EventId>,
425 now: OffsetDateTime,
426) -> Result<usize, CliError> {
427 let mut session = Session::open(vault_path, pp)?;
428 for in_event in &in_events {
429 let payload = EventPayload::ClassifyInbound(ClassifyInbound {
430 transfer_in_event: in_event.clone(),
431 as_: InboundClass::SelfTransferMine {
432 basis: None,
433 acquired_at: None,
434 },
435 });
436 // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
437 // nothing lands on disk (CLI atomicity; the TUI path must instead ROLL BACK, see D3).
438 append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
439 }
440 session.save()?;
441 Ok(in_events.len())
442}
443
444/// bulk-classify-inbound-income (Cycle 4) — Phase 1 (read): open the session and compute the bulk
445/// classify-income plan. Two-phase by design (mirrors `bulk_self_transfer_in_plan`): this read phase
446/// renders NOTHING to the vault. The plan is the shared read helper `Session::bulk_classify_income_plan`
447/// — it excludes missing-price rows [#a tax-safety] and reports them as `excluded_missing_price`.
448pub fn bulk_classify_income_plan(
449 vault_path: &Path,
450 pp: &Passphrase,
451 filter: crate::BulkIncomeFilter,
452) -> Result<crate::BulkIncomePlan, CliError> {
453 let session = Session::open(vault_path, pp)?;
454 session.bulk_classify_income_plan(filter)
455}
456
457/// bulk-classify-inbound-income (Cycle 4) — Phase 2 (write): atomically classify every `in_event` as
458/// `Income { kind, fmv, business }` with a PER-ROW auto-FMV. Its OWN append-loop (mirrors
459/// `apply_bulk_self_transfer_in`; NOT the tui-edit `persist_bulk_decisions`, which btctax-cli cannot
460/// reach — dependency cycle, R0-I1). One `ClassifyInbound { transfer_in_event, Income{..} }` per row,
461/// bare `?`-before-`save` (a mid-batch failure returns before `save` → the in-memory session is
462/// discarded, nothing lands on disk = CLI atomicity), then a SINGLE `session.save()`.
463///
464/// **[#a] Auto-FMV is resolved per-row via `fmv_of(date, sat)`.** The dispatch derives `in_events`
465/// from `plan.included` (the fmv-`Some` rows only), so every id here resolves to a real price and the
466/// `fmv: Option<Usd>` field is `Some` — a persisted `Income{fmv:None}` (→ Hard `FmvMissing` year-gate)
467/// is never emitted. `kind` + `business` are UNIFORM across the batch; `fmv` is per-row. Returns the
468/// number classified.
469pub fn apply_bulk_classify_inbound_income(
470 vault_path: &Path,
471 pp: &Passphrase,
472 in_events: Vec<EventId>,
473 kind: IncomeKind,
474 business: bool,
475 now: OffsetDateTime,
476) -> Result<usize, CliError> {
477 let mut session = Session::open(vault_path, pp)?;
478 let prices = btctax_adapters::BundledPrices::load()?;
479 let events = load_all(session.conn())?;
480 let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
481 events.iter().map(|e| (&e.id, e)).collect();
482 // Resolve (sat, date) per in_event from the event log (the FMV is a per-row auto-value; R0-M3:
483 // `fmv` is already Option<Usd> so it takes `fmv_of(..)` DIRECTLY — never `Some(fmv_of(..))`).
484 let mut n = 0usize;
485 for in_event in &in_events {
486 let Some(ev) = index.get(in_event) else {
487 continue;
488 };
489 let EventPayload::TransferIn(ti) = &ev.payload else {
490 continue;
491 };
492 let date = btctax_core::conventions::tax_date(ev.utc_timestamp, ev.original_tz);
493 // #a defense-in-depth: a missing-price row must NEVER be emitted as `Income { fmv: None }`
494 // (→ Hard `FmvMissing` year-gate, unrecoverable without void+reclassify). The plan already
495 // excludes these; skipping here makes `Income{fmv:None}` STRUCTURALLY unreachable from this
496 // `pub` apply even if a future caller passed a non-plan-filtered id.
497 let Some(fmv) = btctax_core::price::fmv_of(&prices, date, ti.sat) else {
498 continue;
499 };
500 let payload = EventPayload::ClassifyInbound(ClassifyInbound {
501 transfer_in_event: in_event.clone(),
502 as_: InboundClass::Income {
503 kind,
504 fmv: Some(fmv),
505 business,
506 },
507 });
508 // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
509 // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK).
510 append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
511 n += 1;
512 }
513 session.save()?;
514 Ok(n)
515}
516
517/// bulk-reclassify-outflow (Cycle 5) — Phase 1 (read): open the session and compute the bulk
518/// reclassify-outflow plan. Two-phase by design (mirrors `bulk_classify_income_plan`): this read phase
519/// renders NOTHING to the vault. The plan is the shared read helper `Session::bulk_reclassify_outflow_plan`
520/// — it excludes missing-price rows [#a tax-safety] and reports them as `excluded_missing_price`.
521pub fn bulk_reclassify_outflow_plan(
522 vault_path: &Path,
523 pp: &Passphrase,
524 filter: BulkFilter,
525) -> Result<BulkReclassifyOutflowPlan, CliError> {
526 let session = Session::open(vault_path, pp)?;
527 session.bulk_reclassify_outflow_plan(filter)
528}
529
530/// bulk-reclassify-outflow (Cycle 5) — Phase 2 (write): atomically reclassify every `out_event` as a
531/// `Dispose{kind}` with a PER-ROW auto-FMV as ESTIMATED proceeds, AND mark each in the `bulk_estimated`
532/// side-table, then a SINGLE `save`. Its OWN append-loop (mirrors `apply_bulk_classify_inbound_income`;
533/// the CLI CANNOT reach the tui-edit `persist_bulk_decisions` — dependency cycle, R0-I1 of Cycle 4).
534///
535/// **[#a defense-in-depth]** the fmv is RE-DERIVED per-row via `fmv_of(date, sat)`, never trusting a
536/// threaded number: `let Some(fmv) = … else { continue };`. The dispatch derives `out_events` from
537/// `plan.included` (the fmv-`Some` rows only), so every id resolves — and a missing-price row can NEVER
538/// be emitted as a Sell with fabricated `0`/bogus proceeds (a SILENT misreport), even if a future caller
539/// passed a non-plan id. `fee_usd: None` always (the on-chain `fee_sat` still flows via resolve.rs
540/// regardless — a uniform USD disposition-fee across heterogeneous txns is meaningless); `donee: None`
541/// (Dispose has none). `kind` is UNIFORM across the batch. Bare `?`-before-`save`: a mid-batch failure
542/// returns before `save` → the in-memory session is discarded, so NEITHER the appends NOR the side-table
543/// marks land [R0-M2]. Returns the number reclassified.
544pub fn apply_bulk_reclassify_outflow(
545 vault_path: &Path,
546 pp: &Passphrase,
547 out_events: Vec<EventId>,
548 kind: DisposeKind,
549 now: OffsetDateTime,
550) -> Result<usize, CliError> {
551 let mut session = Session::open(vault_path, pp)?;
552 let prices = btctax_adapters::BundledPrices::load()?;
553 let events = load_all(session.conn())?;
554 let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
555 events.iter().map(|e| (&e.id, e)).collect();
556 // Provenance stamp (the batch made-date) recorded on each side-table row.
557 let marked_at = tax_date(now, UtcOffset::UTC).to_string();
558 let mut n = 0usize;
559 for out_event in &out_events {
560 let Some(ev) = index.get(out_event) else {
561 continue;
562 };
563 let EventPayload::TransferOut(t) = &ev.payload else {
564 continue;
565 };
566 let date = tax_date(ev.utc_timestamp, ev.original_tz);
567 // #a defense-in-depth: re-derive the fmv; a missing-price row is skipped, NEVER emitted as a
568 // Dispose with fabricated proceeds. Makes a fabricated-proceeds Sell structurally unreachable.
569 let Some(fmv) = btctax_core::price::fmv_of(&prices, date, t.sat) else {
570 continue;
571 };
572 let payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
573 transfer_out_event: out_event.clone(),
574 as_: OutflowClass::Dispose { kind },
575 principal_proceeds_or_fmv: fmv,
576 fee_usd: None,
577 donee: None,
578 });
579 // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
580 // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK).
581 append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
582 // Flag the ESTIMATED proceeds in the side-table (same in-memory conn, flushed by the single
583 // save below; discarded together with the append on any mid-batch failure — [R0-M2]).
584 crate::bulk_estimated::mark(session.conn(), out_event, &marked_at)?;
585 n += 1;
586 }
587 session.save()?;
588 Ok(n)
589}
590
591/// bulk-resolve-conflict D2 — Phase 1 (read): open the session and compute the bulk resolve-conflict
592/// plan. Two-phase by design (mirrors `bulk_link_plan`): this read phase renders NOTHING to the vault,
593/// so the interactive `y/N` confirmation stays a thin, untested shell in the `main.rs` dispatch. The
594/// plan is the shared read helper `Session::bulk_resolve_conflict_plan` (D1). The session (and its
595/// VaultLock) is dropped on return, before the confirmation prompt runs.
596pub fn bulk_resolve_conflict_plan(
597 vault_path: &Path,
598 pp: &Passphrase,
599) -> Result<BulkResolvePlan, CliError> {
600 let session = Session::open(vault_path, pp)?;
601 session.bulk_resolve_conflict_plan()
602}
603
604/// bulk-resolve-conflict D2 — Phase 2 (write), ACCEPT: atomically append one `SupersedeImport` per
605/// conflict (adopt each `new_payload` onto its target id), then a SINGLE `save`. All-or-nothing: a
606/// mid-batch `append_decision` failure returns `Err` BEFORE the save, and the local `Session` is
607/// dropped with nothing written — the exact one-session / N-append / one-save atomicity of
608/// `apply_bulk_link_transfer`. Mirrors the shipped single-item split `accept_conflict`/`reject_conflict`
609/// [R0-I1 — NO `ResolveKind` in the CLI; it lives only in btctax-tui-edit]. Returns the number accepted.
610pub fn apply_bulk_accept_conflicts(
611 vault_path: &Path,
612 pp: &Passphrase,
613 conflict_events: Vec<EventId>,
614 now: OffsetDateTime,
615) -> Result<usize, CliError> {
616 let mut session = Session::open(vault_path, pp)?;
617 for conflict_event in &conflict_events {
618 let payload = EventPayload::SupersedeImport(SupersedeImport {
619 conflict_event: conflict_event.clone(),
620 });
621 // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
622 // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK via persist_bulk_decisions).
623 append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
624 }
625 session.save()?;
626 Ok(conflict_events.len())
627}
628
629/// bulk-resolve-conflict D2 — Phase 2 (write), REJECT: atomically append one `RejectImport` per
630/// conflict (keep each target's current payload; clear the blocker), then a SINGLE `save`. Same
631/// all-or-nothing CLI atomicity as `apply_bulk_accept_conflicts`. Returns the number rejected.
632pub fn apply_bulk_reject_conflicts(
633 vault_path: &Path,
634 pp: &Passphrase,
635 conflict_events: Vec<EventId>,
636 now: OffsetDateTime,
637) -> Result<usize, CliError> {
638 let mut session = Session::open(vault_path, pp)?;
639 for conflict_event in &conflict_events {
640 let payload = EventPayload::RejectImport(RejectImport {
641 conflict_event: conflict_event.clone(),
642 });
643 append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
644 }
645 session.save()?;
646 Ok(conflict_events.len())
647}
648
649/// bulk-void D2 — Phase 1 (read): open the session and compute the bulk-void plan. Two-phase by design
650/// (mirrors `bulk_resolve_conflict_plan`): this read phase renders NOTHING to the vault, so the
651/// interactive `y/N` confirmation stays a thin, untested shell in the `main.rs` dispatch. The plan is
652/// the shared read helper `Session::bulk_void_plan` (D1) — the SINGLE `voidable_decisions` predicate,
653/// which OMITS effective allocations (#7). The session (and its VaultLock) is dropped on return.
654pub fn bulk_void_plan(vault_path: &Path, pp: &Passphrase) -> Result<BulkVoidPlan, CliError> {
655 let session = Session::open(vault_path, pp)?;
656 session.bulk_void_plan()
657}
658
659/// bulk-void D2 — Phase 2 (write): atomically append one `VoidDecisionEvent` per `target` AND, for each
660/// `LotSelection` target, clear its optimizer attestation (`optimize_attest::clear`), then a SINGLE
661/// `save`. All-or-nothing: a mid-batch `append_decision` / `clear` failure returns `Err` BEFORE the
662/// save, and the local `Session` is dropped with nothing written — the exact one-session / N-append /
663/// one-save atomicity of `apply_bulk_accept_conflicts` (the TUI path instead ROLLS BACK explicitly via
664/// `persist_bulk_void`).
665///
666/// # [R0-M3] the ONLY CLI-layer #7 defense
667/// `targets` MUST be exactly the `bulk_void_plan` rows (predicate-filtered), re-derived from the vault
668/// inside the dispatch — NEVER raw `--ref` ids. The single CLI `void` does NO `effective_alloc` check,
669/// so a raw-id bulk path would let a caller void an effective allocation → Hard `DecisionConflict`.
670/// Each target is `(target_event_id, disposal_to_clear)` carried straight from the plan row. Returns
671/// the number voided.
672pub fn apply_bulk_void(
673 vault_path: &Path,
674 pp: &Passphrase,
675 targets: Vec<(EventId, Option<EventId>)>,
676 now: OffsetDateTime,
677) -> Result<usize, CliError> {
678 let mut session = Session::open(vault_path, pp)?;
679 // Map ReclassifyOutflow decision id → its transfer_out_event, so voiding one clears its
680 // bulk_estimated `[est]` flag — symmetric with single `void` + TUI persist_bulk_void [WB — R0-I1
681 // closed on the CLI bulk path]. Idempotent clear; O(n) build, void is infrequent.
682 let events = load_all(session.conn())?;
683 let reclass_map: std::collections::HashMap<EventId, EventId> = events
684 .iter()
685 .filter_map(|e| match &e.payload {
686 EventPayload::ReclassifyOutflow(ro) => {
687 Some((e.id.clone(), ro.transfer_out_event.clone()))
688 }
689 _ => None,
690 })
691 .collect();
692 for (target_event_id, disposal_to_clear) in &targets {
693 // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
694 // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK via persist_bulk_void).
695 append_decision(
696 session.conn(),
697 EventPayload::VoidDecisionEvent(VoidDecisionEvent {
698 target_event_id: target_event_id.clone(),
699 }),
700 now,
701 UtcOffset::UTC,
702 None,
703 )?;
704 // Per-LotSelection side-effect: clear its disposal's optimizer attestation ATOMICALLY (same
705 // in-memory conn, flushed by the single save below). Idempotent — clearing an absent row is Ok.
706 if let Some(disposal) = disposal_to_clear {
707 crate::optimize_attest::clear(session.conn(), disposal)?;
708 }
709 // Symmetric ReclassifyOutflow side-effect: clear its bulk_estimated flag in the same batch.
710 if let Some(out_event) = reclass_map.get(target_event_id) {
711 crate::bulk_estimated::clear(session.conn(), out_event)?;
712 }
713 }
714 session.save()?;
715 Ok(targets.len())
716}
717
718/// FR6/TP7: confirm a self-transfer. `target` is a destination `TransferIn` event (`--to-event`) or a
719/// known wallet (`--to-wallet`); the engine relocates the lots carrying basis + acquired_at.
720pub fn link_transfer(
721 vault_path: &Path,
722 pp: &Passphrase,
723 out_ref: &str,
724 target: TransferTarget,
725 now: OffsetDateTime,
726) -> Result<EventId, CliError> {
727 let out_event = parse_event_id(out_ref)?;
728 let mut session = Session::open(vault_path, pp)?;
729 let payload = EventPayload::TransferLink(TransferLink {
730 out_event,
731 in_event_or_wallet: target,
732 });
733 append_and_save(&mut session, payload, now)
734}
735
736/// self-transfer-passthrough C3 — Phase 1 (read): compute the self-transfer match proposals on the HELD
737/// session (READ-ONLY; appends/persists NOTHING). Mirrors `bulk_link_plan`: the shared read helper is
738/// `Session::self_transfer_match_plan`; the session (and its VaultLock) is dropped on return.
739pub fn self_transfer_match_plan(
740 vault_path: &Path,
741 pp: &Passphrase,
742) -> Result<Vec<MatchProposal>, CliError> {
743 let session = Session::open(vault_path, pp)?;
744 session.self_transfer_match_plan()
745}
746
747/// self-transfer-passthrough C3 — Phase 2 (write), DROP: append one `SelfTransferPassthrough` decision
748/// mapping BOTH legs to `Op::Skip` (non-taxable passthrough). Mirrors `link_transfer` (one append + save).
749/// The RELOCATE case is NOT here — it routes to the EXISTING `link_transfer(out, InEvent(in))` (G-RELOCATE-REUSE).
750pub fn apply_self_transfer_passthrough(
751 vault_path: &Path,
752 pp: &Passphrase,
753 in_ref: &str,
754 out_ref: &str,
755 now: OffsetDateTime,
756) -> Result<EventId, CliError> {
757 let in_event = parse_event_id(in_ref)?;
758 let out_event = parse_event_id(out_ref)?;
759 let mut session = Session::open(vault_path, pp)?;
760 let payload = EventPayload::SelfTransferPassthrough(SelfTransferPassthrough {
761 in_event,
762 out_event,
763 });
764 append_and_save(&mut session, payload, now)
765}
766
767/// FR7/§7.4: build a Path-B safe-harbor allocation that seeds from the **pre-2025 residue** (the
768/// 2025-01-01 Universal-pool position), so it conserves against the engine's allocation-independent
769/// conservation guard.
770///
771/// I-1: the engine's guard compares `Σ alloc.lots.sat`/`usd_basis` to `transition::universal_snapshot`
772/// — a pre-2025-ONLY fold of the Universal pool (resolve.rs §7.4, step 3). The FULL projection's
773/// `state.lots` reflects POST-2025-disposal residuals (a 2025 Sell consumes pre-2025 lots in FIFO), so
774/// seeding from them would yield `alloc_sat < snap.held_sat` → hard `SafeHarborUnconservable` → Path A,
775/// breaking the normal workflow. Instead we re-project a pre-2025-only event subset and read ITS lots:
776/// - keep ONLY import events whose tax-date `< 2025-01-01` (drop every 2025+ acquire/dispose/transfer);
777/// - keep ALL reconciliation decisions/conflicts — they SHAPE the residue (a 2026 `ClassifyInbound`
778/// supplies a pre-2025 `TransferIn`'s basis; `ReclassifyOutflow`/`TransferLink` consume/relocate a
779/// pre-2025 lot) — and carry a 2026 made-date, so they must NOT be tax-date-filtered;
780/// - DROP any prior `SafeHarborAllocation` so the residue stays allocation-INDEPENDENT (matches
781/// `universal_snapshot`, which never applies a seed) → re-allocation is idempotent.
782///
783/// This subset re-runs the IDENTICAL `fold_event` arms the engine's snapshot uses, so the totals match
784/// exactly (the only difference, Path A's per-wallet relocation, preserves sat/basis 1:1; `finalize`
785/// attributes Universal-pool lots by `lot.wallet`). For `ActualPosition` the per-wallet assignment falls
786/// out of those residue lots' `wallet` (= the wallet holding each lot at 2025-01-01). `ProRata` still
787/// seeds from these actuals; a true cross-wallet pro-rata redistribution is a manual-input refinement
788/// (Open question O4). The engine's `SafeHarborUnconservable` guard remains the backstop for any residual
789/// drift (e.g. a rare self-transfer straddling the 2025 boundary) — fails closed, never silent wrong tax.
790pub fn safe_harbor_allocate(
791 vault_path: &Path,
792 pp: &Passphrase,
793 method: AllocMethod,
794 attested: bool,
795 now: OffsetDateTime,
796) -> Result<EventId, CliError> {
797 let mut session = Session::open(vault_path, pp)?;
798
799 // R-M7b / D3: attestation gate — EARLY, before empty-lots / conservation work.
800 // A SafeHarborAllocation permanently records pre2025_method and is irrevocable; require an
801 // explicit declared+attested pre-2025 method so the commitment is a deliberate, attested choice
802 // rather than a silent FIFO default. Note: the `attested` parameter to this function is
803 // `timely_allocation_attested` (§5.02(4)) — a separate attestation; do not conflate the two.
804 {
805 let cli_cfg = session.config()?;
806 if !cli_cfg.pre2025_method_attested {
807 let m = match cli_cfg.pre2025_method {
808 LotMethod::Fifo => "fifo",
809 LotMethod::Lifo => "lifo",
810 LotMethod::Hifo => "hifo",
811 };
812 return Err(CliError::Usage(format!(
813 "refusing to record a safe-harbor allocation under an UNDECLARED pre-2025 method \
814 ({m}); a safe-harbor allocation permanently records the method used to reconstruct \
815 your pre-2025 basis. Declare your filed method first: \
816 config --set-pre2025-method {m} --attest-pre2025-method"
817 )));
818 }
819 }
820
821 // Pre-2025 residue + the recorded `pre2025_method` come from the single shared read helper
822 // (`Session::safe_harbor_residue`, D3) — the same source the TUI allocate opener uses. It reads
823 // config ONCE, so the returned method is STRUCTURALLY the one the residue was projected under.
824 let (lots, pre2025_method) = session.safe_harbor_residue()?;
825 if lots.is_empty() {
826 return Err(CliError::Usage(
827 "no pre-2025 lots to allocate (Path A applies; safe harbor unnecessary)".into(),
828 ));
829 }
830 let payload = EventPayload::SafeHarborAllocation(SafeHarborAllocation {
831 lots,
832 as_of_date: TRANSITION_DATE,
833 method,
834 timely_allocation_attested: attested,
835 // §A.7: the recorded pre-2025 method is the SAME config method the residue above was projected
836 // under (both from the one `safe_harbor_residue` config read), so the listed lots conserve
837 // against the engine's method-aware snapshot. Immutable thereafter; a later live-config change
838 // fires the hard `Pre2025MethodConflictsAllocation` rather than silently breaking conservation.
839 pre2025_method,
840 });
841 append_and_save(&mut session, payload, now)
842}
843
844/// §A.4 / SPEC Task 5: emit a `LotSelection` decision for a specific disposal. `disposal_ref` is the
845/// canonical `EventId` string of the disposal event; `picks` is at least one `LotPick`. The engine
846/// validates completeness (Σsat == disposal principal) and lot existence in the fold; this function
847/// only appends the decision — it does NOT attempt to validate up-front (that would require a full
848/// projection, which the engine always does). Identification must exist by the time of sale (§1.1012-1(j)).
849pub fn select_lots(
850 vault_path: &Path,
851 pp: &Passphrase,
852 disposal_ref: &str,
853 picks: Vec<LotPick>,
854 now: OffsetDateTime,
855) -> Result<EventId, CliError> {
856 let disposal_event = parse_event_id(disposal_ref)?;
857 if picks.is_empty() {
858 return Err(CliError::Usage(
859 "select-lots needs at least one --from <lot-id>:<sat>".into(),
860 ));
861 }
862 let mut session = Session::open(vault_path, pp)?;
863 append_and_save(
864 &mut session,
865 EventPayload::LotSelection(LotSelection {
866 disposal_event,
867 lots: picks,
868 }),
869 now,
870 )
871}
872
873/// §A.4 / SPEC Task 5: batch-import `LotSelection` decisions from a CSV file.
874///
875/// CSV format: `disposal_ref,origin_event_id,split_sequence,sat` (header required, validated loudly).
876/// Rows sharing a `disposal_ref` are grouped into a single `LotSelection` (one decision per disposal);
877/// grouping is by BTreeMap on the canonical disposal_ref string → deterministic order (NFR4).
878/// All decisions are written in one session → one `save()`.
879pub fn import_selections(
880 vault_path: &Path,
881 pp: &Passphrase,
882 csv_path: &Path,
883 now: OffsetDateTime,
884) -> Result<Vec<EventId>, CliError> {
885 let mut rdr = csv::ReaderBuilder::new()
886 .has_headers(true)
887 .from_path(csv_path)?;
888 {
889 let hdr = rdr.headers()?;
890 let cols: Vec<&str> = hdr.iter().collect();
891 if cols != ["disposal_ref", "origin_event_id", "split_sequence", "sat"] {
892 return Err(CliError::Usage(format!(
893 "import-selections CSV header must be \
894 disposal_ref,origin_event_id,split_sequence,sat; got {cols:?}"
895 )));
896 }
897 }
898 // Group picks by disposal_ref; BTreeMap gives deterministic iteration order (NFR4).
899 let mut by_disposal: std::collections::BTreeMap<String, Vec<LotPick>> =
900 std::collections::BTreeMap::new();
901 for rec in rdr.records() {
902 let rec = rec?;
903 let disposal_ref = rec
904 .get(0)
905 .ok_or_else(|| CliError::Usage("missing disposal_ref".into()))?
906 .to_string();
907 let origin_str = rec
908 .get(1)
909 .ok_or_else(|| CliError::Usage("missing origin_event_id".into()))?;
910 let split_str = rec
911 .get(2)
912 .ok_or_else(|| CliError::Usage("missing split_sequence".into()))?;
913 let sat_str = rec
914 .get(3)
915 .ok_or_else(|| CliError::Usage("missing sat".into()))?;
916 let origin_event_id = parse_event_id(origin_str)?;
917 let split_sequence = split_str
918 .trim()
919 .parse::<u32>()
920 .map_err(|e| CliError::Usage(format!("bad split_sequence {split_str:?}: {e}")))?;
921 let sat = sat_str
922 .trim()
923 .parse::<i64>()
924 .map_err(|e| CliError::Usage(format!("bad sat {sat_str:?}: {e}")))?;
925 by_disposal.entry(disposal_ref).or_default().push(LotPick {
926 lot: LotId {
927 origin_event_id,
928 split_sequence,
929 },
930 sat,
931 });
932 }
933 let mut session = Session::open(vault_path, pp)?;
934 let mut ids = Vec::new();
935 for (disposal_ref, lots) in by_disposal {
936 let disposal_event = parse_event_id(&disposal_ref)?;
937 let id = append_decision(
938 session.conn(),
939 EventPayload::LotSelection(LotSelection {
940 disposal_event,
941 lots,
942 }),
943 now,
944 UtcOffset::UTC,
945 None,
946 )?;
947 ids.push(id);
948 }
949 session.save()?;
950 Ok(ids)
951}
952
953/// M3 / SPEC A.1 / A.5(a): append a `MethodElection` decision — the forward standing order.
954///
955/// This is an EVENT, not a config flag mutation. The standing order is irrevocable (unless voided)
956/// and governs all method-honoring disposals on/after `effective_from`. Back-dating is blocked by
957/// the engine (`MethodElectionBackdated` hard blocker when `effective_from < made-date`).
958///
959/// `wallet` carries the optional per-ACCOUNT scope (§A.5(a)): `None` = a GLOBAL election (unchanged);
960/// `Some(WalletId::Exchange{..})` = that exchange account's method. [R0-M3] Only `Exchange` wallets
961/// are electable (a `self:LABEL` scope is rejected), and the account MUST be one the vault already
962/// knows — an unknown/typo'd account is rejected LOUDLY so it can't silently create a dead election
963/// the user believes is in force. [R0-M1] The scope lives in the `MethodElection` PAYLOAD, not the
964/// `LedgerEvent.wallet` column (`append_decision` passes `None` for the event-level wallet).
965///
966/// When `effective_from` is `None`, defaults to the decision's made-date (`now` in UTC), which
967/// satisfies the `effective_from >= made-date` invariant by construction.
968pub fn set_forward_method(
969 vault_path: &Path,
970 pp: &Passphrase,
971 m: LotMethod,
972 wallet: Option<WalletId>,
973 effective_from: Option<TaxDate>,
974 now: OffsetDateTime,
975) -> Result<EventId, CliError> {
976 let effective_from = effective_from.unwrap_or_else(|| now.to_offset(UtcOffset::UTC).date());
977 let mut session = Session::open(vault_path, pp)?;
978 // [R0-M3] Validate an explicit --exchange scope BEFORE appending: only Exchange wallets are
979 // electable, and the account must be present in the loaded events (enumerate distinct
980 // WalletId::Exchange). A silent dead election (typo'd provider/account) would mislead the user
981 // into thinking a method is in force when it isn't — so reject unknown scopes loudly.
982 if let Some(w) = &wallet {
983 let WalletId::Exchange { .. } = w else {
984 return Err(CliError::Usage(format!(
985 "--exchange must name an exchange account (exchange:PROVIDER:ACCOUNT); \
986 {w:?} is not electable — a method election is a brokerage-account concept"
987 )));
988 };
989 let events = load_all(session.conn())?;
990 let known: std::collections::BTreeSet<WalletId> = events
991 .iter()
992 .filter_map(|e| e.wallet.clone())
993 .filter(|w| matches!(w, WalletId::Exchange { .. }))
994 .collect();
995 if !known.contains(w) {
996 let names: Vec<String> = known
997 .iter()
998 .map(|k| match k {
999 WalletId::Exchange { provider, account } => {
1000 format!("exchange:{provider}:{account}")
1001 }
1002 other => format!("{other:?}"),
1003 })
1004 .collect();
1005 return Err(CliError::Usage(format!(
1006 "--exchange {w:?} is not a known exchange account in this vault \
1007 (accounts are created by importing events). Known exchange accounts: [{}]",
1008 names.join(", ")
1009 )));
1010 }
1011 }
1012 append_and_save(
1013 &mut session,
1014 EventPayload::MethodElection(MethodElection {
1015 effective_from,
1016 method: m,
1017 wallet,
1018 }),
1019 now,
1020 )
1021}
1022
1023/// FR7: attest an existing allocation. Events are immutable, so attestation = void the single live prior
1024/// allocation and re-append it with `timely_allocation_attested = true`. Attestation only cures a
1025/// §5.02(4) TIME-BAR; it is NOT valid on an already-effective allocation (which needs nothing) nor on one
1026/// that fails CONSERVATION (which needs a corrected allocation, not an attestation).
1027///
1028/// Uses `Session::load_events_and_project` to load the event log exactly once — the old pattern
1029/// (separate `load_all(session.conn())` + `session.project()`) loaded the same DB rows twice.
1030pub fn safe_harbor_attest(
1031 vault_path: &Path,
1032 pp: &Passphrase,
1033 now: OffsetDateTime,
1034) -> Result<EventId, CliError> {
1035 let mut session = Session::open(vault_path, pp)?;
1036 let (events, state, _cfg) = session.load_events_and_project()?;
1037
1038 // Eng-I1 / I-2(a): EXCLUDE voided allocations from the single-allocation guard, so the legitimate
1039 // allocate→inert→void→re-allocate→attest workflow (which leaves an OLD, voided allocation in the log)
1040 // is not blocked by "multiple allocations present." Build the voided-target set from `VoidDecisionEvent`s
1041 // (mirrors resolve.rs pass-1 step 1a) and keep only LIVE (non-voided) allocations.
1042 let voided: std::collections::BTreeSet<EventId> = events
1043 .iter()
1044 .filter_map(|e| match &e.payload {
1045 EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
1046 _ => None,
1047 })
1048 .collect();
1049 let allocs: Vec<(&EventId, &SafeHarborAllocation)> = events
1050 .iter()
1051 .filter(|e| !voided.contains(&e.id))
1052 .filter_map(|e| match &e.payload {
1053 EventPayload::SafeHarborAllocation(a) => Some((&e.id, a)),
1054 _ => None,
1055 })
1056 .collect();
1057 let (prior_id, prior) = match allocs.as_slice() {
1058 [one] => (one.0.clone(), one.1.clone()),
1059 [] => {
1060 return Err(CliError::Usage(
1061 "no allocation to attest; run `safe-harbor allocate` first".into(),
1062 ))
1063 }
1064 _ => {
1065 return Err(CliError::Usage(
1066 "multiple live allocations present; void the stale one before attesting".into(),
1067 ))
1068 }
1069 };
1070 if prior.timely_allocation_attested {
1071 return Err(CliError::Usage("allocation is already attested".into()));
1072 }
1073
1074 // I-2(b) / N-2: classify the prior allocation's CURRENT status via the projection loaded above
1075 // (`load_events_and_project`), reading the engine's own effectiveness verdict (the blockers it
1076 // stamps onto `prior_id`):
1077 // * `SafeHarborUnconservable` (hard) → attestation CANNOT cure it (only a corrected allocation can).
1078 // * `SafeHarborTimebar` (advisory) → inert PURELY because of the §5.02(4) bar → attestation cures it.
1079 // * neither → ALREADY EFFECTIVE → attesting would Void an effective allocation
1080 // (→ irrevocable `decision_conflicts`, §7.4) AND append a second effective allocation (→ two effective
1081 // → Path A, irrecoverable). Refuse and advise `verify` (NOT "void the effective one").
1082 let blocked_with = |k: BlockerKind| {
1083 state
1084 .blockers
1085 .iter()
1086 .any(|b| b.event.as_ref() == Some(&prior_id) && b.kind == k)
1087 };
1088 let unconservable = blocked_with(BlockerKind::SafeHarborUnconservable);
1089 let timebarred = blocked_with(BlockerKind::SafeHarborTimebar);
1090 // (closure's borrow of `prior_id` ends here, so the move into the Void below is sound.)
1091 if unconservable {
1092 return Err(CliError::Usage(
1093 "allocation fails conservation (not a time-bar); re-run `safe-harbor allocate` to rebuild it — attestation cannot cure conservation".into(),
1094 ));
1095 }
1096 if !timebarred {
1097 return Err(CliError::Usage(
1098 "allocation already effective; no attestation needed — run `verify`".into(),
1099 ));
1100 }
1101
1102 // Inert PURELY due to a time-bar → attestation cures it. Append Void(prior) + a re-attested copy.
1103 // (N2: same `now` for both; `decision_seq` orders/distinguishes them — Void first, then re-attest.)
1104 append_decision(
1105 session.conn(),
1106 EventPayload::VoidDecisionEvent(VoidDecisionEvent {
1107 target_event_id: prior_id,
1108 }),
1109 now,
1110 UtcOffset::UTC,
1111 None,
1112 )?;
1113 let attested = SafeHarborAllocation {
1114 timely_allocation_attested: true,
1115 ..prior
1116 };
1117 let id = append_decision(
1118 session.conn(),
1119 EventPayload::SafeHarborAllocation(attested),
1120 now,
1121 UtcOffset::UTC,
1122 None,
1123 )?;
1124 session.save()?;
1125 Ok(id)
1126}
1127
1128/// SE-completion Chunk C (D3): flip `business` (and optionally `kind`) on an already-imported
1129/// `Income` event. Enables SE-tax treatment for professional miners / stakers whose River (and
1130/// other adapter) income arrives with `business: false` hard-coded at ingest time.
1131///
1132/// The engine validates the target at collection time: if the referenced event does not exist OR its
1133/// effective payload is not `Income`, a Hard `DecisionConflict` blocker fires and the decision is
1134/// excluded (not silently inert, not a panic). To correct a `TransferIn` row use `classify-inbound-income`
1135/// instead. **DecisionConflict is Hard — to re-decide, `void` the prior decision then re-issue.**
1136pub fn reclassify_income(
1137 vault_path: &Path,
1138 pp: &Passphrase,
1139 income_ref: &str,
1140 business: bool,
1141 kind: Option<IncomeKind>,
1142 now: OffsetDateTime,
1143) -> Result<EventId, CliError> {
1144 let income_event = parse_event_id(income_ref)?;
1145 let mut session = Session::open(vault_path, pp)?;
1146 let payload = EventPayload::ReclassifyIncome(ReclassifyIncome {
1147 income_event,
1148 business,
1149 kind,
1150 });
1151 append_and_save(&mut session, payload, now)
1152}
1153
1154/// Chunk 3b D2: store Form 8283 Section-B donation + appraiser details in the
1155/// `donation_details` side-table for the donation identified by `event_ref`.
1156///
1157/// **[R0-M] Projected-removals validation:** the `event_ref` must resolve to a
1158/// `Removal { kind == Donation }` in the PROJECTED `state.removals` — NOT by scanning the raw
1159/// event log. A ref to a non-donation removal (Gift) or to an event that produces no removal
1160/// (e.g. an Acquire) → `CliError::Usage` with a clear message. No decision is appended; this
1161/// is a side-table write (last-write-wins upsert, like `tax_profile::set`).
1162pub fn set_donation_details(
1163 vault_path: &Path,
1164 pp: &Passphrase,
1165 event_ref: &str,
1166 details: DonationDetails,
1167) -> Result<(), CliError> {
1168 let event_id = parse_event_id(event_ref)?;
1169 let mut session = Session::open(vault_path, pp)?;
1170 let (state, _cfg) = session.project()?;
1171
1172 // [R0-M] Validate against the PROJECTED state.removals.
1173 let matched_removal = state.removals.iter().find(|r| r.event == event_id);
1174 match matched_removal {
1175 None => {
1176 return Err(CliError::Usage(format!(
1177 "not a donation / not found: {event_ref:?} does not match any removal in the \
1178 projected ledger (check removals.csv 'event' column for the correct ref)"
1179 )));
1180 }
1181 Some(r) if r.kind != RemovalKind::Donation => {
1182 return Err(CliError::Usage(format!(
1183 "not a donation: {event_ref:?} is a {:?} removal, not a Donation",
1184 r.kind
1185 )));
1186 }
1187 Some(_) => {} // confirmed Donation — proceed
1188 }
1189
1190 crate::donation_details::set(session.conn(), &event_id, &details)?;
1191 session.save()?;
1192 Ok(())
1193}
1194
1195/// Chunk 3b D2: read back stored `DonationDetails` for the donation identified by `event_ref`.
1196/// Returns `None` when no details have been stored yet. Read-only — no projection needed.
1197pub fn show_donation_details(
1198 vault_path: &Path,
1199 pp: &Passphrase,
1200 event_ref: &str,
1201) -> Result<Option<DonationDetails>, CliError> {
1202 let event_id = parse_event_id(event_ref)?;
1203 let session = Session::open(vault_path, pp)?;
1204 crate::donation_details::get(session.conn(), &event_id)
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209 use super::*;
1210 use btctax_core::event::{Acquire, BasisSource, TransferOut};
1211 use btctax_core::persistence::append_import_batch;
1212 use btctax_core::{
1213 EventId, LedgerEvent, OutflowClass, ReclassifyOutflow, Source, SourceRef, WalletId,
1214 };
1215 use btctax_store::Passphrase;
1216 use rust_decimal_macros::dec;
1217 use time::macros::date;
1218
1219 fn pp() -> Passphrase {
1220 Passphrase::new("test-pass".into())
1221 }
1222
1223 /// Build a minimal `DonationDetails` for tests (synthetic — no real PII).
1224 fn test_details() -> DonationDetails {
1225 DonationDetails {
1226 donee_name: "Test Charity".into(),
1227 donee_address: None,
1228 donee_ein: Some("12-3456789".into()),
1229 appraiser_name: "Test Appraiser".into(),
1230 appraiser_address: None,
1231 appraiser_tin: Some("987654321".into()),
1232 appraiser_ptin: None,
1233 appraiser_qualifications: Some("Certified bitcoin appraiser".into()),
1234 appraisal_date: Some(date!(2025 - 06 - 01)),
1235 fmv_method_override: None,
1236 }
1237 }
1238
1239 /// Create a vault + Acquire + TransferOut + ReclassifyOutflow(Donate). Returns
1240 /// (vault_path, donation_event_id, acquire_event_id) where donation_event_id is the
1241 /// TransferOut EventId (which becomes the Removal.event in the projected ledger).
1242 /// PRIVACY: synthetic values only.
1243 fn setup_donation_vault(dir: &tempfile::TempDir) -> (std::path::PathBuf, EventId, EventId) {
1244 use crate::Session;
1245
1246 let vault_path = dir.path().join("vault.pgp");
1247 let mut session = Session::create(&vault_path, &pp()).unwrap();
1248
1249 // Fixed timestamps (deterministic, reproducible). Both pre-2025 (< 2025-01-01 =
1250 // Unix 1_735_689_600) to stay in the Universal-pool path (no per-wallet allocation needed).
1251 // ts_acq ≈ 2023-11-14, ts_out ≈ 2024-07-03.
1252 let ts_acq = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1253 let ts_out = time::OffsetDateTime::from_unix_timestamp(1_720_000_000).unwrap();
1254
1255 // Both events use the same exchange wallet so lots can be consumed from the same pool.
1256 let wallet = WalletId::Exchange {
1257 provider: "coinbase".into(),
1258 account: "default".into(),
1259 };
1260
1261 // Acquire 2_000_000 sats at $60,000 cost basis.
1262 let acq_id = EventId::import(Source::Coinbase, SourceRef::new("in|test-acq-001"));
1263 let acq_ev = LedgerEvent {
1264 id: acq_id.clone(),
1265 utc_timestamp: ts_acq,
1266 original_tz: UtcOffset::UTC,
1267 wallet: Some(wallet.clone()),
1268 payload: EventPayload::Acquire(Acquire {
1269 sat: 2_000_000,
1270 usd_cost: dec!(60000),
1271 fee_usd: dec!(0),
1272 basis_source: BasisSource::ComputedFromCost,
1273 }),
1274 };
1275 append_import_batch(session.conn(), &[acq_ev]).unwrap();
1276
1277 // TransferOut 500_000 sats from the same wallet.
1278 let out_id = EventId::import(Source::Coinbase, SourceRef::new("out|test-donation-001"));
1279 let out_ev = LedgerEvent {
1280 id: out_id.clone(),
1281 utc_timestamp: ts_out,
1282 original_tz: UtcOffset::UTC,
1283 wallet: Some(wallet.clone()),
1284 payload: EventPayload::TransferOut(TransferOut {
1285 sat: 500_000,
1286 fee_sat: None,
1287 dest_addr: None,
1288 txid: None,
1289 }),
1290 };
1291 append_import_batch(session.conn(), &[out_ev]).unwrap();
1292
1293 // ReclassifyOutflow as Donation with explicit FMV (no price lookup needed).
1294 let classify_payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
1295 transfer_out_event: out_id.clone(),
1296 as_: OutflowClass::Donate {
1297 appraisal_required: false,
1298 },
1299 principal_proceeds_or_fmv: dec!(15000),
1300 fee_usd: None,
1301 donee: Some("Test Charity".into()),
1302 });
1303 append_decision(
1304 session.conn(),
1305 classify_payload,
1306 ts_out,
1307 UtcOffset::UTC,
1308 None,
1309 )
1310 .unwrap();
1311
1312 session.save().unwrap();
1313 (vault_path, out_id, acq_id)
1314 }
1315
1316 /// `set_donation_details` on a real Donation event stores it;
1317 /// `show_donation_details` reads it back correctly.
1318 #[test]
1319 fn set_then_show_round_trips_on_real_donation() {
1320 let dir = tempfile::tempdir().unwrap();
1321 let (vault_path, out_id, _acq_id) = setup_donation_vault(&dir);
1322
1323 // Store details.
1324 set_donation_details(&vault_path, &pp(), &out_id.canonical(), test_details()).unwrap();
1325
1326 // Read back.
1327 let stored = show_donation_details(&vault_path, &pp(), &out_id.canonical())
1328 .unwrap()
1329 .expect("details must be present");
1330 assert_eq!(stored, test_details());
1331 }
1332
1333 /// Targeting a missing ref → a clear `CliError::Usage` (not a panic).
1334 #[test]
1335 fn set_donation_details_missing_ref_is_usage_error() {
1336 let dir = tempfile::tempdir().unwrap();
1337 let (vault_path, _out_id, _acq_id) = setup_donation_vault(&dir);
1338
1339 let bogus = EventId::import(Source::Coinbase, SourceRef::new("out|no-such-event"));
1340 let err = set_donation_details(&vault_path, &pp(), &bogus.canonical(), test_details())
1341 .unwrap_err();
1342 assert!(
1343 matches!(err, CliError::Usage(_)),
1344 "expected Usage error, got: {err}"
1345 );
1346 let msg = err.to_string();
1347 assert!(
1348 msg.contains("not a donation") || msg.contains("not found"),
1349 "error must mention 'not a donation' or 'not found': {msg}"
1350 );
1351 }
1352
1353 /// Targeting the Acquire event (not a Donation removal) → a clear `CliError::Usage`.
1354 #[test]
1355 fn set_donation_details_non_donation_event_is_usage_error() {
1356 let dir = tempfile::tempdir().unwrap();
1357 let (vault_path, _out_id, acq_id) = setup_donation_vault(&dir);
1358
1359 // The Acquire event is not a Donation removal in the projected ledger.
1360 let err = set_donation_details(&vault_path, &pp(), &acq_id.canonical(), test_details())
1361 .unwrap_err();
1362 assert!(
1363 matches!(err, CliError::Usage(_)),
1364 "expected Usage error, got: {err}"
1365 );
1366 }
1367
1368 /// `show_donation_details` returns `None` before any details are stored.
1369 #[test]
1370 fn show_donation_details_returns_none_before_set() {
1371 let dir = tempfile::tempdir().unwrap();
1372 let (vault_path, out_id, _acq_id) = setup_donation_vault(&dir);
1373
1374 let stored = show_donation_details(&vault_path, &pp(), &out_id.canonical()).unwrap();
1375 assert_eq!(stored, None);
1376 }
1377
1378 /// Build a vault with a Gift removal (not a Donation). Returns (vault_path, gift_event_id).
1379 fn setup_gift_vault(dir: &tempfile::TempDir) -> (std::path::PathBuf, EventId) {
1380 use crate::Session;
1381 let vault_path = dir.path().join("gift-vault.pgp");
1382 let mut session = Session::create(&vault_path, &pp()).unwrap();
1383
1384 let ts_acq = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1385 let ts_out = time::OffsetDateTime::from_unix_timestamp(1_720_000_000).unwrap();
1386 let wallet = WalletId::Exchange {
1387 provider: "coinbase".into(),
1388 account: "default".into(),
1389 };
1390
1391 let acq_id = EventId::import(Source::Coinbase, SourceRef::new("in|gift-acq-001"));
1392 let acq_ev = LedgerEvent {
1393 id: acq_id.clone(),
1394 utc_timestamp: ts_acq,
1395 original_tz: UtcOffset::UTC,
1396 wallet: Some(wallet.clone()),
1397 payload: EventPayload::Acquire(Acquire {
1398 sat: 2_000_000,
1399 usd_cost: dec!(60000),
1400 fee_usd: dec!(0),
1401 basis_source: BasisSource::ComputedFromCost,
1402 }),
1403 };
1404 append_import_batch(session.conn(), &[acq_ev]).unwrap();
1405
1406 let out_id = EventId::import(Source::Coinbase, SourceRef::new("out|gift-001"));
1407 let out_ev = LedgerEvent {
1408 id: out_id.clone(),
1409 utc_timestamp: ts_out,
1410 original_tz: UtcOffset::UTC,
1411 wallet: Some(wallet.clone()),
1412 payload: EventPayload::TransferOut(TransferOut {
1413 sat: 500_000,
1414 fee_sat: None,
1415 dest_addr: None,
1416 txid: None,
1417 }),
1418 };
1419 append_import_batch(session.conn(), &[out_ev]).unwrap();
1420
1421 // Reclassify as GiftOut (NOT Donate)
1422 let classify_payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
1423 transfer_out_event: out_id.clone(),
1424 as_: OutflowClass::GiftOut,
1425 principal_proceeds_or_fmv: dec!(15000),
1426 fee_usd: None,
1427 donee: None,
1428 });
1429 append_decision(
1430 session.conn(),
1431 classify_payload,
1432 ts_out,
1433 UtcOffset::UTC,
1434 None,
1435 )
1436 .unwrap();
1437
1438 session.save().unwrap();
1439 (vault_path, out_id)
1440 }
1441
1442 /// `set_donation_details` targeting a Gift removal → "is a Gift removal, not a Donation" error.
1443 /// Exercises the `Some(r) if r.kind != RemovalKind::Donation` arm (previously untested).
1444 #[test]
1445 fn set_donation_details_gift_removal_is_usage_error() {
1446 let dir = tempfile::tempdir().unwrap();
1447 let (vault_path, gift_out_id) = setup_gift_vault(&dir);
1448
1449 let err =
1450 set_donation_details(&vault_path, &pp(), &gift_out_id.canonical(), test_details())
1451 .unwrap_err();
1452 assert!(
1453 matches!(err, CliError::Usage(_)),
1454 "expected Usage error, got: {err}"
1455 );
1456 let msg = err.to_string();
1457 assert!(
1458 msg.contains("not a donation") || msg.contains("Gift"),
1459 "error must mention 'not a donation' or 'Gift': {msg}"
1460 );
1461 }
1462}