Skip to main content

btctax_cli/cmd/
tranche.rs

1//! Conservative-filing `DeclareTranche` record path + the tranche⇄allocation mutual-exclusion guard
2//! (D-8 UX layer). A tranche is undocumented BTC declared at $0 basis (the IRS fallback), folded to an
3//! `EstimatedConservative` lot homed at `window_end`. See `design/conservative-filing/SPEC.md` D-8.
4//!
5//! The engine backstop (Task 5 — `SafeHarborUnconservable` denies a `SafeHarborAllocation` effectiveness
6//! over a live tranche residue) is the GUARANTEE; these record-time guards are the early, friendly error.
7//! They are the single source of the mutual-exclusion predicate for ALL FOUR allocation append sites
8//! (CLI `safe_harbor_allocate` + `safe_harbor_attest`; TUI `persist_safe_harbor_allocate` +
9//! `persist_safe_harbor_attest`) and the tranche record path here.
10
11use crate::{CliError, Session};
12use btctax_core::conventions::TRANSITION_DATE;
13use btctax_core::persistence::load_all;
14use btctax_core::tranche_guard::{in_force_allocation_exists, pre2025_tranche_exists};
15use btctax_core::{EventId, EventPayload, LedgerEvent, Sat, TaxDate, WalletId};
16use btctax_store::Passphrase;
17use std::path::Path;
18use time::OffsetDateTime;
19
20/// Tranche-side hedge (tax r2 N-3): the user is recording a tranche and is blocked by an allocation, so
21/// the finality caveat is about that ALLOCATION. A filed safe-harbor allocation cannot be silently unwound.
22const ALLOCATION_IS_FINAL_HINT: &str = "revisit the in-app safe-harbor allocation; if your filed \
23    allocation is already final, unallocated pre-2025 units are a facts-and-circumstances matter for a \
24    professional";
25
26/// Allocation-side hedge (tax review r1 Nit): the user is recording an allocation and is blocked by a
27/// tranche, so the finality caveat is about that TRANCHE (a filed basis — `$0` or a promoted floor), not
28/// the allocation.
29const TRANCHE_IS_FINAL_HINT: &str = "Void the tranche first (`reconcile void <decision-ref>`); if you \
30    have already filed the tranche's basis ($0 or a promoted floor), unallocated pre-2025 units are a \
31    facts-and-circumstances matter for a professional";
32
33/// P8 Nit: is `wallet` referenced by any prior event — an import's `wallet`, or a prior tranche
34/// declaration's target wallet? A `false` result means `--wallet` likely has a TYPO. This drives a WARN
35/// only, NEVER a refusal: a conservative-filing tranche lot in any wallet still files at its conservative
36/// basis ($0, or a promoted floor) — tax-neutral — so a typo merely strands the lot in a phantom wallet
37/// rather than mis-stating tax. Pure over the event log.
38pub fn wallet_is_known(events: &[LedgerEvent], wallet: &WalletId) -> bool {
39    events.iter().any(|e| {
40        e.wallet.as_ref() == Some(wallet)
41            || matches!(&e.payload, EventPayload::DeclareTranche(t) if &t.wallet == wallet)
42    })
43}
44
45/// ALLOCATION-side guard (the chokepoint for all four allocation append sites): refuse recording a
46/// `SafeHarborAllocation` while a pre-2025 tranche is on file. v1 makes them mutually exclusive (D-8).
47pub fn guard_allocation_vs_tranche(events: &[LedgerEvent]) -> Result<(), CliError> {
48    if pre2025_tranche_exists(events) {
49        return Err(CliError::Usage(format!(
50            "refusing to record a safe-harbor allocation while a pre-2025 conservative-filing tranche \
51             ($0 or a promoted floor, EstimatedConservative) is on file — v1 makes the two mutually \
52             exclusive. {TRANCHE_IS_FINAL_HINT}."
53        )));
54    }
55    Ok(())
56}
57
58/// TRANSITION-side guard for the tranche record path: refuse recording a PRE-2025 tranche while an
59/// in-force allocation exists. A `window_end ≥ 2025` tranche is NOT blocked (records cleanly beside an
60/// effective allocation — else P7's mandatory disclosure is foreclosed for the mixed-records filer).
61///
62/// `pub(crate)` (Defensive Filing Wizard Task 2): `crate::chokepoint::plan_declare` also calls this — the
63/// single source of the guard stays HERE (this module), not duplicated into the chokepoint.
64pub(crate) fn guard_tranche_vs_allocation(
65    events: &[LedgerEvent],
66    window_end: TaxDate,
67) -> Result<(), CliError> {
68    if window_end < TRANSITION_DATE && in_force_allocation_exists(events) {
69        return Err(CliError::Usage(format!(
70            "refusing to record a pre-2025 conservative-filing tranche while a safe-harbor allocation \
71             is on file — v1 makes the two mutually exclusive; {ALLOCATION_IS_FINAL_HINT}."
72        )));
73    }
74    Ok(())
75}
76
77/// Append a `DeclareTranche` decision (a $0-basis `EstimatedConservative` lot) and persist.
78///
79/// `now` is the injected decision creation-time (deterministic in tests). The tranche folds via the
80/// shared `Op::Acquire` path to a lot homed at `window_end`, $0 basis, tagged `EstimatedConservative`.
81/// Record-time guard (D-8): a PRE-2025 tranche is refused while an in-force allocation exists.
82///
83/// Defensive Filing Wizard Task 2: the actual plan/apply PIPELINE now lives in `crate::chokepoint`
84/// (`plan_declare`/`apply_declare`) — a reusable chokepoint a future TUI can drive identically. This
85/// function is a THIN DRIVER over it: `Session::open` → `plan_declare` (passing
86/// `target_shortfall=None` — the CLI free-form path never runs the DFW-D5.2 clearance shadow, mapping a
87/// `Refusal` to a `CliError`) → the phantom-wallet stderr warning (tax-M-3: I/O, not gate logic, so it
88/// stays HERE rather than inside the pure `plan_declare`) → `apply_declare`.
89pub fn declare_tranche(
90    vault_path: &Path,
91    pp: &Passphrase,
92    sat: Sat,
93    wallet: WalletId,
94    window_start: TaxDate,
95    window_end: TaxDate,
96    now: OffsetDateTime,
97) -> Result<EventId, CliError> {
98    let mut session = Session::open(vault_path, pp)?;
99    let events = load_all(session.conn())?;
100    let cfg = session.config()?.to_projection();
101
102    let plan = crate::chokepoint::plan_declare(
103        &events,
104        session.prices(),
105        &cfg,
106        sat,
107        wallet.clone(),
108        window_start,
109        window_end,
110        None,
111        now,
112    )
113    .map_err(CliError::from)?;
114
115    // The declaration WILL be recorded now — warn (never refuse) on a `--wallet` that no prior event
116    // references (a likely typo that strands the tranche lot in a phantom wallet; it still files at its
117    // conservative basis). tax-M-3: this is I/O, not gate logic, so it stays in the driver, AFTER
118    // `plan_declare` succeeds — a REFUSED declaration never emits the misleading "stranded lot" note
119    // (arch N-1, preserved: the shipped verb warned only once the guard had already admitted).
120    if !wallet_is_known(&events, &wallet) {
121        eprintln!(
122            "warning: --wallet {} has no prior events in this vault; if this is a typo the conservative \
123             tranche lot is stranded in a phantom wallet (it still files at its conservative basis — $0, \
124             or a promoted floor). Re-run with the intended --wallet if this was unintended.",
125            crate::render::wallet_label(&wallet)
126        );
127    }
128
129    let event_id = crate::chokepoint::apply_declare(&mut session, plan, now)?;
130
131    // Approach-B experimental disclosure (`design/approach-b-experimental-notice`): declaring a tranche
132    // is exactly "acting on this feature" — warn on stderr (never stdout — stdout is parsed/piped),
133    // mirroring the phantom-wallet warning's own shape and its "I/O, not gate logic, stays in the
134    // driver, AFTER the write succeeds" placement. UNCONDITIONAL, not re-derived from a re-read of the
135    // ledger: `apply_declare` just returned `Ok`, so a live (non-voided) `DeclareTranche` now exists BY
136    // CONSTRUCTION — `uses_approach_b` is trivially true here, nothing in this fn could have voided it.
137    // ★ fix round 1 Important: the original re-read used `load_all(session.conn())?`, so a transient I/O
138    // error on that PURELY DISCLOSURE-driving read turned an ALREADY-SUCCEEDED declare into a non-zero
139    // exit — a filer who sees that naturally re-runs the command, appending a DUPLICATE tranche. Never
140    // re-derive a disclosure from a re-read when the write it depends on already told you the answer.
141    eprint!("\n⚠ {}", btctax_core::experimental::NOTICE.plain_text());
142
143    Ok(event_id)
144}