Skip to main content

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, LedgerEvent, LotId, LotMethod, LotPick, LotSelection,
17    ManualFmv, MethodElection, OutflowClass, ReclassifyIncome, ReclassifyOutflow, RejectImport,
18    RemovalKind, SafeHarborAllocation, SelfTransferPassthrough, SupersedeImport, TaxDate,
19    TransferLink, 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/// UX-P4-3: refuse — AT RECORD TIME, before any append (fail-closed) — a reconcile decision the
39/// resolver would adjudicate as a NEW `DecisionConflict`: a first-wins duplicate, a wrong-type or
40/// unknown target, or a non-revocable/unknown `void`. The predicate is `btctax_core::would_conflict`
41/// (the real projection run with pseudo forced OFF), so record-time == resolver by CONSTRUCTION — a
42/// hand-rebuilt subset would drift (SPEC §3.2 mandate). The refusal carries the resolver's own reason
43/// plus the unified discoverability pointer (`events list` + `void decision|N`). Call it from the
44/// single-verb append fns AFTER the payload is built and BEFORE `append_and_save`; the bulk `apply_*`
45/// paths are deliberately OUT of scope (plan-generated, not user-typed, refs — SPEC §3.2).
46fn guard_decision_conflict(
47    session: &Session,
48    payload: &EventPayload,
49    now: OffsetDateTime,
50) -> Result<(), CliError> {
51    let events = load_all(session.conn())?;
52    let cfg = session.config()?.to_projection();
53    if let Some(detail) = btctax_core::would_conflict(&events, session.prices(), &cfg, payload, now)
54    {
55        // The resolver's `detail` is now surface-neutral and self-contained (it names `events list`
56        // and, for duplicates, the void remedy — UX-P4-3 hint unification), so the record-time layer
57        // only frames it as a refusal (nothing was appended); no second, contradictory hint.
58        return Err(CliError::Usage(format!(
59            "cannot record this decision — {detail}"
60        )));
61    }
62    Ok(())
63}
64
65/// FR6: classify an externally-sourced inbound `TransferIn` as Income or a received Gift. For Income
66/// this supplies the FMV basis; for Gift it supplies donor basis/date + fmv_at_gift (TP11 dual-basis).
67/// This is the re-supply path for the §9.1 Swan `deposit` basis GAP.
68pub fn classify_inbound(
69    vault_path: &Path,
70    pp: &Passphrase,
71    in_ref: &str,
72    class: InboundClass,
73    now: OffsetDateTime,
74) -> Result<EventId, CliError> {
75    let transfer_in_event = parse_event_id(in_ref)?;
76    let mut session = Session::open(vault_path, pp)?;
77
78    // UX-P4-4(b): a supplied acquisition date that STRICTLY post-dates the receipt is impossible —
79    // self-transferred / gifted coins cannot have been acquired after they arrived. Refuse at record
80    // time, BEFORE any decision is appended (fail-closed). The acquisition date and the receipt come
81    // from different sources (the CLI `--acquired`/`--donor-acquired` flag vs. the imported TransferIn),
82    // so the message prints the receipt date + its tz basis. Same-day is allowed (a same-day
83    // acquire→receive is legitimate, and a tz skew can already shift the calendar day by one). The
84    // receipt lives on the referenced TransferIn event; if the ref does not resolve to a TransferIn
85    // (wrong-target), the guard is skipped and the existing DecisionConflict path adjudicates.
86    let acquired_override: Option<(&str, TaxDate)> = match &class {
87        InboundClass::SelfTransferMine { acquired_at, .. } => {
88            acquired_at.map(|d| ("--acquired", d))
89        }
90        InboundClass::GiftReceived {
91            donor_acquired_at, ..
92        } => donor_acquired_at.map(|d| ("--donor-acquired", d)),
93        InboundClass::Income { .. } => None,
94    };
95    if let Some((flag, acquired)) = acquired_override {
96        let events = load_all(session.conn())?;
97        if let Some(ev) = events.iter().find(|e| e.id == transfer_in_event) {
98            if let EventPayload::TransferIn(_) = &ev.payload {
99                let receipt = tax_date(ev.utc_timestamp, ev.original_tz);
100                if acquired > receipt {
101                    return Err(CliError::Usage(format!(
102                        "{flag} {acquired} is after the receipt date {receipt} (recorded in {tz}); \
103                         coins cannot be acquired after they are received. Same-day is allowed.",
104                        tz = tz_label(ev.original_tz),
105                    )));
106                }
107            }
108        }
109    }
110
111    let payload = EventPayload::ClassifyInbound(ClassifyInbound {
112        transfer_in_event,
113        as_: class,
114    });
115    guard_decision_conflict(&session, &payload, now)?;
116    append_and_save(&mut session, payload, now)
117}
118
119/// Render a `UtcOffset` as a human tz basis for record-time messages: `UTC` for the zero offset,
120/// otherwise `UTC±HH:MM` (UX-P4-4(b)). Kept dependency-free (no `time` `formatting` feature) via
121/// `as_hms`, so it also works when the offset carries seconds (rendered at minute resolution).
122fn tz_label(tz: UtcOffset) -> String {
123    let (h, m, _s) = tz.as_hms();
124    if h == 0 && m == 0 {
125        "UTC".to_string()
126    } else {
127        let sign = if h < 0 || m < 0 { '-' } else { '+' };
128        format!("UTC{sign}{:02}:{:02}", h.unsigned_abs(), m.unsigned_abs())
129    }
130}
131
132/// FR6: reclassify a pending `TransferOut` as a Sell/Spend disposition, a Gift out, or a Donation.
133/// `principal` is the gross proceeds (Dispose) or FMV-at-transfer (Gift/Donate); `fee_usd` is the
134/// optional disposition fee (TP8 / TP2). The engine applies the configured TP8 (c)/(b) fee treatment.
135/// `donee` is the optional free-form donee identifier (Chunk 2); `None` for disposals and legacy records.
136#[allow(clippy::too_many_arguments)]
137pub fn reclassify_outflow(
138    vault_path: &Path,
139    pp: &Passphrase,
140    out_ref: &str,
141    class: OutflowClass,
142    principal: Usd,
143    fee_usd: Option<Usd>,
144    donee: Option<String>,
145    now: OffsetDateTime,
146) -> Result<EventId, CliError> {
147    let transfer_out_event = parse_event_id(out_ref)?;
148    let mut session = Session::open(vault_path, pp)?;
149
150    let payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
151        transfer_out_event: transfer_out_event.clone(),
152        as_: class,
153        principal_proceeds_or_fmv: principal,
154        fee_usd,
155        donee,
156    });
157    // UX-P4-3 × UX-P4-4(d) (whole-branch review Minor #1): run the record-time conflict guard FIRST, so a
158    // refused decision returns HERE and the "recording it as entered" `--amount` advisory below is never
159    // printed for a decision that is then refused (the contradictory message pair). The advisory is
160    // reached only once the decision is accepted.
161    guard_decision_conflict(&session, &payload, now)?;
162
163    // UX-P4-4(d): a price-based sanity check on `--amount` (the USD FMV / proceeds). Emit the advisory
164    // (if any) to stderr — non-fatal; the amount is recorded as entered. Resolve the outflow's sats +
165    // date from its TransferOut event and the market value from the event-date close.
166    {
167        let events = load_all(session.conn())?;
168        if let Some(ev) = events.iter().find(|e| e.id == transfer_out_event) {
169            if let EventPayload::TransferOut(out) = &ev.payload {
170                let date = tax_date(ev.utc_timestamp, ev.original_tz);
171                let market_value = btctax_core::price::fmv_of(session.prices(), date, out.sat);
172                let btc = Usd::from(out.sat) / Usd::from(100_000_000);
173                if let Some(line) = amount_fmv_advisory(principal, market_value, btc, date) {
174                    eprintln!("{line}");
175                }
176            }
177        }
178    }
179
180    append_and_save(&mut session, payload, now)
181}
182
183/// UX-P4-4(d): the stderr line (if any) for the `--amount` FMV sanity check. `market_value` is
184/// `fmv_of(prices, event_date, out_sats)` — the USD value of the disposed BTC at the event-date
185/// close (`None` when that date has no price). The FMV of donated BTC is its value AT the
186/// contribution date (26 CFR 1.170A-1(c)(1)), so the yardstick is that close, NOT cost basis — a
187/// $0/low-basis long-held gift is the common case, and a basis threshold would false-warn on it.
188///
189/// - `amount` exceeding **100x** a positive market value is almost certainly the sats count typed
190///   into a dollars field (`sat ≈ 1e8·BTC` vs. `value ≈ BTC·close`, a ratio ≈ `1e8/close`): WARN.
191/// - a plausible amount (≤ 100x), or a dust market value that rounds to $0 (no meaningful ratio):
192///   no line.
193/// - no price for the date: the check cannot run — emit a NOTE, so the guard never dies silently.
194///
195/// Pure (all I/O — price lookup, stderr — is the caller's) so the decision is unit-testable.
196fn amount_fmv_advisory(
197    amount: Usd,
198    market_value: Option<Usd>,
199    btc: Usd,
200    date: TaxDate,
201) -> Option<String> {
202    match market_value {
203        Some(mv) if mv > Usd::ZERO && amount > mv * Usd::from(100) => Some(format!(
204            "warning: --amount ${amount} is more than 100x the ${mv} market value of {btc} BTC at \
205             the {date} close — did you enter the sats amount as dollars? --amount is the USD \
206             proceeds/FMV; recording it as entered (not fatal)."
207        )),
208        Some(_) => None, // plausible amount, or dust whose market value rounds to $0
209        None => Some(format!(
210            "note: no BTC price for {date}; skipping the --amount FMV sanity check."
211        )),
212    }
213}
214
215/// FR3: set a manual FMV on an event (`ManualEntry`), clearing its `fmv_missing` blocker.
216pub fn set_fmv(
217    vault_path: &Path,
218    pp: &Passphrase,
219    event_ref: &str,
220    usd_fmv: Usd,
221    now: OffsetDateTime,
222) -> Result<EventId, CliError> {
223    let event = parse_event_id(event_ref)?;
224    let mut session = Session::open(vault_path, pp)?;
225    // UX-P4-3: `set-fmv` is exempt from the DUPLICATE refusal ONLY (ManualFmv is last-wins — re-pointing
226    // an FMV is a sanctioned correction, so the resolver raises no conflict on a second one) but STILL
227    // gets existence/type validation. `would_conflict` gives exactly this for free: a `set-fmv` on an
228    // unknown or non-Income target IS a new DecisionConflict the resolver excludes → refused here.
229    let payload = EventPayload::ManualFmv(ManualFmv { event, usd_fmv });
230    guard_decision_conflict(&session, &payload, now)?;
231    append_and_save(&mut session, payload, now)
232}
233
234/// BG-D9 Task 8 (arch/tax r1 I-3): the prior-year fold-diff advisory in the VOID direction, or `Vec::new()`
235/// when the void `target` is not an EFFECTIVE promote void. Voiding a `PromoteTranche` decision reverts a
236/// filed floor basis toward `$0`, which can HIFO-reorder a PRIOR filed year's 8949/8283 (amend-to-PAY).
237/// Non-gating; the caller PRINTS it BEFORE recording the void. A single stored `profile` cannot fit the
238/// multi-year span, so `None` is passed (the tax-Δ arm falls back to the gain/deduction Δ sign — the amend
239/// direction is still correct). `now` (the void's own injected creation-time) supplies the advisory's
240/// `current` tax-year cutoff (Task 10 handoff) — the BTCTAX_NOW seam, never a wall clock.
241fn promote_void_advisory_lines(
242    session: &Session,
243    events: &[LedgerEvent],
244    target_event_id: &EventId,
245    now: OffsetDateTime,
246) -> Vec<String> {
247    // Only an EFFECTIVE void reverts a filed floor: the void `target` IS a `PromoteTranche` decision.
248    // The DeclareTranche-with-live-promote void does NOT reach here — it is refused UPSTREAM at record
249    // time by `guard_decision_conflict`/`would_conflict` (the engine holds the tranche in force via its
250    // live promote → an inert `DecisionConflict`; record-time == resolver by construction), and the
251    // bulk/TUI void plans exclude it via `voidable_decisions`' `promoted_target` filter. So this fn only
252    // ever sees the effective PromoteTranche-target void — never a DeclareTranche target (arch r1 M-3: the
253    // former `.or_else` DeclareTranche path was unreachable dead code that would have printed a reversion
254    // + amend advisory for a void that changes nothing).
255    let promote_id = events
256        .iter()
257        .find(|e| e.id == *target_event_id && matches!(e.payload, EventPayload::PromoteTranche(_)))
258        .map(|e| e.id.clone());
259    let Some(pid) = promote_id else {
260        return Vec::new();
261    };
262    let Ok(cfg) = session.config().map(|c| c.to_projection()) else {
263        return Vec::new();
264    };
265    let tables = btctax_adapters::BundledTaxTables::load();
266    let current = tax_date(now, UtcOffset::UTC).year();
267    btctax_core::conservative::promote_prior_year_advisory(
268        events,
269        session.prices(),
270        &cfg,
271        &pid,
272        btctax_core::conservative::Direction::Void,
273        None,
274        &tables,
275        current,
276    )
277}
278
279/// FR8: void a revocable decision. Voiding a non-revocable / effective-allocation target raises
280/// `decision_conflicts` in the projection (no effect) — the CLI only appends; the engine adjudicates.
281///
282/// When the voided decision is a `LotSelection`, also clears that disposal's `optimize_attestation`
283/// row ATOMICALLY (same in-memory DB, one `session.save()`). This closes the revocation-completeness
284/// edge: without the clear, a post-void re-run where the FIFO default equals the optimum
285/// (`proposed==current`, D ∈ unchanged) could mislabel D as `AttestedRecording` from a stale row
286/// the user never attested in this context. Non-LotSelection decisions are unaffected (the
287/// `optimize_attestation` table has no row to clear — the delete is a no-op).
288pub fn void(
289    vault_path: &Path,
290    pp: &Passphrase,
291    target_ref: &str,
292    now: OffsetDateTime,
293) -> Result<EventId, CliError> {
294    let target_event_id = parse_event_id(target_ref)?;
295    let mut session = Session::open(vault_path, pp)?;
296
297    // Determine if the target decision is a LotSelection so we can clear its attestation row.
298    // Load events first; the find is O(n) but n is small and void is infrequent.
299    let events = load_all(session.conn())?;
300    let disposal_to_clear: Option<EventId> = events
301        .iter()
302        .find(|e| e.id == target_event_id)
303        .and_then(|e| match &e.payload {
304            EventPayload::LotSelection(ls) => Some(ls.disposal_event.clone()),
305            _ => None,
306        });
307    // Symmetric side-effect [WB]: voiding a ReclassifyOutflow must clear its `bulk_estimated` `[est]`
308    // flag (keyed by transfer_out_event == Disposal.event) — else a stale marker survives a
309    // void→re-reclassify (the R0-I1 gap, closed on the CLI path too, mirroring optimize_attest above).
310    let reclass_out_to_clear: Option<EventId> = events
311        .iter()
312        .find(|e| e.id == target_event_id)
313        .and_then(|e| match &e.payload {
314            EventPayload::ReclassifyOutflow(ro) => Some(ro.transfer_out_event.clone()),
315            _ => None,
316        });
317
318    // UX-P4-3: refuse a void the resolver would adjudicate as a NEW DecisionConflict — a non-revocable
319    // target (SupersedeImport/RejectImport/VoidDecisionEvent) or an unknown target.
320    guard_decision_conflict(
321        &session,
322        &EventPayload::VoidDecisionEvent(VoidDecisionEvent {
323            target_event_id: target_event_id.clone(),
324        }),
325        now,
326    )?;
327    // Plus an explicit already-voided refusal: the resolver treats a double-void of a REVOCABLE target
328    // as idempotent (no new conflict), but the SPEC refuses it at record time — a live VoidDecisionEvent
329    // already names this target.
330    if events.iter().any(|e| {
331        matches!(&e.payload,
332            EventPayload::VoidDecisionEvent(v) if v.target_event_id == target_event_id)
333    }) {
334        return Err(CliError::Usage(format!(
335            "cannot record this decision — {} is already voided — see `btctax events list` for event \
336             refs + decision status",
337            target_event_id.canonical()
338        )));
339    }
340
341    // BG-D9 Task 8 (arch/tax r1 I-3): if this void removes a live promote (or targets a promoted tranche),
342    // WARN — before recording, non-gating — about the PRIOR-year 8949/8283 rewrites the floor→$0 revert
343    // re-exposes (an amend-to-pay warning). Printed only when the target actually has a live promote.
344    for line in promote_void_advisory_lines(&session, &events, &target_event_id, now) {
345        println!("{line}");
346    }
347
348    // Append the VoidDecisionEvent (no save yet — we batch with the attestation clear below).
349    let id = append_decision(
350        session.conn(),
351        EventPayload::VoidDecisionEvent(VoidDecisionEvent { target_event_id }),
352        now,
353        UtcOffset::UTC,
354        None,
355    )?;
356
357    // If the voided decision was a LotSelection, clear the attestation row for its disposal
358    // ATOMICALLY — both the void event and the row delete land in the same in-memory Connection
359    // and are flushed together by the single `session.save()` below (mirrors accept's atomic
360    // co-persist). Idempotent: clearing an absent row is Ok (no error).
361    if let Some(disposal) = disposal_to_clear {
362        crate::optimize_attest::clear(session.conn(), &disposal)?;
363    }
364    if let Some(out_event) = reclass_out_to_clear {
365        crate::bulk_estimated::clear(session.conn(), &out_event)?;
366    }
367
368    session.save()?;
369    Ok(id)
370}
371
372/// Pseudo-reconcile mode toggle (sub-project 2). Persists the `pseudo_reconcile` flag in `cli_config`
373/// (a projection input parameter, NOT ledger state — NFR6). `on = true` ⇒ projection synthesizes
374/// non-persisted default decisions to clear the Hard classification blockers; `off` reverts to real-only
375/// instantly (no fictional events were ever written). Returns the new flag value.
376pub fn pseudo_set_mode(vault_path: &Path, pp: &Passphrase, on: bool) -> Result<bool, CliError> {
377    let mut session = Session::open(vault_path, pp)?;
378    crate::config::set_pseudo_reconcile(session.conn(), on)?;
379    session.save()?;
380    Ok(on)
381}
382
383/// One row of a `reconcile pseudo approve` preview (sub-project 2, T5). `target` is the imported event the
384/// synthetic governs; `kind` is the default TYPE; `wallet`/`year` are the target's provenance (for the
385/// filter + preview). Deterministic order (mirrors `pseudo_plan`).
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct PseudoApproveRow {
388    pub target: EventId,
389    pub kind: btctax_core::PseudoKind,
390    pub wallet: Option<WalletId>,
391    pub year: Option<i32>,
392}
393
394/// The `--kind`/`--wallet`/`--year` filter for `reconcile pseudo approve`. `None` fields = no restriction.
395#[derive(Debug, Clone, Default)]
396pub struct PseudoApproveFilter {
397    pub kind: Option<btctax_core::PseudoKind>,
398    pub wallet: Option<WalletId>,
399    pub year: Option<i32>,
400}
401
402/// Compute the pseudo-default plan and KEEP only the entries matching `filter`, paired with the target's
403/// (wallet, tax-year) provenance. Shared by the read (`pseudo_approve_plan`) and write
404/// (`apply_bulk_pseudo_approve`) phases so the preview and the apply are guaranteed identical (the
405/// own-loop RE-derives — never trusts a threaded list). `plan` order is `pseudo_plan`'s stable order [N2].
406fn filtered_pseudo_plan<'a>(
407    plan: &'a [btctax_core::PseudoDefault],
408    index: &std::collections::HashMap<&EventId, &btctax_core::LedgerEvent>,
409    filter: &PseudoApproveFilter,
410) -> Vec<(
411    &'a btctax_core::PseudoDefault,
412    Option<WalletId>,
413    Option<i32>,
414)> {
415    plan.iter()
416        .filter_map(|pd| {
417            let ev = index.get(&pd.target);
418            let wallet = ev.and_then(|e| e.wallet.clone());
419            let year = ev.map(|e| tax_date(e.utc_timestamp, e.original_tz).year());
420            if let Some(k) = filter.kind {
421                if pd.kind != k {
422                    return None;
423                }
424            }
425            if let Some(want) = &filter.wallet {
426                if wallet.as_ref() != Some(want) {
427                    return None;
428                }
429            }
430            if let Some(y) = filter.year {
431                if year != Some(y) {
432                    return None;
433                }
434            }
435            Some((pd, wallet, year))
436        })
437        .collect()
438}
439
440/// Pseudo-approve — Phase 1 (read): compute the filtered plan of pseudo defaults that WOULD be promoted to
441/// real decisions. Renders NOTHING to the vault (the confirmation stays a thin shell in `main.rs`).
442pub fn pseudo_approve_plan(
443    vault_path: &Path,
444    pp: &Passphrase,
445    filter: PseudoApproveFilter,
446) -> Result<Vec<PseudoApproveRow>, CliError> {
447    let session = Session::open(vault_path, pp)?;
448    let prices = session.prices();
449    let events = load_all(session.conn())?;
450    let cfg = session.config()?.to_projection();
451    let plan = btctax_core::pseudo_plan(&events, prices, &cfg);
452    let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
453        events.iter().map(|e| (&e.id, e)).collect();
454    Ok(filtered_pseudo_plan(&plan, &index, &filter)
455        .into_iter()
456        .map(|(pd, wallet, year)| PseudoApproveRow {
457            target: pd.target.clone(),
458            kind: pd.kind,
459            wallet,
460            year,
461        })
462        .collect())
463}
464
465/// Pseudo-approve — Phase 2 (write): materialize the filtered pseudo defaults as REAL (attested) decisions
466/// via btctax-cli's OWN append-loop [R0-M4] (mirrors `apply_bulk_classify_inbound_income`; the CLI CANNOT
467/// reach the tui-edit `persist_bulk_decisions` — dependency cycle, Cargo.toml:19). Empty-guard (no matches
468/// ⇒ NO save); bare `?`-before-`save` (a mid-batch failure returns before `save`, discarding the in-memory
469/// session = CLI atomicity); a SINGLE `save`. Defense-in-depth: RE-derives the plan here (never trusts a
470/// threaded list). After approval these decisions are REAL → the next projection resolves them via the real
471/// decision path, so pseudo mode injects NO synthetic for them (no longer `[PSEUDO]`). Returns the count.
472pub fn apply_bulk_pseudo_approve(
473    vault_path: &Path,
474    pp: &Passphrase,
475    filter: PseudoApproveFilter,
476    now: OffsetDateTime,
477) -> Result<usize, CliError> {
478    let mut session = Session::open(vault_path, pp)?;
479    let prices = session.prices();
480    let events = load_all(session.conn())?;
481    let cfg = session.config()?.to_projection();
482    let plan = btctax_core::pseudo_plan(&events, prices, &cfg);
483    let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
484        events.iter().map(|e| (&e.id, e)).collect();
485    let selected = filtered_pseudo_plan(&plan, &index, &filter);
486    // Empty-guard: nothing to approve ⇒ do NOT touch the vault.
487    if selected.is_empty() {
488        return Ok(0);
489    }
490    let mut n = 0usize;
491    for (pd, _wallet, _year) in &selected {
492        // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
493        // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK).
494        append_decision(
495            session.conn(),
496            pd.decision.clone(),
497            now,
498            UtcOffset::UTC,
499            None,
500        )?;
501        n += 1;
502    }
503    session.save()?;
504    Ok(n)
505}
506
507/// FR2/§7.3: resolve an `Unclassified` row to a real imported payload (preserving the target EventId).
508/// The payload is supplied as JSON (`EventPayload` is `Deserialize`) — e.g. `{"Acquire":{…}}`.
509pub fn classify_raw(
510    vault_path: &Path,
511    pp: &Passphrase,
512    target_ref: &str,
513    payload_json: &str,
514    now: OffsetDateTime,
515) -> Result<EventId, CliError> {
516    let target = parse_event_id(target_ref)?;
517    let as_: EventPayload = serde_json::from_str(payload_json)
518        .map_err(|e| CliError::Usage(format!("bad --payload-json: {e}")))?;
519    if !as_.is_imported() {
520        return Err(CliError::Usage(
521            "classify-raw payload must be an imported variant (Acquire/Income/Dispose/TransferOut/TransferIn/Unclassified)".into(),
522        ));
523    }
524    let mut session = Session::open(vault_path, pp)?;
525    let payload = EventPayload::ClassifyRaw(ClassifyRaw {
526        target,
527        as_: Box::new(as_),
528    });
529    guard_decision_conflict(&session, &payload, now)?;
530    append_and_save(&mut session, payload, now)
531}
532
533/// FR1/FR8: accept an `ImportConflict` (apply the new payload to the target, keeping its EventId).
534pub fn accept_conflict(
535    vault_path: &Path,
536    pp: &Passphrase,
537    conflict_ref: &str,
538    now: OffsetDateTime,
539) -> Result<EventId, CliError> {
540    let conflict_event = parse_event_id(conflict_ref)?;
541    let mut session = Session::open(vault_path, pp)?;
542    append_and_save(
543        &mut session,
544        EventPayload::SupersedeImport(SupersedeImport { conflict_event }),
545        now,
546    )
547}
548
549/// FR1/FR8: reject an `ImportConflict` (keep the original; clear the blocker).
550pub fn reject_conflict(
551    vault_path: &Path,
552    pp: &Passphrase,
553    conflict_ref: &str,
554    now: OffsetDateTime,
555) -> Result<EventId, CliError> {
556    let conflict_event = parse_event_id(conflict_ref)?;
557    let mut session = Session::open(vault_path, pp)?;
558    append_and_save(
559        &mut session,
560        EventPayload::RejectImport(RejectImport { conflict_event }),
561        now,
562    )
563}
564
565/// bulk-link-transfer D2 — Phase 1 (read): open the session and compute the bulk link-transfer plan.
566///
567/// Two-phase by design [R0-M2]: this read phase opens/renders NOTHING to the vault, so the
568/// interactive `y/N` confirmation stays a thin, untested shell in the `main.rs` dispatch. The plan is
569/// the shared read helper `Session::bulk_link_transfer_plan` (D1). The session (and its VaultLock) is
570/// dropped on return, before the confirmation prompt runs.
571pub fn bulk_link_plan(
572    vault_path: &Path,
573    pp: &Passphrase,
574    filter: BulkFilter,
575    dest: WalletId,
576) -> Result<BulkLinkPlan, CliError> {
577    let session = Session::open(vault_path, pp)?;
578    session.bulk_link_transfer_plan(filter, dest)
579}
580
581/// bulk-link-transfer D2 — Phase 2 (write): atomically link every `out_event` to `dest` as a
582/// self-transfer. Appends one `TransferLink { out_event, Wallet(dest) }` per row, then a SINGLE
583/// `save`. All-or-nothing: a mid-batch `append_decision` failure returns `Err` BEFORE the save, and
584/// the local `Session` is dropped with nothing written — the exact one-session / N-append / one-save
585/// atomicity of `import_selections`. Returns the number of outflows linked.
586pub fn apply_bulk_link_transfer(
587    vault_path: &Path,
588    pp: &Passphrase,
589    out_events: Vec<EventId>,
590    dest: WalletId,
591    now: OffsetDateTime,
592) -> Result<usize, CliError> {
593    let mut session = Session::open(vault_path, pp)?;
594    for out_event in &out_events {
595        let payload = EventPayload::TransferLink(TransferLink {
596            out_event: out_event.clone(),
597            in_event_or_wallet: TransferTarget::Wallet(dest.clone()),
598        });
599        // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
600        // nothing lands on disk (CLI atomicity; the TUI path must instead ROLL BACK, see D3 [R0-I1]).
601        append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
602    }
603    session.save()?;
604    Ok(out_events.len())
605}
606
607/// bulk-classify-inbound-self-transfer D2 — Phase 1 (read): open the session and compute the bulk STI
608/// plan. Two-phase by design (mirrors `bulk_link_plan`): this read phase renders NOTHING to the vault,
609/// so the interactive `y/N` confirmation stays a thin, untested shell in the `main.rs` dispatch. The
610/// plan is the shared read helper `Session::bulk_self_transfer_in_plan` (D1). The session (and its
611/// VaultLock) is dropped on return, before the confirmation prompt runs.
612pub fn bulk_self_transfer_in_plan(
613    vault_path: &Path,
614    pp: &Passphrase,
615    filter: BulkStiFilter,
616) -> Result<BulkStiPlan, CliError> {
617    let session = Session::open(vault_path, pp)?;
618    session.bulk_self_transfer_in_plan(filter)
619}
620
621/// bulk-classify-inbound-self-transfer D2 — Phase 2 (write): atomically classify every `in_event` as
622/// a `SelfTransferMine { basis: None, acquired_at: None }` ($0 conservative basis, non-taxable).
623/// Appends one `ClassifyInbound { transfer_in_event, SelfTransferMine{None, None} }` per row, then a
624/// SINGLE `save`. All-or-nothing: a mid-batch `append_decision` failure returns `Err` BEFORE the save,
625/// and the local `Session` is dropped with nothing written — the exact one-session / N-append /
626/// one-save atomicity of `apply_bulk_link_transfer`. Returns the number of inbounds classified.
627pub fn apply_bulk_self_transfer_in(
628    vault_path: &Path,
629    pp: &Passphrase,
630    in_events: Vec<EventId>,
631    now: OffsetDateTime,
632) -> Result<usize, CliError> {
633    let mut session = Session::open(vault_path, pp)?;
634    for in_event in &in_events {
635        let payload = EventPayload::ClassifyInbound(ClassifyInbound {
636            transfer_in_event: in_event.clone(),
637            as_: InboundClass::SelfTransferMine {
638                basis: None,
639                acquired_at: None,
640            },
641        });
642        // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
643        // nothing lands on disk (CLI atomicity; the TUI path must instead ROLL BACK, see D3).
644        append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
645    }
646    session.save()?;
647    Ok(in_events.len())
648}
649
650/// bulk-classify-inbound-income (Cycle 4) — Phase 1 (read): open the session and compute the bulk
651/// classify-income plan. Two-phase by design (mirrors `bulk_self_transfer_in_plan`): this read phase
652/// renders NOTHING to the vault. The plan is the shared read helper `Session::bulk_classify_income_plan`
653/// — it excludes missing-price rows [#a tax-safety] and reports them as `excluded_missing_price`.
654pub fn bulk_classify_income_plan(
655    vault_path: &Path,
656    pp: &Passphrase,
657    filter: crate::BulkIncomeFilter,
658) -> Result<crate::BulkIncomePlan, CliError> {
659    let session = Session::open(vault_path, pp)?;
660    session.bulk_classify_income_plan(filter)
661}
662
663/// bulk-classify-inbound-income (Cycle 4) — Phase 2 (write): atomically classify every `in_event` as
664/// `Income { kind, fmv, business }` with a PER-ROW auto-FMV. Its OWN append-loop (mirrors
665/// `apply_bulk_self_transfer_in`; NOT the tui-edit `persist_bulk_decisions`, which btctax-cli cannot
666/// reach — dependency cycle, R0-I1). One `ClassifyInbound { transfer_in_event, Income{..} }` per row,
667/// bare `?`-before-`save` (a mid-batch failure returns before `save` → the in-memory session is
668/// discarded, nothing lands on disk = CLI atomicity), then a SINGLE `session.save()`.
669///
670/// **[#a] Auto-FMV is resolved per-row via `fmv_of(date, sat)`.** The dispatch derives `in_events`
671/// from `plan.included` (the fmv-`Some` rows only), so every id here resolves to a real price and the
672/// `fmv: Option<Usd>` field is `Some` — a persisted `Income{fmv:None}` (→ Hard `FmvMissing` year-gate)
673/// is never emitted. `kind` + `business` are UNIFORM across the batch; `fmv` is per-row. Returns the
674/// number classified.
675pub fn apply_bulk_classify_inbound_income(
676    vault_path: &Path,
677    pp: &Passphrase,
678    in_events: Vec<EventId>,
679    kind: IncomeKind,
680    business: bool,
681    now: OffsetDateTime,
682) -> Result<usize, CliError> {
683    let mut session = Session::open(vault_path, pp)?;
684    let prices = session.prices();
685    let events = load_all(session.conn())?;
686    let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
687        events.iter().map(|e| (&e.id, e)).collect();
688    // Resolve (sat, date) per in_event from the event log (the FMV is a per-row auto-value; R0-M3:
689    // `fmv` is already Option<Usd> so it takes `fmv_of(..)` DIRECTLY — never `Some(fmv_of(..))`).
690    let mut n = 0usize;
691    for in_event in &in_events {
692        let Some(ev) = index.get(in_event) else {
693            continue;
694        };
695        let EventPayload::TransferIn(ti) = &ev.payload else {
696            continue;
697        };
698        let date = btctax_core::conventions::tax_date(ev.utc_timestamp, ev.original_tz);
699        // #a defense-in-depth: a missing-price row must NEVER be emitted as `Income { fmv: None }`
700        // (→ Hard `FmvMissing` year-gate, unrecoverable without void+reclassify). The plan already
701        // excludes these; skipping here makes `Income{fmv:None}` STRUCTURALLY unreachable from this
702        // `pub` apply even if a future caller passed a non-plan-filtered id.
703        let Some(fmv) = btctax_core::price::fmv_of(prices, date, ti.sat) else {
704            continue;
705        };
706        let payload = EventPayload::ClassifyInbound(ClassifyInbound {
707            transfer_in_event: in_event.clone(),
708            as_: InboundClass::Income {
709                kind,
710                fmv: Some(fmv),
711                business,
712            },
713        });
714        // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
715        // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK).
716        append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
717        n += 1;
718    }
719    session.save()?;
720    Ok(n)
721}
722
723/// bulk-reclassify-outflow (Cycle 5) — Phase 1 (read): open the session and compute the bulk
724/// reclassify-outflow plan. Two-phase by design (mirrors `bulk_classify_income_plan`): this read phase
725/// renders NOTHING to the vault. The plan is the shared read helper `Session::bulk_reclassify_outflow_plan`
726/// — it excludes missing-price rows [#a tax-safety] and reports them as `excluded_missing_price`.
727pub fn bulk_reclassify_outflow_plan(
728    vault_path: &Path,
729    pp: &Passphrase,
730    filter: BulkFilter,
731) -> Result<BulkReclassifyOutflowPlan, CliError> {
732    let session = Session::open(vault_path, pp)?;
733    session.bulk_reclassify_outflow_plan(filter)
734}
735
736/// bulk-reclassify-outflow (Cycle 5) — Phase 2 (write): atomically reclassify every `out_event` as a
737/// `Dispose{kind}` with a PER-ROW auto-FMV as ESTIMATED proceeds, AND mark each in the `bulk_estimated`
738/// side-table, then a SINGLE `save`. Its OWN append-loop (mirrors `apply_bulk_classify_inbound_income`;
739/// the CLI CANNOT reach the tui-edit `persist_bulk_decisions` — dependency cycle, R0-I1 of Cycle 4).
740///
741/// **[#a defense-in-depth]** the fmv is RE-DERIVED per-row via `fmv_of(date, sat)`, never trusting a
742/// threaded number: `let Some(fmv) = … else { continue };`. The dispatch derives `out_events` from
743/// `plan.included` (the fmv-`Some` rows only), so every id resolves — and a missing-price row can NEVER
744/// be emitted as a Sell with fabricated `0`/bogus proceeds (a SILENT misreport), even if a future caller
745/// passed a non-plan id. `fee_usd: None` always (the on-chain `fee_sat` still flows via resolve.rs
746/// regardless — a uniform USD disposition-fee across heterogeneous txns is meaningless); `donee: None`
747/// (Dispose has none). `kind` is UNIFORM across the batch. Bare `?`-before-`save`: a mid-batch failure
748/// returns before `save` → the in-memory session is discarded, so NEITHER the appends NOR the side-table
749/// marks land [R0-M2]. Returns the number reclassified.
750pub fn apply_bulk_reclassify_outflow(
751    vault_path: &Path,
752    pp: &Passphrase,
753    out_events: Vec<EventId>,
754    kind: DisposeKind,
755    now: OffsetDateTime,
756) -> Result<usize, CliError> {
757    let mut session = Session::open(vault_path, pp)?;
758    let prices = session.prices();
759    let events = load_all(session.conn())?;
760    let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
761        events.iter().map(|e| (&e.id, e)).collect();
762    // Provenance stamp (the batch made-date) recorded on each side-table row.
763    let marked_at = tax_date(now, UtcOffset::UTC).to_string();
764    let mut n = 0usize;
765    for out_event in &out_events {
766        let Some(ev) = index.get(out_event) else {
767            continue;
768        };
769        let EventPayload::TransferOut(t) = &ev.payload else {
770            continue;
771        };
772        let date = tax_date(ev.utc_timestamp, ev.original_tz);
773        // #a defense-in-depth: re-derive the fmv; a missing-price row is skipped, NEVER emitted as a
774        // Dispose with fabricated proceeds. Makes a fabricated-proceeds Sell structurally unreachable.
775        let Some(fmv) = btctax_core::price::fmv_of(prices, date, t.sat) else {
776            continue;
777        };
778        let payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
779            transfer_out_event: out_event.clone(),
780            as_: OutflowClass::Dispose { kind },
781            principal_proceeds_or_fmv: fmv,
782            fee_usd: None,
783            donee: None,
784        });
785        // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
786        // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK).
787        append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
788        // Flag the ESTIMATED proceeds in the side-table (same in-memory conn, flushed by the single
789        // save below; discarded together with the append on any mid-batch failure — [R0-M2]).
790        crate::bulk_estimated::mark(session.conn(), out_event, &marked_at)?;
791        n += 1;
792    }
793    session.save()?;
794    Ok(n)
795}
796
797/// bulk-resolve-conflict D2 — Phase 1 (read): open the session and compute the bulk resolve-conflict
798/// plan. Two-phase by design (mirrors `bulk_link_plan`): this read phase renders NOTHING to the vault,
799/// so the interactive `y/N` confirmation stays a thin, untested shell in the `main.rs` dispatch. The
800/// plan is the shared read helper `Session::bulk_resolve_conflict_plan` (D1). The session (and its
801/// VaultLock) is dropped on return, before the confirmation prompt runs.
802pub fn bulk_resolve_conflict_plan(
803    vault_path: &Path,
804    pp: &Passphrase,
805) -> Result<BulkResolvePlan, CliError> {
806    let session = Session::open(vault_path, pp)?;
807    session.bulk_resolve_conflict_plan()
808}
809
810/// bulk-resolve-conflict D2 — Phase 2 (write), ACCEPT: atomically append one `SupersedeImport` per
811/// conflict (adopt each `new_payload` onto its target id), then a SINGLE `save`. All-or-nothing: a
812/// mid-batch `append_decision` failure returns `Err` BEFORE the save, and the local `Session` is
813/// dropped with nothing written — the exact one-session / N-append / one-save atomicity of
814/// `apply_bulk_link_transfer`. Mirrors the shipped single-item split `accept_conflict`/`reject_conflict`
815/// [R0-I1 — NO `ResolveKind` in the CLI; it lives only in btctax-tui-edit]. Returns the number accepted.
816pub fn apply_bulk_accept_conflicts(
817    vault_path: &Path,
818    pp: &Passphrase,
819    conflict_events: Vec<EventId>,
820    now: OffsetDateTime,
821) -> Result<usize, CliError> {
822    let mut session = Session::open(vault_path, pp)?;
823    for conflict_event in &conflict_events {
824        let payload = EventPayload::SupersedeImport(SupersedeImport {
825            conflict_event: conflict_event.clone(),
826        });
827        // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
828        // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK via persist_bulk_decisions).
829        append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
830    }
831    session.save()?;
832    Ok(conflict_events.len())
833}
834
835/// bulk-resolve-conflict D2 — Phase 2 (write), REJECT: atomically append one `RejectImport` per
836/// conflict (keep each target's current payload; clear the blocker), then a SINGLE `save`. Same
837/// all-or-nothing CLI atomicity as `apply_bulk_accept_conflicts`. Returns the number rejected.
838pub fn apply_bulk_reject_conflicts(
839    vault_path: &Path,
840    pp: &Passphrase,
841    conflict_events: Vec<EventId>,
842    now: OffsetDateTime,
843) -> Result<usize, CliError> {
844    let mut session = Session::open(vault_path, pp)?;
845    for conflict_event in &conflict_events {
846        let payload = EventPayload::RejectImport(RejectImport {
847            conflict_event: conflict_event.clone(),
848        });
849        append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
850    }
851    session.save()?;
852    Ok(conflict_events.len())
853}
854
855/// bulk-void D2 — Phase 1 (read): open the session and compute the bulk-void plan. Two-phase by design
856/// (mirrors `bulk_resolve_conflict_plan`): this read phase renders NOTHING to the vault, so the
857/// interactive `y/N` confirmation stays a thin, untested shell in the `main.rs` dispatch. The plan is
858/// the shared read helper `Session::bulk_void_plan` (D1) — the SINGLE `voidable_decisions` predicate,
859/// which OMITS effective allocations (#7). The session (and its VaultLock) is dropped on return.
860pub fn bulk_void_plan(vault_path: &Path, pp: &Passphrase) -> Result<BulkVoidPlan, CliError> {
861    let session = Session::open(vault_path, pp)?;
862    session.bulk_void_plan()
863}
864
865/// bulk-void D2 — Phase 2 (write): atomically append one `VoidDecisionEvent` per `target` AND, for each
866/// `LotSelection` target, clear its optimizer attestation (`optimize_attest::clear`), then a SINGLE
867/// `save`. All-or-nothing: a mid-batch `append_decision` / `clear` failure returns `Err` BEFORE the
868/// save, and the local `Session` is dropped with nothing written — the exact one-session / N-append /
869/// one-save atomicity of `apply_bulk_accept_conflicts` (the TUI path instead ROLLS BACK explicitly via
870/// `persist_bulk_void`).
871///
872/// # [R0-M3] the ONLY CLI-layer #7 defense
873/// `targets` MUST be exactly the `bulk_void_plan` rows (predicate-filtered), re-derived from the vault
874/// inside the dispatch — NEVER raw `--ref` ids. The single CLI `void` does NO `effective_alloc` check,
875/// so a raw-id bulk path would let a caller void an effective allocation → Hard `DecisionConflict`.
876/// Each target is `(target_event_id, disposal_to_clear)` carried straight from the plan row. Returns
877/// the number voided.
878pub fn apply_bulk_void(
879    vault_path: &Path,
880    pp: &Passphrase,
881    targets: Vec<(EventId, Option<EventId>)>,
882    now: OffsetDateTime,
883) -> Result<usize, CliError> {
884    let mut session = Session::open(vault_path, pp)?;
885    // Map ReclassifyOutflow decision id → its transfer_out_event, so voiding one clears its
886    // bulk_estimated `[est]` flag — symmetric with single `void` + TUI persist_bulk_void [WB — R0-I1
887    // closed on the CLI bulk path]. Idempotent clear; O(n) build, void is infrequent.
888    let events = load_all(session.conn())?;
889    let reclass_map: std::collections::HashMap<EventId, EventId> = events
890        .iter()
891        .filter_map(|e| match &e.payload {
892            EventPayload::ReclassifyOutflow(ro) => {
893                Some((e.id.clone(), ro.transfer_out_event.clone()))
894            }
895            _ => None,
896        })
897        .collect();
898    // BG-D9 Task 8: warn per promoted target (against the pre-batch snapshot) about the prior-year
899    // rewrites the floor→$0 revert re-exposes — before recording, non-gating.
900    for (target_event_id, _) in &targets {
901        for line in promote_void_advisory_lines(&session, &events, target_event_id, now) {
902            println!("{line}");
903        }
904    }
905    for (target_event_id, disposal_to_clear) in &targets {
906        // `?` on a mid-batch failure returns before `save` — the in-memory session is discarded, so
907        // nothing lands on disk (CLI atomicity; the TUI path instead ROLLS BACK via persist_bulk_void).
908        append_decision(
909            session.conn(),
910            EventPayload::VoidDecisionEvent(VoidDecisionEvent {
911                target_event_id: target_event_id.clone(),
912            }),
913            now,
914            UtcOffset::UTC,
915            None,
916        )?;
917        // Per-LotSelection side-effect: clear its disposal's optimizer attestation ATOMICALLY (same
918        // in-memory conn, flushed by the single save below). Idempotent — clearing an absent row is Ok.
919        if let Some(disposal) = disposal_to_clear {
920            crate::optimize_attest::clear(session.conn(), disposal)?;
921        }
922        // Symmetric ReclassifyOutflow side-effect: clear its bulk_estimated flag in the same batch.
923        if let Some(out_event) = reclass_map.get(target_event_id) {
924            crate::bulk_estimated::clear(session.conn(), out_event)?;
925        }
926    }
927    session.save()?;
928    Ok(targets.len())
929}
930
931/// FR6/TP7: confirm a self-transfer. `target` is a destination `TransferIn` event (`--to-event`) or a
932/// known wallet (`--to-wallet`); the engine relocates the lots carrying basis + acquired_at.
933pub fn link_transfer(
934    vault_path: &Path,
935    pp: &Passphrase,
936    out_ref: &str,
937    target: TransferTarget,
938    now: OffsetDateTime,
939) -> Result<EventId, CliError> {
940    let out_event = parse_event_id(out_ref)?;
941    let mut session = Session::open(vault_path, pp)?;
942    let payload = EventPayload::TransferLink(TransferLink {
943        out_event,
944        in_event_or_wallet: target,
945    });
946    append_and_save(&mut session, payload, now)
947}
948
949/// self-transfer-passthrough C3 — Phase 1 (read): compute the self-transfer match proposals on the HELD
950/// session (READ-ONLY; appends/persists NOTHING). Mirrors `bulk_link_plan`: the shared read helper is
951/// `Session::self_transfer_match_plan`; the session (and its VaultLock) is dropped on return.
952pub fn self_transfer_match_plan(
953    vault_path: &Path,
954    pp: &Passphrase,
955) -> Result<Vec<MatchProposal>, CliError> {
956    let session = Session::open(vault_path, pp)?;
957    session.self_transfer_match_plan()
958}
959
960/// self-transfer-passthrough C3 — Phase 2 (write), DROP: append one `SelfTransferPassthrough` decision
961/// mapping BOTH legs to `Op::Skip` (non-taxable passthrough). Mirrors `link_transfer` (one append + save).
962/// The RELOCATE case is NOT here — it routes to the EXISTING `link_transfer(out, InEvent(in))` (G-RELOCATE-REUSE).
963pub fn apply_self_transfer_passthrough(
964    vault_path: &Path,
965    pp: &Passphrase,
966    in_ref: &str,
967    out_ref: &str,
968    now: OffsetDateTime,
969) -> Result<EventId, CliError> {
970    let in_event = parse_event_id(in_ref)?;
971    let out_event = parse_event_id(out_ref)?;
972    let mut session = Session::open(vault_path, pp)?;
973    let payload = EventPayload::SelfTransferPassthrough(SelfTransferPassthrough {
974        in_event,
975        out_event,
976    });
977    append_and_save(&mut session, payload, now)
978}
979
980/// FR7/§7.4: build a Path-B safe-harbor allocation that seeds from the **pre-2025 residue** (the
981/// 2025-01-01 Universal-pool position), so it conserves against the engine's allocation-independent
982/// conservation guard.
983///
984/// I-1: the engine's guard compares `Σ alloc.lots.sat`/`usd_basis` to `transition::universal_snapshot`
985/// — a pre-2025-ONLY fold of the Universal pool (resolve.rs §7.4, step 3). The FULL projection's
986/// `state.lots` reflects POST-2025-disposal residuals (a 2025 Sell consumes pre-2025 lots in FIFO), so
987/// seeding from them would yield `alloc_sat < snap.held_sat` → hard `SafeHarborUnconservable` → Path A,
988/// breaking the normal workflow. Instead we re-project a pre-2025-only event subset and read ITS lots:
989///   - keep ONLY import events whose tax-date `< 2025-01-01` (drop every 2025+ acquire/dispose/transfer);
990///   - keep ALL reconciliation decisions/conflicts — they SHAPE the residue (a 2026 `ClassifyInbound`
991///     supplies a pre-2025 `TransferIn`'s basis; `ReclassifyOutflow`/`TransferLink` consume/relocate a
992///     pre-2025 lot) — and carry a 2026 made-date, so they must NOT be tax-date-filtered;
993///   - DROP any prior `SafeHarborAllocation` so the residue stays allocation-INDEPENDENT (matches
994///     `universal_snapshot`, which never applies a seed) → re-allocation is idempotent.
995///
996/// This subset re-runs the IDENTICAL `fold_event` arms the engine's snapshot uses, so the totals match
997/// exactly (the only difference, Path A's per-wallet relocation, preserves sat/basis 1:1; `finalize`
998/// attributes Universal-pool lots by `lot.wallet`). For `ActualPosition` the per-wallet assignment falls
999/// out of those residue lots' `wallet` (= the wallet holding each lot at 2025-01-01). `ProRata` still
1000/// seeds from these actuals; a true cross-wallet pro-rata redistribution is a manual-input refinement
1001/// (Open question O4). The engine's `SafeHarborUnconservable` guard remains the backstop for any residual
1002/// drift (e.g. a rare self-transfer straddling the 2025 boundary) — fails closed, never silent wrong tax.
1003pub fn safe_harbor_allocate(
1004    vault_path: &Path,
1005    pp: &Passphrase,
1006    method: AllocMethod,
1007    attested: bool,
1008    now: OffsetDateTime,
1009) -> Result<EventId, CliError> {
1010    let mut session = Session::open(vault_path, pp)?;
1011
1012    // D-8: refuse recording an allocation while a pre-2025 conservative-filing tranche is on file
1013    // (v1 makes them mutually exclusive). Chokepoint shared with `safe_harbor_attest` and both TUI
1014    // persist sites; the engine `SafeHarborUnconservable` backstop is the guarantee behind it.
1015    crate::cmd::tranche::guard_allocation_vs_tranche(&load_all(session.conn())?)?;
1016
1017    // R-M7b / D3: attestation gate — EARLY, before empty-lots / conservation work.
1018    // A SafeHarborAllocation permanently records pre2025_method and is irrevocable; require an
1019    // explicit declared+attested pre-2025 method so the commitment is a deliberate, attested choice
1020    // rather than a silent FIFO default. Note: the `attested` parameter to this function is
1021    // `timely_allocation_attested` (§5.02(4)) — a separate attestation; do not conflate the two.
1022    {
1023        let cli_cfg = session.config()?;
1024        if !cli_cfg.pre2025_method_attested {
1025            let m = match cli_cfg.pre2025_method {
1026                LotMethod::Fifo => "fifo",
1027                LotMethod::Lifo => "lifo",
1028                LotMethod::Hifo => "hifo",
1029            };
1030            return Err(CliError::Usage(format!(
1031                "refusing to record a safe-harbor allocation under an UNDECLARED pre-2025 method \
1032                 ({m}); a safe-harbor allocation permanently records the method used to reconstruct \
1033                 your pre-2025 basis. Declare your filed method first: \
1034                 config --set-pre2025-method {m} --attest-pre2025-method"
1035            )));
1036        }
1037    }
1038
1039    // Pre-2025 residue + the recorded `pre2025_method` come from the single shared read helper
1040    // (`Session::safe_harbor_residue`, D3) — the same source the TUI allocate opener uses. It reads
1041    // config ONCE, so the returned method is STRUCTURALLY the one the residue was projected under.
1042    let (lots, pre2025_method) = session.safe_harbor_residue()?;
1043    if lots.is_empty() {
1044        return Err(CliError::Usage(
1045            "no pre-2025 lots to allocate (Path A applies; safe harbor unnecessary)".into(),
1046        ));
1047    }
1048    let payload = EventPayload::SafeHarborAllocation(SafeHarborAllocation {
1049        lots,
1050        as_of_date: TRANSITION_DATE,
1051        method,
1052        timely_allocation_attested: attested,
1053        // §A.7: the recorded pre-2025 method is the SAME config method the residue above was projected
1054        // under (both from the one `safe_harbor_residue` config read), so the listed lots conserve
1055        // against the engine's method-aware snapshot. Immutable thereafter; a later live-config change
1056        // fires the hard `Pre2025MethodConflictsAllocation` rather than silently breaking conservation.
1057        pre2025_method,
1058    });
1059    append_and_save(&mut session, payload, now)
1060}
1061
1062/// §A.4 / SPEC Task 5: emit a `LotSelection` decision for a specific disposal. `disposal_ref` is the
1063/// canonical `EventId` string of the disposal event; `picks` is at least one `LotPick`. The engine
1064/// validates completeness (Σsat == disposal principal) and lot existence in the fold; this function
1065/// only appends the decision — it does NOT attempt to validate up-front (that would require a full
1066/// projection, which the engine always does). Identification must exist by the time of sale (§1.1012-1(j)).
1067pub fn select_lots(
1068    vault_path: &Path,
1069    pp: &Passphrase,
1070    disposal_ref: &str,
1071    picks: Vec<LotPick>,
1072    now: OffsetDateTime,
1073) -> Result<EventId, CliError> {
1074    let disposal_event = parse_event_id(disposal_ref)?;
1075    if picks.is_empty() {
1076        return Err(CliError::Usage(
1077            "select-lots needs at least one --from <lot-id>:<sat>".into(),
1078        ));
1079    }
1080    let mut session = Session::open(vault_path, pp)?;
1081    append_and_save(
1082        &mut session,
1083        EventPayload::LotSelection(LotSelection {
1084            disposal_event,
1085            lots: picks,
1086        }),
1087        now,
1088    )
1089}
1090
1091/// §A.4 / SPEC Task 5: batch-import `LotSelection` decisions from a CSV file.
1092///
1093/// CSV format: `disposal_ref,origin_event_id,split_sequence,sat` (header required, validated loudly).
1094/// Rows sharing a `disposal_ref` are grouped into a single `LotSelection` (one decision per disposal);
1095/// grouping is by BTreeMap on the canonical disposal_ref string → deterministic order (NFR4).
1096/// All decisions are written in one session → one `save()`.
1097pub fn import_selections(
1098    vault_path: &Path,
1099    pp: &Passphrase,
1100    csv_path: &Path,
1101    now: OffsetDateTime,
1102) -> Result<Vec<EventId>, CliError> {
1103    let mut rdr = csv::ReaderBuilder::new()
1104        .has_headers(true)
1105        .from_path(csv_path)?;
1106    {
1107        let hdr = rdr.headers()?;
1108        let cols: Vec<&str> = hdr.iter().collect();
1109        if cols != ["disposal_ref", "origin_event_id", "split_sequence", "sat"] {
1110            return Err(CliError::Usage(format!(
1111                "import-selections CSV header must be \
1112                 disposal_ref,origin_event_id,split_sequence,sat; got {cols:?}"
1113            )));
1114        }
1115    }
1116    // Group picks by disposal_ref; BTreeMap gives deterministic iteration order (NFR4).
1117    let mut by_disposal: std::collections::BTreeMap<String, Vec<LotPick>> =
1118        std::collections::BTreeMap::new();
1119    for rec in rdr.records() {
1120        let rec = rec?;
1121        let disposal_ref = rec
1122            .get(0)
1123            .ok_or_else(|| CliError::Usage("missing disposal_ref".into()))?
1124            .to_string();
1125        let origin_str = rec
1126            .get(1)
1127            .ok_or_else(|| CliError::Usage("missing origin_event_id".into()))?;
1128        let split_str = rec
1129            .get(2)
1130            .ok_or_else(|| CliError::Usage("missing split_sequence".into()))?;
1131        let sat_str = rec
1132            .get(3)
1133            .ok_or_else(|| CliError::Usage("missing sat".into()))?;
1134        let origin_event_id = parse_event_id(origin_str)?;
1135        let split_sequence = split_str
1136            .trim()
1137            .parse::<u32>()
1138            .map_err(|e| CliError::Usage(format!("bad split_sequence {split_str:?}: {e}")))?;
1139        let sat = sat_str
1140            .trim()
1141            .parse::<i64>()
1142            .map_err(|e| CliError::Usage(format!("bad sat {sat_str:?}: {e}")))?;
1143        by_disposal.entry(disposal_ref).or_default().push(LotPick {
1144            lot: LotId {
1145                origin_event_id,
1146                split_sequence,
1147            },
1148            sat,
1149        });
1150    }
1151    let mut session = Session::open(vault_path, pp)?;
1152    let mut ids = Vec::new();
1153    for (disposal_ref, lots) in by_disposal {
1154        let disposal_event = parse_event_id(&disposal_ref)?;
1155        let id = append_decision(
1156            session.conn(),
1157            EventPayload::LotSelection(LotSelection {
1158                disposal_event,
1159                lots,
1160            }),
1161            now,
1162            UtcOffset::UTC,
1163            None,
1164        )?;
1165        ids.push(id);
1166    }
1167    session.save()?;
1168    Ok(ids)
1169}
1170
1171/// M3 / SPEC A.1 / A.5(a): append a `MethodElection` decision — the forward standing order.
1172///
1173/// This is an EVENT, not a config flag mutation. The standing order is irrevocable (unless voided)
1174/// and governs all method-honoring disposals on/after `effective_from`. Back-dating is blocked by
1175/// the engine (`MethodElectionBackdated` hard blocker when `effective_from < made-date`).
1176///
1177/// `wallet` carries the optional per-ACCOUNT scope (§A.5(a)): `None` = a GLOBAL election (unchanged);
1178/// `Some(WalletId::Exchange{..})` = that exchange account's method. [R0-M3] Only `Exchange` wallets
1179/// are electable (a `self:LABEL` scope is rejected), and the account MUST be one the vault already
1180/// knows — an unknown/typo'd account is rejected LOUDLY so it can't silently create a dead election
1181/// the user believes is in force. [R0-M1] The scope lives in the `MethodElection` PAYLOAD, not the
1182/// `LedgerEvent.wallet` column (`append_decision` passes `None` for the event-level wallet).
1183///
1184/// When `effective_from` is `None`, defaults to the decision's made-date (`now` in UTC), which
1185/// satisfies the `effective_from >= made-date` invariant by construction.
1186pub fn set_forward_method(
1187    vault_path: &Path,
1188    pp: &Passphrase,
1189    m: LotMethod,
1190    wallet: Option<WalletId>,
1191    effective_from: Option<TaxDate>,
1192    now: OffsetDateTime,
1193) -> Result<EventId, CliError> {
1194    let effective_from = effective_from.unwrap_or_else(|| now.to_offset(UtcOffset::UTC).date());
1195    let mut session = Session::open(vault_path, pp)?;
1196    // [R0-M3] Validate an explicit --exchange scope BEFORE appending: only Exchange wallets are
1197    // electable, and the account must be present in the loaded events (enumerate distinct
1198    // WalletId::Exchange). A silent dead election (typo'd provider/account) would mislead the user
1199    // into thinking a method is in force when it isn't — so reject unknown scopes loudly.
1200    if let Some(w) = &wallet {
1201        let WalletId::Exchange { .. } = w else {
1202            return Err(CliError::Usage(format!(
1203                "--exchange must name an exchange account (exchange:PROVIDER:ACCOUNT); \
1204                 {w:?} is not electable — a method election is a brokerage-account concept"
1205            )));
1206        };
1207        let events = load_all(session.conn())?;
1208        let known: std::collections::BTreeSet<WalletId> = events
1209            .iter()
1210            .filter_map(|e| e.wallet.clone())
1211            .filter(|w| matches!(w, WalletId::Exchange { .. }))
1212            .collect();
1213        if !known.contains(w) {
1214            let names: Vec<String> = known
1215                .iter()
1216                .map(|k| match k {
1217                    WalletId::Exchange { provider, account } => {
1218                        format!("exchange:{provider}:{account}")
1219                    }
1220                    other => format!("{other:?}"),
1221                })
1222                .collect();
1223            return Err(CliError::Usage(format!(
1224                "--exchange {w:?} is not a known exchange account in this vault \
1225                 (accounts are created by importing events). Known exchange accounts: [{}]",
1226                names.join(", ")
1227            )));
1228        }
1229    }
1230    append_and_save(
1231        &mut session,
1232        EventPayload::MethodElection(MethodElection {
1233            effective_from,
1234            method: m,
1235            wallet,
1236        }),
1237        now,
1238    )
1239}
1240
1241/// FR7: attest an existing allocation. Events are immutable, so attestation = void the single live prior
1242/// allocation and re-append it with `timely_allocation_attested = true`. Attestation only cures a
1243/// §5.02(4) TIME-BAR; it is NOT valid on an already-effective allocation (which needs nothing) nor on one
1244/// that fails CONSERVATION (which needs a corrected allocation, not an attestation).
1245///
1246/// Uses `Session::load_events_and_project` to load the event log exactly once — the old pattern
1247/// (separate `load_all(session.conn())` + `session.project()`) loaded the same DB rows twice.
1248pub fn safe_harbor_attest(
1249    vault_path: &Path,
1250    pp: &Passphrase,
1251    now: OffsetDateTime,
1252) -> Result<EventId, CliError> {
1253    let mut session = Session::open(vault_path, pp)?;
1254    let (events, state, _cfg) = session.load_events_and_project()?;
1255
1256    // D-8: refuse re-appending an allocation while a pre-2025 tranche is on file (defense-in-depth —
1257    // the guarded record paths already keep them apart; this closes any future unguarded coexistence).
1258    crate::cmd::tranche::guard_allocation_vs_tranche(&events)?;
1259
1260    // Eng-I1 / I-2(a): EXCLUDE voided allocations from the single-allocation guard, so the legitimate
1261    // allocate→inert→void→re-allocate→attest workflow (which leaves an OLD, voided allocation in the log)
1262    // is not blocked by "multiple allocations present." Build the voided-target set from `VoidDecisionEvent`s
1263    // (mirrors resolve.rs pass-1 step 1a) and keep only LIVE (non-voided) allocations.
1264    let voided: std::collections::BTreeSet<EventId> = events
1265        .iter()
1266        .filter_map(|e| match &e.payload {
1267            EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
1268            _ => None,
1269        })
1270        .collect();
1271    let allocs: Vec<(&EventId, &SafeHarborAllocation)> = events
1272        .iter()
1273        .filter(|e| !voided.contains(&e.id))
1274        .filter_map(|e| match &e.payload {
1275            EventPayload::SafeHarborAllocation(a) => Some((&e.id, a)),
1276            _ => None,
1277        })
1278        .collect();
1279    let (prior_id, prior) = match allocs.as_slice() {
1280        [one] => (one.0.clone(), one.1.clone()),
1281        [] => {
1282            return Err(CliError::Usage(
1283                "no allocation to attest; run `safe-harbor allocate` first".into(),
1284            ))
1285        }
1286        _ => {
1287            return Err(CliError::Usage(
1288                "multiple live allocations present; void the stale one before attesting".into(),
1289            ))
1290        }
1291    };
1292    if prior.timely_allocation_attested {
1293        return Err(CliError::Usage("allocation is already attested".into()));
1294    }
1295
1296    // I-2(b) / N-2: classify the prior allocation's CURRENT status via the projection loaded above
1297    // (`load_events_and_project`), reading the engine's own effectiveness verdict (the blockers it
1298    // stamps onto `prior_id`):
1299    //   * `SafeHarborUnconservable` (hard) → attestation CANNOT cure it (only a corrected allocation can).
1300    //   * `SafeHarborTimebar` (advisory)   → inert PURELY because of the §5.02(4) bar → attestation cures it.
1301    //   * neither                          → ALREADY EFFECTIVE → attesting would Void an effective allocation
1302    //     (→ irrevocable `decision_conflicts`, §7.4) AND append a second effective allocation (→ two effective
1303    //     → Path A, irrecoverable). Refuse and advise `verify` (NOT "void the effective one").
1304    let blocked_with = |k: BlockerKind| {
1305        state
1306            .blockers
1307            .iter()
1308            .any(|b| b.event.as_ref() == Some(&prior_id) && b.kind == k)
1309    };
1310    let unconservable = blocked_with(BlockerKind::SafeHarborUnconservable);
1311    let timebarred = blocked_with(BlockerKind::SafeHarborTimebar);
1312    // (closure's borrow of `prior_id` ends here, so the move into the Void below is sound.)
1313    if unconservable {
1314        return Err(CliError::Usage(
1315            "allocation fails conservation (not a time-bar); re-run `safe-harbor allocate` to rebuild it — attestation cannot cure conservation".into(),
1316        ));
1317    }
1318    if !timebarred {
1319        return Err(CliError::Usage(
1320            "allocation already effective; no attestation needed — run `verify`".into(),
1321        ));
1322    }
1323
1324    // Inert PURELY due to a time-bar → attestation cures it. Append Void(prior) + a re-attested copy.
1325    // (N2: same `now` for both; `decision_seq` orders/distinguishes them — Void first, then re-attest.)
1326    append_decision(
1327        session.conn(),
1328        EventPayload::VoidDecisionEvent(VoidDecisionEvent {
1329            target_event_id: prior_id,
1330        }),
1331        now,
1332        UtcOffset::UTC,
1333        None,
1334    )?;
1335    let attested = SafeHarborAllocation {
1336        timely_allocation_attested: true,
1337        ..prior
1338    };
1339    let id = append_decision(
1340        session.conn(),
1341        EventPayload::SafeHarborAllocation(attested),
1342        now,
1343        UtcOffset::UTC,
1344        None,
1345    )?;
1346    session.save()?;
1347    Ok(id)
1348}
1349
1350/// SE-completion Chunk C (D3): flip `business` (and optionally `kind`) on an already-imported
1351/// `Income` event. Enables SE-tax treatment for professional miners / stakers whose River (and
1352/// other adapter) income arrives with `business: false` hard-coded at ingest time.
1353///
1354/// The engine validates the target at collection time: if the referenced event does not exist OR its
1355/// effective payload is not `Income`, a Hard `DecisionConflict` blocker fires and the decision is
1356/// excluded (not silently inert, not a panic). To correct a `TransferIn` row use `classify-inbound-income`
1357/// instead. **DecisionConflict is Hard — to re-decide, `void` the prior decision then re-issue.**
1358pub fn reclassify_income(
1359    vault_path: &Path,
1360    pp: &Passphrase,
1361    income_ref: &str,
1362    business: bool,
1363    kind: Option<IncomeKind>,
1364    now: OffsetDateTime,
1365) -> Result<EventId, CliError> {
1366    let income_event = parse_event_id(income_ref)?;
1367    let mut session = Session::open(vault_path, pp)?;
1368    let payload = EventPayload::ReclassifyIncome(ReclassifyIncome {
1369        income_event,
1370        business,
1371        kind,
1372    });
1373    guard_decision_conflict(&session, &payload, now)?;
1374    append_and_save(&mut session, payload, now)
1375}
1376
1377/// Chunk 3b D2: store Form 8283 Section-B donation + appraiser details in the
1378/// `donation_details` side-table for the donation identified by `event_ref`.
1379///
1380/// **[R0-M] Projected-removals validation:** the `event_ref` must resolve to a
1381/// `Removal { kind == Donation }` in the PROJECTED `state.removals` — NOT by scanning the raw
1382/// event log. A ref to a non-donation removal (Gift) or to an event that produces no removal
1383/// (e.g. an Acquire) → `CliError::Usage` with a clear message. No decision is appended; this
1384/// is a side-table write (last-write-wins upsert, like `tax_profile::set`).
1385pub fn set_donation_details(
1386    vault_path: &Path,
1387    pp: &Passphrase,
1388    event_ref: &str,
1389    details: DonationDetails,
1390) -> Result<(), CliError> {
1391    let event_id = parse_event_id(event_ref)?;
1392    let mut session = Session::open(vault_path, pp)?;
1393    let (state, _cfg) = session.project()?;
1394
1395    // [R0-M] Validate against the PROJECTED state.removals.
1396    let matched_removal = state.removals.iter().find(|r| r.event == event_id);
1397    match matched_removal {
1398        None => {
1399            return Err(CliError::Usage(format!(
1400                "not a donation / not found: {event_ref:?} does not match any removal in the \
1401                 projected ledger. If this is an outflow you intend to donate, first classify it: \
1402                 `reconcile reclassify-outflow <out-ref> --as-kind donate --amount <usd> …` — once \
1403                 classified, its ref appears in removals.csv (the 'event' column)."
1404            )));
1405        }
1406        Some(r) if r.kind != RemovalKind::Donation => {
1407            return Err(CliError::Usage(format!(
1408                "not a donation: {event_ref:?} is a {:?} removal, not a Donation",
1409                r.kind
1410            )));
1411        }
1412        Some(_) => {} // confirmed Donation — proceed
1413    }
1414
1415    crate::donation_details::set(session.conn(), &event_id, &details)?;
1416    session.save()?;
1417    Ok(())
1418}
1419
1420/// Chunk 3b D2: read back stored `DonationDetails` for the donation identified by `event_ref`.
1421/// Returns `None` when no details have been stored yet. Read-only — no projection needed.
1422pub fn show_donation_details(
1423    vault_path: &Path,
1424    pp: &Passphrase,
1425    event_ref: &str,
1426) -> Result<Option<DonationDetails>, CliError> {
1427    let event_id = parse_event_id(event_ref)?;
1428    let session = Session::open(vault_path, pp)?;
1429    crate::donation_details::get(session.conn(), &event_id)
1430}
1431
1432#[cfg(test)]
1433mod tests {
1434    use super::*;
1435    use btctax_core::event::{Acquire, BasisSource, TransferOut};
1436    use btctax_core::persistence::append_import_batch;
1437    use btctax_core::{
1438        EventId, LedgerEvent, OutflowClass, ReclassifyOutflow, Source, SourceRef, WalletId,
1439    };
1440    use btctax_store::Passphrase;
1441    use rust_decimal_macros::dec;
1442    use time::macros::date;
1443
1444    fn pp() -> Passphrase {
1445        Passphrase::new("test-pass".into())
1446    }
1447
1448    // ── UX-P4-4(d): the --amount FMV sanity advisory (pure decision; mutation-proven) ──
1449
1450    /// A `--amount` entered as the sats count (5_000_000 for 0.05 BTC) dwarfs 100x the ~$3,044
1451    /// market value → a non-fatal warning that names --amount and the sats mistake.
1452    #[test]
1453    fn amount_fmv_advisory_warns_on_sats_as_dollars() {
1454        let line = amount_fmv_advisory(
1455            dec!(5000000),
1456            Some(dec!(3044.48)),
1457            dec!(0.05),
1458            date!(2024 - 07 - 03),
1459        )
1460        .expect("a sats-as-dollars amount must warn");
1461        assert!(
1462            line.contains("warning")
1463                && line.contains("--amount")
1464                && line.to_lowercase().contains("sats"),
1465            "warning must name --amount + the sats mistake: {line}"
1466        );
1467    }
1468
1469    /// A legitimate FMV — even a few multiples above the market value — does NOT warn.
1470    #[test]
1471    fn amount_fmv_advisory_silent_on_plausible_amount() {
1472        assert!(amount_fmv_advisory(
1473            dec!(3044.48),
1474            Some(dec!(3044.48)),
1475            dec!(0.05),
1476            date!(2024 - 07 - 03)
1477        )
1478        .is_none());
1479        assert!(amount_fmv_advisory(
1480            dec!(9000),
1481            Some(dec!(3044.48)),
1482            dec!(0.05),
1483            date!(2024 - 07 - 03)
1484        )
1485        .is_none());
1486    }
1487
1488    /// The threshold is a STRICT 100x: exactly 100x is silent, a cent over warns.
1489    #[test]
1490    fn amount_fmv_advisory_boundary_is_strict_100x() {
1491        let mv = dec!(3000);
1492        assert!(
1493            amount_fmv_advisory(dec!(300000), Some(mv), dec!(0.05), date!(2024 - 07 - 03))
1494                .is_none(),
1495            "exactly 100x must not warn"
1496        );
1497        assert!(
1498            amount_fmv_advisory(dec!(300000.01), Some(mv), dec!(0.05), date!(2024 - 07 - 03))
1499                .is_some(),
1500            "just over 100x must warn"
1501        );
1502    }
1503
1504    /// A dust market value that rounds to $0 yields no meaningful ratio → no warning.
1505    #[test]
1506    fn amount_fmv_advisory_skips_dust_that_rounds_to_zero() {
1507        assert!(amount_fmv_advisory(
1508            dec!(100),
1509            Some(dec!(0)),
1510            dec!(0.00000001),
1511            date!(2024 - 07 - 03)
1512        )
1513        .is_none());
1514    }
1515
1516    /// No price for the date ⇒ the check cannot run: emit a NOTE (never silent), not a warning.
1517    #[test]
1518    fn amount_fmv_advisory_notes_missing_price() {
1519        let line = amount_fmv_advisory(dec!(3000), None, dec!(0.05), date!(2024 - 07 - 03))
1520            .expect("no-price must produce a note");
1521        assert!(
1522            line.contains("no BTC price") && !line.contains("warning"),
1523            "no-price must be a note, not a warning: {line}"
1524        );
1525    }
1526
1527    /// UX-P4-4(b) review r1 M3: `tz_label` renders UTC and signed non-UTC offsets — including the
1528    /// subtle `h==0, m<0` (UTC−00:30) sign arm and non-zero minutes — so a sign-handling regression
1529    /// in the receipt-date refusal message reds here.
1530    #[test]
1531    fn tz_label_renders_utc_and_signed_offsets() {
1532        use time::macros::offset;
1533        assert_eq!(tz_label(UtcOffset::UTC), "UTC");
1534        assert_eq!(tz_label(offset!(-05:00)), "UTC-05:00");
1535        assert_eq!(tz_label(offset!(+05:45)), "UTC+05:45");
1536        assert_eq!(tz_label(offset!(-00:30)), "UTC-00:30");
1537    }
1538
1539    /// Build a minimal `DonationDetails` for tests (synthetic — no real PII).
1540    fn test_details() -> DonationDetails {
1541        DonationDetails {
1542            donee_name: "Test Charity".into(),
1543            donee_address: None,
1544            donee_ein: Some("12-3456789".into()),
1545            appraiser_name: "Test Appraiser".into(),
1546            appraiser_address: None,
1547            appraiser_tin: Some("987654321".into()),
1548            appraiser_ptin: None,
1549            appraiser_qualifications: Some("Certified bitcoin appraiser".into()),
1550            appraisal_date: Some(date!(2025 - 06 - 01)),
1551            fmv_method_override: None,
1552        }
1553    }
1554
1555    /// Create a vault + Acquire + TransferOut + ReclassifyOutflow(Donate). Returns
1556    /// (vault_path, donation_event_id, acquire_event_id) where donation_event_id is the
1557    /// TransferOut EventId (which becomes the Removal.event in the projected ledger).
1558    /// PRIVACY: synthetic values only.
1559    fn setup_donation_vault(dir: &tempfile::TempDir) -> (std::path::PathBuf, EventId, EventId) {
1560        use crate::Session;
1561
1562        let vault_path = dir.path().join("vault.pgp");
1563        let mut session = Session::create(&vault_path, &pp()).unwrap();
1564
1565        // Fixed timestamps (deterministic, reproducible). Both pre-2025 (< 2025-01-01 =
1566        // Unix 1_735_689_600) to stay in the Universal-pool path (no per-wallet allocation needed).
1567        // ts_acq ≈ 2023-11-14, ts_out ≈ 2024-07-03.
1568        let ts_acq = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1569        let ts_out = time::OffsetDateTime::from_unix_timestamp(1_720_000_000).unwrap();
1570
1571        // Both events use the same exchange wallet so lots can be consumed from the same pool.
1572        let wallet = WalletId::Exchange {
1573            provider: "coinbase".into(),
1574            account: "default".into(),
1575        };
1576
1577        // Acquire 2_000_000 sats at $60,000 cost basis.
1578        let acq_id = EventId::import(Source::Coinbase, SourceRef::new("in|test-acq-001"));
1579        let acq_ev = LedgerEvent {
1580            id: acq_id.clone(),
1581            utc_timestamp: ts_acq,
1582            original_tz: UtcOffset::UTC,
1583            wallet: Some(wallet.clone()),
1584            payload: EventPayload::Acquire(Acquire {
1585                sat: 2_000_000,
1586                usd_cost: dec!(60000),
1587                fee_usd: dec!(0),
1588                basis_source: BasisSource::ComputedFromCost,
1589            }),
1590        };
1591        append_import_batch(session.conn(), &[acq_ev]).unwrap();
1592
1593        // TransferOut 500_000 sats from the same wallet.
1594        let out_id = EventId::import(Source::Coinbase, SourceRef::new("out|test-donation-001"));
1595        let out_ev = LedgerEvent {
1596            id: out_id.clone(),
1597            utc_timestamp: ts_out,
1598            original_tz: UtcOffset::UTC,
1599            wallet: Some(wallet.clone()),
1600            payload: EventPayload::TransferOut(TransferOut {
1601                sat: 500_000,
1602                fee_sat: None,
1603                dest_addr: None,
1604                txid: None,
1605            }),
1606        };
1607        append_import_batch(session.conn(), &[out_ev]).unwrap();
1608
1609        // ReclassifyOutflow as Donation with explicit FMV (no price lookup needed).
1610        let classify_payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
1611            transfer_out_event: out_id.clone(),
1612            as_: OutflowClass::Donate {
1613                appraisal_required: false,
1614            },
1615            principal_proceeds_or_fmv: dec!(15000),
1616            fee_usd: None,
1617            donee: Some("Test Charity".into()),
1618        });
1619        append_decision(
1620            session.conn(),
1621            classify_payload,
1622            ts_out,
1623            UtcOffset::UTC,
1624            None,
1625        )
1626        .unwrap();
1627
1628        session.save().unwrap();
1629        (vault_path, out_id, acq_id)
1630    }
1631
1632    /// `set_donation_details` on a real Donation event stores it;
1633    /// `show_donation_details` reads it back correctly.
1634    #[test]
1635    fn set_then_show_round_trips_on_real_donation() {
1636        let dir = tempfile::tempdir().unwrap();
1637        let (vault_path, out_id, _acq_id) = setup_donation_vault(&dir);
1638
1639        // Store details.
1640        set_donation_details(&vault_path, &pp(), &out_id.canonical(), test_details()).unwrap();
1641
1642        // Read back.
1643        let stored = show_donation_details(&vault_path, &pp(), &out_id.canonical())
1644            .unwrap()
1645            .expect("details must be present");
1646        assert_eq!(stored, test_details());
1647    }
1648
1649    /// UX-P4-4(c) at the CLI surface: a malformed `--donee-ein` is refused end-to-end (through the
1650    /// projection check + the `donation_details::set` choke point), and nothing is stored. The
1651    /// accept + normalize + other-shape cases are unit-tested on `validate_and_normalize`; this proves
1652    /// the CLI path actually reaches the choke point and fails closed.
1653    #[test]
1654    fn set_donation_details_refuses_malformed_donee_ein() {
1655        let dir = tempfile::tempdir().unwrap();
1656        let (vault_path, out_id, _acq_id) = setup_donation_vault(&dir);
1657
1658        let mut bad = test_details();
1659        bad.donee_ein = Some("banana".into());
1660        let err = set_donation_details(&vault_path, &pp(), &out_id.canonical(), bad).unwrap_err();
1661        assert!(
1662            matches!(err, CliError::Usage(_)),
1663            "expected Usage, got: {err}"
1664        );
1665        assert!(
1666            err.to_string().contains("--donee-ein"),
1667            "the refusal must name --donee-ein: {err}"
1668        );
1669
1670        // Fail-closed: nothing was stored for the refused donation.
1671        assert!(
1672            show_donation_details(&vault_path, &pp(), &out_id.canonical())
1673                .unwrap()
1674                .is_none(),
1675            "a refused set must store no details"
1676        );
1677    }
1678
1679    /// Targeting a missing ref → a clear `CliError::Usage` (not a panic).
1680    #[test]
1681    fn set_donation_details_missing_ref_is_usage_error() {
1682        let dir = tempfile::tempdir().unwrap();
1683        let (vault_path, _out_id, _acq_id) = setup_donation_vault(&dir);
1684
1685        let bogus = EventId::import(Source::Coinbase, SourceRef::new("out|no-such-event"));
1686        let err = set_donation_details(&vault_path, &pp(), &bogus.canonical(), test_details())
1687            .unwrap_err();
1688        assert!(
1689            matches!(err, CliError::Usage(_)),
1690            "expected Usage error, got: {err}"
1691        );
1692        let msg = err.to_string();
1693        assert!(
1694            msg.contains("not a donation") || msg.contains("not found"),
1695            "error must mention 'not a donation' or 'not found': {msg}"
1696        );
1697        // UX-P4-12(g): the message names the MISSING PRIOR STEP with VALID syntax (reclassify-outflow
1698        // --as-kind donate …), not just a circular pointer at removals.csv (where an unclassified
1699        // outflow does not yet appear).
1700        assert!(
1701            msg.contains("reclassify-outflow") && msg.contains("--as-kind donate"),
1702            "error must point at the missing prior step with valid syntax: {msg}"
1703        );
1704    }
1705
1706    /// Targeting the Acquire event (not a Donation removal) → a clear `CliError::Usage`.
1707    #[test]
1708    fn set_donation_details_non_donation_event_is_usage_error() {
1709        let dir = tempfile::tempdir().unwrap();
1710        let (vault_path, _out_id, acq_id) = setup_donation_vault(&dir);
1711
1712        // The Acquire event is not a Donation removal in the projected ledger.
1713        let err = set_donation_details(&vault_path, &pp(), &acq_id.canonical(), test_details())
1714            .unwrap_err();
1715        assert!(
1716            matches!(err, CliError::Usage(_)),
1717            "expected Usage error, got: {err}"
1718        );
1719    }
1720
1721    /// `show_donation_details` returns `None` before any details are stored.
1722    #[test]
1723    fn show_donation_details_returns_none_before_set() {
1724        let dir = tempfile::tempdir().unwrap();
1725        let (vault_path, out_id, _acq_id) = setup_donation_vault(&dir);
1726
1727        let stored = show_donation_details(&vault_path, &pp(), &out_id.canonical()).unwrap();
1728        assert_eq!(stored, None);
1729    }
1730
1731    /// Build a vault with a Gift removal (not a Donation). Returns (vault_path, gift_event_id).
1732    fn setup_gift_vault(dir: &tempfile::TempDir) -> (std::path::PathBuf, EventId) {
1733        use crate::Session;
1734        let vault_path = dir.path().join("gift-vault.pgp");
1735        let mut session = Session::create(&vault_path, &pp()).unwrap();
1736
1737        let ts_acq = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
1738        let ts_out = time::OffsetDateTime::from_unix_timestamp(1_720_000_000).unwrap();
1739        let wallet = WalletId::Exchange {
1740            provider: "coinbase".into(),
1741            account: "default".into(),
1742        };
1743
1744        let acq_id = EventId::import(Source::Coinbase, SourceRef::new("in|gift-acq-001"));
1745        let acq_ev = LedgerEvent {
1746            id: acq_id.clone(),
1747            utc_timestamp: ts_acq,
1748            original_tz: UtcOffset::UTC,
1749            wallet: Some(wallet.clone()),
1750            payload: EventPayload::Acquire(Acquire {
1751                sat: 2_000_000,
1752                usd_cost: dec!(60000),
1753                fee_usd: dec!(0),
1754                basis_source: BasisSource::ComputedFromCost,
1755            }),
1756        };
1757        append_import_batch(session.conn(), &[acq_ev]).unwrap();
1758
1759        let out_id = EventId::import(Source::Coinbase, SourceRef::new("out|gift-001"));
1760        let out_ev = LedgerEvent {
1761            id: out_id.clone(),
1762            utc_timestamp: ts_out,
1763            original_tz: UtcOffset::UTC,
1764            wallet: Some(wallet.clone()),
1765            payload: EventPayload::TransferOut(TransferOut {
1766                sat: 500_000,
1767                fee_sat: None,
1768                dest_addr: None,
1769                txid: None,
1770            }),
1771        };
1772        append_import_batch(session.conn(), &[out_ev]).unwrap();
1773
1774        // Reclassify as GiftOut (NOT Donate)
1775        let classify_payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
1776            transfer_out_event: out_id.clone(),
1777            as_: OutflowClass::GiftOut,
1778            principal_proceeds_or_fmv: dec!(15000),
1779            fee_usd: None,
1780            donee: None,
1781        });
1782        append_decision(
1783            session.conn(),
1784            classify_payload,
1785            ts_out,
1786            UtcOffset::UTC,
1787            None,
1788        )
1789        .unwrap();
1790
1791        session.save().unwrap();
1792        (vault_path, out_id)
1793    }
1794
1795    /// `set_donation_details` targeting a Gift removal → "is a Gift removal, not a Donation" error.
1796    /// Exercises the `Some(r) if r.kind != RemovalKind::Donation` arm (previously untested).
1797    #[test]
1798    fn set_donation_details_gift_removal_is_usage_error() {
1799        let dir = tempfile::tempdir().unwrap();
1800        let (vault_path, gift_out_id) = setup_gift_vault(&dir);
1801
1802        let err =
1803            set_donation_details(&vault_path, &pp(), &gift_out_id.canonical(), test_details())
1804                .unwrap_err();
1805        assert!(
1806            matches!(err, CliError::Usage(_)),
1807            "expected Usage error, got: {err}"
1808        );
1809        let msg = err.to_string();
1810        assert!(
1811            msg.contains("not a donation") || msg.contains("Gift"),
1812            "error must mention 'not a donation' or 'Gift': {msg}"
1813        );
1814    }
1815}