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}