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::event::DeclareTranche;
14use btctax_core::persistence::{append_decision, load_all};
15use btctax_core::{EventId, EventPayload, LedgerEvent, Sat, TaxDate, WalletId};
16use btctax_store::Passphrase;
17use std::collections::BTreeSet;
18use std::path::Path;
19use time::{OffsetDateTime, UtcOffset};
20
21/// Tranche-side hedge (tax r2 N-3): the user is recording a tranche and is blocked by an allocation, so
22/// the finality caveat is about that ALLOCATION. A filed safe-harbor allocation cannot be silently unwound.
23const ALLOCATION_IS_FINAL_HINT: &str = "revisit the in-app safe-harbor allocation; if your filed \
24    allocation is already final, unallocated pre-2025 units are a facts-and-circumstances matter for a \
25    professional";
26
27/// Allocation-side hedge (tax review r1 Nit): the user is recording an allocation and is blocked by a
28/// tranche, so the finality caveat is about that TRANCHE (a filed basis — `$0` or a promoted floor), not
29/// the allocation.
30const TRANCHE_IS_FINAL_HINT: &str = "Void the tranche first (`reconcile void <decision-ref>`); if you \
31    have already filed the tranche's basis ($0 or a promoted floor), unallocated pre-2025 units are a \
32    facts-and-circumstances matter for a professional";
33
34/// The set of event ids targeted by any `VoidDecisionEvent` in the log — the record-time "voided" view.
35///
36/// Mirrors `resolve.rs` pass-1 step 1a and the attest site's own `voided` set: a decision is not-in-force
37/// once a `VoidDecisionEvent` names it. (A void of a `SafeHarborAllocation` is resolver-deferred to Task 12
38/// for its EFFECTIVE-vs-inert semantics, but for THIS friendly record-time layer the presence of the void
39/// is enough — the engine backstop is the guarantee behind it.)
40fn void_targets(events: &[LedgerEvent]) -> BTreeSet<EventId> {
41    events
42        .iter()
43        .filter_map(|e| match &e.payload {
44            EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
45            _ => None,
46        })
47        .collect()
48}
49
50/// True iff an IN-FORCE (non-voided) `SafeHarborAllocation` exists — **effective OR inert** (arch r2
51/// New-3: an inert allocation can be flipped effective, so it too collides with a new pre-2025 tranche).
52/// Deliberately NOT scoped to effective allocations (that would let a pre-2025 tranche slip in beside an
53/// inert one and silently discard it once the allocation later goes effective).
54pub fn in_force_allocation_exists(events: &[LedgerEvent]) -> bool {
55    // In force = a NON-voided `SafeHarborAllocation` (present, effective OR inert — an inert one can be
56    // flipped effective, arch r2 New-3). A voided allocation is NOT in force here: the ENGINE resolves the
57    // void (T16 review r2 / I-1) — a void of an inert allocation retires it (§7.4 retirement pass), and a
58    // void of an effective allocation raises a Hard `DecisionConflict` there; either way the record-time
59    // predicate correctly ADMITS the tranche and the engine backstop is the guarantee. (This replaces the
60    // r1 blocker-absence "effective" mirror, which coupled badly with the backstop's blocker retraction.)
61    let voided = void_targets(events);
62    events.iter().any(|e| {
63        matches!(e.payload, EventPayload::SafeHarborAllocation(_)) && !voided.contains(&e.id)
64    })
65}
66
67/// True iff a non-voided PRE-2025 (`window_end < TRANSITION_DATE`) `DeclareTranche` exists — the only
68/// tranche that collides with the pre-2025 Universal residue a `SafeHarborAllocation` reconstructs
69/// (tax r1 I-2). A `window_end ≥ 2025` tranche folds into a post-transition per-wallet pool and never
70/// touches Rev-Proc-2024-28, so it does NOT block an allocation.
71pub fn pre2025_tranche_exists(events: &[LedgerEvent]) -> bool {
72    let voided = void_targets(events);
73    events.iter().any(|e| {
74        matches!(&e.payload, EventPayload::DeclareTranche(t) if t.window_end < TRANSITION_DATE)
75            && !voided.contains(&e.id)
76    })
77}
78
79/// P8 Nit: is `wallet` referenced by any prior event — an import's `wallet`, or a prior tranche
80/// declaration's target wallet? A `false` result means `--wallet` likely has a TYPO. This drives a WARN
81/// only, NEVER a refusal: a conservative-filing tranche lot in any wallet still files at its conservative
82/// basis ($0, or a promoted floor) — tax-neutral — so a typo merely strands the lot in a phantom wallet
83/// rather than mis-stating tax. Pure over the event log.
84pub fn wallet_is_known(events: &[LedgerEvent], wallet: &WalletId) -> bool {
85    events.iter().any(|e| {
86        e.wallet.as_ref() == Some(wallet)
87            || matches!(&e.payload, EventPayload::DeclareTranche(t) if &t.wallet == wallet)
88    })
89}
90
91/// ALLOCATION-side guard (the chokepoint for all four allocation append sites): refuse recording a
92/// `SafeHarborAllocation` while a pre-2025 tranche is on file. v1 makes them mutually exclusive (D-8).
93pub fn guard_allocation_vs_tranche(events: &[LedgerEvent]) -> Result<(), CliError> {
94    if pre2025_tranche_exists(events) {
95        return Err(CliError::Usage(format!(
96            "refusing to record a safe-harbor allocation while a pre-2025 conservative-filing tranche \
97             ($0 or a promoted floor, EstimatedConservative) is on file — v1 makes the two mutually \
98             exclusive. {TRANCHE_IS_FINAL_HINT}."
99        )));
100    }
101    Ok(())
102}
103
104/// TRANSITION-side guard for the tranche record path: refuse recording a PRE-2025 tranche while an
105/// in-force allocation exists. A `window_end ≥ 2025` tranche is NOT blocked (records cleanly beside an
106/// effective allocation — else P7's mandatory disclosure is foreclosed for the mixed-records filer).
107fn guard_tranche_vs_allocation(
108    events: &[LedgerEvent],
109    window_end: TaxDate,
110) -> Result<(), CliError> {
111    if window_end < TRANSITION_DATE && in_force_allocation_exists(events) {
112        return Err(CliError::Usage(format!(
113            "refusing to record a pre-2025 conservative-filing tranche while a safe-harbor allocation \
114             is on file — v1 makes the two mutually exclusive; {ALLOCATION_IS_FINAL_HINT}."
115        )));
116    }
117    Ok(())
118}
119
120/// Append a `DeclareTranche` decision (a $0-basis `EstimatedConservative` lot) and persist.
121///
122/// `now` is the injected decision creation-time (deterministic in tests). The tranche folds via the
123/// shared `Op::Acquire` path to a lot homed at `window_end`, $0 basis, tagged `EstimatedConservative`.
124/// Record-time guard (D-8): a PRE-2025 tranche is refused while an in-force allocation exists.
125pub fn declare_tranche(
126    vault_path: &Path,
127    pp: &Passphrase,
128    sat: Sat,
129    wallet: WalletId,
130    window_start: TaxDate,
131    window_end: TaxDate,
132    now: OffsetDateTime,
133) -> Result<EventId, CliError> {
134    // Input validation (record-time refuse — no vault access needed).
135    if sat <= 0 {
136        // A `sat <= 0` tranche would bump `stats.sigma_in` by a non-positive amount (fold.rs),
137        // corrupting Σ-conservation; there is no such thing as declaring zero/negative undocumented BTC.
138        return Err(CliError::Usage(format!(
139            "tranche amount must be > 0 sat (got {sat})"
140        )));
141    }
142    if window_start > window_end {
143        return Err(CliError::Usage(format!(
144            "tranche window_start ({window_start}) must be <= window_end ({window_end})"
145        )));
146    }
147
148    let mut session = Session::open(vault_path, pp)?;
149    let events = load_all(session.conn())?;
150    // Guard FIRST (arch N-1), before the phantom-wallet warning, so a REFUSED declaration never emits the
151    // misleading "stranded lot" note. A pre-2025 tranche is refused while a NON-voided allocation is on
152    // file; a voided allocation is resolved by the engine (T16 review r2 / I-1), so no projection is needed
153    // here.
154    guard_tranche_vs_allocation(&events, window_end)?;
155    // The declaration WILL be recorded now — warn (never refuse) on a `--wallet` that no prior event
156    // references (a likely typo that strands the tranche lot in a phantom wallet; it still files at its
157    // conservative basis).
158    if !wallet_is_known(&events, &wallet) {
159        eprintln!(
160            "warning: --wallet {} has no prior events in this vault; if this is a typo the conservative \
161             tranche lot is stranded in a phantom wallet (it still files at its conservative basis — $0, \
162             or a promoted floor). Re-run with the intended --wallet if this was unintended.",
163            crate::render::wallet_label(&wallet)
164        );
165    }
166    let payload = EventPayload::DeclareTranche(DeclareTranche {
167        sat,
168        wallet,
169        window_start,
170        window_end,
171    });
172    let id = append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
173    session.save()?;
174    Ok(id)
175}