Skip to main content

btctax_cli/cmd/
promote.rs

1//! Approach-B / Task 10 — the `promote-tranche` CLI verb: BG-D5 (record-time purchase-provenance
2//! attestation), BG-D6 (two-sided informed-consent recording), and BG-D7 (Form 8275 Part II present-by-
3//! construction).
4//!
5//! Defensive Filing Wizard Task 1: the actual plan/confirm/apply PIPELINE now lives in
6//! `crate::chokepoint` (`plan_promote`/`render_consent`/`apply_promote`) — a reusable chokepoint a future
7//! TUI can drive identically. This module is a THIN DRIVER over it: `Session::open` → build args →
8//! `plan_promote` (mapping a `Refusal` to a `CliError`) → print the consent screen → prompt/collect the
9//! acknowledgment → `apply_promote`. It also still owns the copy constants (`PROMOTE_ACK_PHRASE`,
10//! `PROVENANCE_TEXT`/`PROVENANCE_VERSION`, `CONSENT_INTRO`) and the shipped `render_consent(terms,
11//! gift_only_years)` two-arg renderer (kept `pub` — `tests/promote_cli.rs` calls it directly, and
12//! `crate::chokepoint::render_consent(&plan)` calls it internally to reproduce the shipped byte order).
13use crate::{CliError, Session};
14use btctax_core::event::ConsentTerm;
15use btctax_core::Usd;
16use btctax_store::Passphrase;
17use std::collections::BTreeSet;
18use std::path::Path;
19use time::OffsetDateTime;
20
21use crate::eventref::parse_event_id;
22
23/// The units' real acquisition provenance (BG-D5). A CLOSED enumeration: only `Purchase` clears the
24/// promote gate — every other value has a documented FMV-at-receipt/carryover basis from the return
25/// (Notice 2014-21; Rev. Rul. 2019-24) and is refused, pointed at modeling the real acquisition instead.
26/// CLI-facing only (not part of the persisted schema — only `provenance_attested: bool` plus the fixed
27/// `PROVENANCE_TEXT`/`PROVENANCE_VERSION` are stored on the event, per T1's `Acknowledgment`).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
29pub enum ProvenanceKind {
30    Purchase,
31    Gift,
32    Inheritance,
33    Mining,
34    Earned,
35    Airdrop,
36    Fork,
37}
38
39impl ProvenanceKind {
40    /// The CLOSED enumeration, in `--provenance` declaration order — the SINGLE list a driver offers the
41    /// filer to choose from. Added for the TUI Promote flow's BG-D5 provenance step (P-C gate tax I-2):
42    /// the wizard must make the filer SELECT their acquisition provenance exactly as `--provenance` makes
43    /// the CLI filer select it, and `btctax-tui-edit` does not depend on `clap`, so it cannot reach
44    /// `ValueEnum::value_variants()`. Kept in lock-step with the enum by
45    /// `tests::provenance_all_covers_every_clap_value_variant`.
46    ///
47    /// ★ A **slice**, not a fixed-length array (whole-branch arch Nit-N1, narrowed before the first
48    /// publish): `[ProvenanceKind; 7]` bakes the variant COUNT into the public type, so adding a
49    /// provenance kind would be a breaking change for every downstream matcher. As a slice that addition
50    /// is purely additive.
51    pub const ALL: &[ProvenanceKind] = &[
52        ProvenanceKind::Purchase,
53        ProvenanceKind::Gift,
54        ProvenanceKind::Inheritance,
55        ProvenanceKind::Mining,
56        ProvenanceKind::Earned,
57        ProvenanceKind::Airdrop,
58        ProvenanceKind::Fork,
59    ];
60
61    /// The filer-facing label for one variant. `pub` (widened from `pub(crate)` for the TUI Promote
62    /// flow's BG-D5 step): the chokepoint (`crate::chokepoint::refuse_non_purchase`) builds its refusal
63    /// copy from this, so a driver offering the enumeration renders the SAME words the refusal names —
64    /// no second copy of the enumeration's wording.
65    pub fn label(self) -> &'static str {
66        match self {
67            ProvenanceKind::Purchase => "purchase",
68            ProvenanceKind::Gift => "gift",
69            ProvenanceKind::Inheritance => "inheritance",
70            ProvenanceKind::Mining => "mining",
71            ProvenanceKind::Earned => "staking/earning",
72            ProvenanceKind::Airdrop => "airdrop",
73            ProvenanceKind::Fork => "fork",
74        }
75    }
76}
77
78/// The exact phrase a filer must affirm to RECORD a `promote-tranche` decision (BG-D6). Distinct from the
79/// pseudo-export `ATTEST_PHRASE` (`lib.rs`) — promoting is an estimated-basis FILING CHOICE, not an
80/// attestation that a fictional draft is being exported on purpose; conflating the two phrases would let a
81/// scripted pseudo-export attest ALSO silently satisfy this gate (N-1). Compared TRIMMED, case-SENSITIVE
82/// (mirrors `require_attestation`'s compare, `lib.rs:208`).
83pub const PROMOTE_ACK_PHRASE: &str = "I understand and accept this estimated-basis risk";
84
85/// BG-D5 attested statement (verbatim, stored on `Acknowledgment.provenance_text`). The affirmative clause
86/// is operative; the negative enumeration is CLOSED so it cannot be misread `expressio unius` (tax r1
87/// M-6).
88pub const PROVENANCE_TEXT: &str = "these units were acquired by purchase within the declared window — \
89     not by gift, inheritance, mining, staking/earning, airdrop, fork, or any acquisition other than \
90     purchase";
91/// Attestation-text version (BG-D5) — bump if `PROVENANCE_TEXT`'s wording ever changes.
92pub const PROVENANCE_VERSION: &str = "v1";
93
94/// The consent-screen intro (BG-D6/D10): the penalty base, "plus interest", and the mitigation framing —
95/// NEVER "safe harbor" (SPEC BG-D7/D10 — the copy must not use that phrase even to deny it).
96const CONSENT_INTRO: &str = "Promoting this tranche is a KNOWING choice to file a >$0 basis floor \
97     (the minimum daily closing price over the attested acquisition window) instead of the IRS-fallback \
98     $0. If an exam determines the correct basis is $0, the penalty is 20% ordinary / 40% worst-case of \
99     the resulting additional tax (the underpayment attributable to the misstatement), plus interest; \
100     the Form 8275 disclosure and the good-faith window-low-close methodology mitigate this exposure, \
101     but do not eliminate it and do not guarantee immunity from penalty.";
102
103/// Render one `ConsentTerm` line for the consent screen (BG-D6/D10 copy).
104fn render_term(term: &ConsentTerm, gift_only_years: &BTreeSet<i32>) -> String {
105    match term {
106        ConsentTerm::ComputedTax {
107            year,
108            delta_usd,
109            deduction_delta_usd,
110        } => {
111            let mut line = if *delta_usd >= Usd::ZERO {
112                format!(
113                    "Year {year}: promoting this tranche SAVES ~${} in computed federal tax.",
114                    delta_usd.round_dp(2)
115                )
116            } else {
117                format!(
118                    "Year {year}: promoting this tranche INCREASES computed federal tax by ~${}.",
119                    (-*delta_usd).round_dp(2)
120                )
121            };
122            if let Some(d) = deduction_delta_usd {
123                line.push_str(&format!(
124                    " The computed tax figure does NOT capture this charitable-deduction change (priced \
125                     only on the full return); its own Δ is ~${}.",
126                    d.round_dp(2)
127                ));
128                if gift_only_years.contains(year) {
129                    line.push_str(
130                        " That Δ is a donee-basis (§1015) documentation change; the donor's 1040 is \
131                         unaffected — NOT a Schedule-A deduction.",
132                    );
133                }
134            }
135            line
136        }
137        ConsentTerm::Uncomputable {
138            year,
139            gain_delta_usd,
140            deduction_delta_usd,
141        } => {
142            let mut line = format!(
143                "Year {year}: tax not computable here (no table/profile/blocked) — promoting changes the \
144                 reported gain by ~${} and the deduction/basis by ~${}.",
145                gain_delta_usd.round_dp(2),
146                deduction_delta_usd.round_dp(2)
147            );
148            if *deduction_delta_usd != Usd::ZERO && gift_only_years.contains(year) {
149                line.push_str(
150                    " The deduction/basis figure is a donee-basis (§1015) documentation change; the \
151                     donor's 1040 is unaffected — NOT a Schedule-A deduction.",
152                );
153            }
154            line
155        }
156        ConsentTerm::CascadeNamed { year } => format!(
157            "Year {year}: this promote's cross-year effects may also shift that year's §1212(b)/§170(d) \
158             carryover-in (named here, not separately quantified)."
159        ),
160        ConsentTerm::Unrealized {
161            sat,
162            hypothetical_reduction,
163            as_of,
164        } => match (hypothetical_reduction, as_of) {
165            (Some(r), Some(d)) => format!(
166                "{sat} sat remain undisposed: at the {d} close, promoting would reduce a future sale's \
167                 reported gain by up to ~${} (hypothetical, not a filed figure) — saving and exposure \
168                 accrue only at disposal.",
169                r.round_dp(2)
170            ),
171            _ => format!(
172                "{sat} sat remain undisposed: no current bundled price is available — the filed floor \
173                 itself is the maximum possible gain reduction on a future sale (hypothetical, not a \
174                 filed figure) — saving and exposure accrue only at disposal."
175            ),
176        },
177    }
178}
179
180/// The consent screen (BG-D6/D10): the penalty-base/interest/mitigation intro, then one line per
181/// `ConsentTerm`. `gift_only_years` (T9 handoff) relabels a gift-only flagged year's deduction/basis-Δ as
182/// a §1015 donee-basis change rather than a Schedule-A deduction. Kept `pub` (not `pub(crate)`): external
183/// KATs in `tests/promote_cli.rs` call this two-arg renderer directly, independent of the full
184/// `chokepoint::PromotePlan` pipeline; `chokepoint::render_consent(&plan)` also calls this internally to
185/// reproduce the shipped `promote.rs:443-455` byte order (I-1).
186pub fn render_consent(terms: &[ConsentTerm], gift_only_years: &BTreeSet<i32>) -> String {
187    let mut out = String::new();
188    out.push_str(CONSENT_INTRO);
189    for term in terms {
190        out.push('\n');
191        out.push_str(&render_term(term, gift_only_years));
192    }
193    out
194}
195
196/// Append a `PromoteTranche` decision (BG-D1) promoting `target_ref`'s `$0` `DeclareTranche` to a filed
197/// `>$0` basis floor, behind the BG-D5 provenance gate, the BG-D7 Part II narrative gate, and the BG-D6
198/// informed-consent acknowledgment. `now` is the injected decision creation-time (deterministic in
199/// tests) — it ALSO doubles as the clock-free "current tax year" the T8 prior-year advisory is filtered
200/// against (years `>= current` are still being authored, not yet filed, so no 1040-X pointer is owed).
201///
202/// A thin driver over `crate::chokepoint`: `Session::open` → build args → `plan_promote` → print the
203/// consent screen → `apply_promote`. No pipeline logic remains here (Task 1).
204pub fn promote_tranche(
205    vault_path: &Path,
206    pp: &Passphrase,
207    target_ref: &str,
208    provenance: ProvenanceKind,
209    part_ii: String,
210    acknowledge: Option<&str>,
211    now: OffsetDateTime,
212) -> Result<btctax_core::EventId, CliError> {
213    let target_event_id = parse_event_id(target_ref)?;
214    let mut session = Session::open(vault_path, pp)?;
215    let events = btctax_core::persistence::load_all(session.conn())?;
216    let cfg = session.config()?.to_projection();
217
218    let plan = crate::chokepoint::plan_promote(
219        &events,
220        session.prices(),
221        &cfg,
222        &target_event_id,
223        provenance,
224        &part_ii,
225        now,
226    )
227    .map_err(CliError::from)?;
228
229    println!("{}", crate::chokepoint::render_consent(&plan));
230
231    let event_id = crate::chokepoint::apply_promote(&mut session, plan, acknowledge, now)?;
232
233    // Approach-B experimental disclosure (`design/approach-b-experimental-notice`): promoting a tranche
234    // is exactly "acting on this feature" — warn on stderr (never stdout — stdout is parsed/piped),
235    // mirroring `cmd::tranche::declare_tranche`'s own placement (I/O, not gate logic, AFTER the write
236    // succeeds — a refused/missing-acknowledgment attempt, which returns via the `?` above, never emits
237    // this). UNCONDITIONAL, not re-derived from a re-read: `apply_promote` just returned `Ok`, so a live
238    // (non-voided) `PromoteTranche` now exists BY CONSTRUCTION — `uses_approach_b` is trivially true
239    // here. ★ fix round 1 Important: the original re-read used `load_all(session.conn())?`, so a
240    // transient I/O error on that PURELY DISCLOSURE-driving read turned an ALREADY-SUCCEEDED promote
241    // into a non-zero exit — the natural response (re-run) is refused anyway (a tranche promotes once),
242    // but it still misreports success as failure. Never re-derive a disclosure from a re-read when the
243    // write it depends on already told you the answer.
244    eprint!("\n⚠ {}", btctax_core::experimental::NOTICE.plain_text());
245
246    Ok(event_id)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use clap::ValueEnum;
253
254    /// Drift guard for `ProvenanceKind::ALL` (added for the TUI's BG-D5 provenance step): the array a
255    /// driver offers the filer MUST stay the SAME closed enumeration `--provenance` accepts. Adding a
256    /// variant without extending `ALL` would silently make it un-offerable in the wizard while the CLI
257    /// still accepts it.
258    #[test]
259    fn provenance_all_covers_every_clap_value_variant() {
260        let variants = ProvenanceKind::value_variants();
261        assert_eq!(
262            ProvenanceKind::ALL.len(),
263            variants.len(),
264            "ProvenanceKind::ALL must list EVERY variant (the closed BG-D5 enumeration): {:?} vs {:?}",
265            ProvenanceKind::ALL,
266            variants
267        );
268        for v in variants {
269            assert!(
270                ProvenanceKind::ALL.contains(v),
271                "ProvenanceKind::ALL is missing {v:?} — a driver could never offer it to the filer"
272            );
273        }
274        assert_eq!(
275            ProvenanceKind::ALL[0],
276            ProvenanceKind::Purchase,
277            "Purchase (the only gate-passing value) stays first so a driver's own ordering is stable"
278        );
279    }
280
281    /// Every variant has a distinct, non-empty filer-facing label (the refusal copy names it).
282    #[test]
283    fn every_provenance_label_is_distinct_and_non_empty() {
284        let mut seen = std::collections::BTreeSet::new();
285        for k in ProvenanceKind::ALL {
286            let l = k.label();
287            assert!(!l.trim().is_empty(), "{k:?} has an empty label");
288            assert!(seen.insert(l), "duplicate provenance label: {l}");
289        }
290    }
291}