btctax_cli/chokepoint/mod.rs
1//! Defensive Filing Wizard, sub-project-2 Phase P-A, Task 1 — the PROMOTE chokepoint: a reusable
2//! plan/confirm/apply pipeline extracted VERBATIM from the shipped CLI verb
3//! (`cmd::promote::promote_tranche`, Approach-B Task 10, `cmd/promote.rs:364-488`) so a future TUI can
4//! drive the EXACT SAME gated pipeline as the CLI. `cmd::promote::promote_tranche` is now a thin driver
5//! over this module: `Session::open` → build args → `plan_promote` (map `Refusal` → `CliError`) →
6//! `println!("{}", render_consent(&plan))` → prompt/collect ack → `apply_promote`.
7//!
8//! **Gate ordering (DFW-D2) MUST match `cmd/promote.rs:378-485` exactly:** resolve-live → BG-D5
9//! provenance → BG-D7 Part II → BG-D3 floor/coverage → BG-D6 `consent_terms` → synthetic-promote advisory
10//! → gift-only relabel → consent render (incl. `wide_window_note`) → **ack inside `apply_promote`,
11//! fail-closed** → `would_conflict` → append.
12//!
13//! ★ **I-1 (byte-parity):** the shipped verb prints, IN ORDER (`promote.rs:443-455`), the
14//! synthetic-promote ADVISORY (pre-consent) → `render_consent(&terms, &gift_only_years)` → the
15//! `wide_window_note` (post-consent). `PromotePlan` therefore carries THREE ordered pieces
16//! (`advisory_lines`, `gift_only_years`, `post_consent_note`) so this module's `render_consent(&plan)`
17//! reproduces `advisory → consent → note` byte-for-byte when printed via a single `println!` — a single
18//! flat `Vec` cannot place `terms` BETWEEN the pre-advisory and the note; do NOT collapse the three. The
19//! shipped `render_consent(terms, gift_only_years)` stays in `cmd::promote` (still `pub` — external KATs
20//! in `tests/promote_cli.rs` call it directly — and is invoked from here); `gift_only_flagged_years`/
21//! `wide_window_note` move HERE.
22//!
23//! ★ **DFW-D6 (the ONE intended behavior change — the sub-1 pseudo-off fix):** `plan_promote` forces
24//! `cfg.pseudo_reconcile = false` on its own COPY (`ProjectionConfig` is `Copy`) before `consent_terms` /
25//! `promote_prior_year_advisory` / `gift_only_flagged_years` — mirroring `would_conflict`
26//! (`project/mod.rs:118`). Without this, a pseudo-active vault's consent screen — and the RECORDED
27//! `Acknowledgment.shown_terms`, the §6664(c) good-faith artifact — could fold in a synthetic default that
28//! was never persisted, misstating what the filer actually acknowledged.
29//!
30//! ★ **arch-m-6/tax-N-1:** `Refusal::Target` covers the resolve-live gate — unknown/voided/wrong-type
31//! target only (`resolve_live_tranche`). Already-promoted (a DOUBLE promote) is NOT caught here; it
32//! surfaces as `would_conflict` at APPLY time (a `CliError`, never a plan `Refusal`) — mirroring
33//! `promote.rs:475-483`.
34//!
35//! ★ **arch-m-new-3:** `plan_promote` takes no `Session`/`state` — the shipped pipeline rebuilds
36//! everything from `events` (`promote.rs:364-488`) — so a caller (CLI or future TUI) supplies its own
37//! already-loaded `events`/`prices`/`cfg`.
38//!
39//! **Task 2 — the DECLARE chokepoint:** `plan_declare`/`apply_declare`, extracted from the shipped
40//! `cmd::tranche::declare_tranche` (`tranche.rs:120-175`). `plan_declare` gates on the shipped set
41//! (`sat>0`, `ws<=we`, `guard_tranche_vs_allocation` — the LAST one stays defined in `cmd::tranche`, not
42//! duplicated here) ALWAYS; **iff `target_shortfall = Some(id)`** it ALSO runs the DFW-D5.2 target-scoped
43//! clearance shadow: append the candidate `DeclareTranche` → re-project (pseudo FORCED off, mirroring
44//! `would_conflict`) → assert no `BlockerKind::UncoveredDisposal` remains on `id`; else `Refusal::Coverage`.
45//! `target_shortfall = None` (the CLI free-form path) is the shipped `declare_tranche` gate set,
46//! BYTE-FOR-BYTE (DFW-D8/SPEC §5) — no clearance shadow runs at all. `apply_declare` is a plain
47//! append+save (declaring is `$0`/revocable/no-Form-8275 — DFW-D8 — so unlike promote there is no
48//! acknowledgment gate and no `would_conflict` pre-check; the shipped verb never ran one either).
49//!
50//! ★ **Refusal-variant note:** the shared `Refusal` enum stays CLOSED at the four Task-1 variants (the
51//! plan review explicitly rejected adding a new `Conflict` variant for this task). The shipped-set gates
52//! (sat/window/allocation) have no promote-shaped variant of their own, so — like the new clearance
53//! failure the brief names explicitly — they map to `Refusal::Coverage`. Every variant collapses to the
54//! identical `CliError::Usage(msg)` via `From<Refusal>` (below), so this is a pure internal-taxonomy
55//! choice: the filer-facing message text is unchanged from the shipped verb either way.
56
57use crate::cli::FormArg;
58use crate::cmd::admin::IrsPdfReport;
59use crate::cmd::promote::{
60 render_consent as render_consent_terms, ProvenanceKind, PROMOTE_ACK_PHRASE, PROVENANCE_TEXT,
61 PROVENANCE_VERSION,
62};
63use crate::{CliError, Session};
64use btctax_core::conservative::{self, Direction};
65use btctax_core::conservative_promote::{self, PromoteRefusal};
66use btctax_core::conventions::tax_date;
67use btctax_core::event::{
68 Acknowledgment, ConsentTerm, DeclareTranche, EventPayload, FloorMethod, PromoteTranche,
69};
70use btctax_core::persistence::{append_decision, load_all};
71use btctax_core::price::PriceProvider;
72use btctax_core::project::ProjectionConfig;
73use btctax_core::state::BlockerKind;
74use btctax_core::{
75 project, EventId, LedgerEvent, LedgerState, RemovalKind, Sat, TaxDate, TaxTables, Usd, WalletId,
76};
77use std::collections::BTreeSet;
78use std::path::PathBuf;
79use time::{OffsetDateTime, UtcOffset};
80
81/// Everything computed BEFORE the filer types the acknowledgment phrase (the `PromoteTranche` decision
82/// id, `target`, is not yet known — it is assigned at `apply_promote`'s `append_decision`). ★ I-1: the
83/// three ordered fields (`advisory_lines`, `gift_only_years`, `post_consent_note`) let `render_consent`
84/// reproduce the shipped verb's `advisory → consent → note` byte order — do NOT collapse them into one
85/// `Vec` or pre-render `gift_only_years` into a string.
86#[derive(Debug, Clone)]
87pub struct PromotePlan {
88 /// The `DeclareTranche` decision this promotes (BG-D1) — the `PromoteTranche.target` field.
89 pub target: EventId,
90 /// BG-D6 `consent_terms` output — ALSO snapshotted verbatim onto `payload`'s
91 /// `Acknowledgment.shown_terms` (the §6664(c) good-faith artifact).
92 pub terms: Vec<ConsentTerm>,
93 /// The PRE-consent synthetic-promote advisory lines (`promote.rs:443`, `for line in &advisory`).
94 pub advisory_lines: Vec<String>,
95 /// T9 handoff: an INPUT to the shipped `render_consent(terms, gift_only_years)`
96 /// (`promote.rs:333`/`:453`) — NOT a pre-rendered string.
97 pub gift_only_years: BTreeSet<i32>,
98 /// `wide_window_note`, printed AFTER the consent screen (`promote.rs:454`).
99 pub post_consent_note: Option<String>,
100 /// The `PromoteTranche` payload `apply_promote` appends on a successful acknowledgment.
101 pub payload: EventPayload,
102}
103
104/// A `plan_promote` refusal — fail-closed, BEFORE any computation past the failing gate. Each variant
105/// carries the exact filer-facing message (byte-identical to the shipped verb's `CliError::Usage` text),
106/// so mapping to `CliError` (the thin CLI driver) or a distinct TUI error surface is trivial either way.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum Refusal {
109 /// The resolve-live gate (`resolve_live_tranche`): `target` is absent, wrong-type, or voided.
110 Target(String),
111 /// BG-D5: a non-`Purchase` provenance.
112 Provenance(String),
113 /// BG-D3 (promote): `filed_basis_for` could not produce a trustworthy floor
114 /// (`NoCoverage`/`PartialCoverage`). ALSO (Task 2, declare): the shipped-set gates (`sat>0`, `ws<=we`,
115 /// `guard_tranche_vs_allocation`) AND the DFW-D5.2 target-scoped clearance shadow (a candidate tranche
116 /// that does not clear the named shortfall) — see the module doc's "Refusal-variant note".
117 Coverage(String),
118 /// BG-D7: an empty/whitespace Form 8275 Part II narrative.
119 PartII(String),
120}
121
122impl From<Refusal> for CliError {
123 fn from(r: Refusal) -> CliError {
124 let msg = match r {
125 Refusal::Target(m) => m,
126 Refusal::Provenance(m) => m,
127 Refusal::Coverage(m) => m,
128 Refusal::PartII(m) => m,
129 };
130 CliError::Usage(msg)
131 }
132}
133
134/// True iff a live (non-voided) `VoidDecisionEvent` names `id`. Moved verbatim from `cmd/promote.rs`.
135fn is_voided(events: &[LedgerEvent], id: &EventId) -> bool {
136 events.iter().any(
137 |e| matches!(&e.payload, EventPayload::VoidDecisionEvent(v) if v.target_event_id == *id),
138 )
139}
140
141/// Resolve `target_event_id` to a LIVE (present, non-voided) `DeclareTranche`, or `Refusal::Target`. A
142/// record-time convenience guard — the engine's own `DecisionConflict` adjudication is the backstop for
143/// any target this misses (moved verbatim from `cmd/promote.rs::resolve_live_tranche`, DFW-D2 gate 1).
144fn resolve_live_tranche(
145 events: &[LedgerEvent],
146 target_event_id: &EventId,
147) -> Result<DeclareTranche, Refusal> {
148 let not_live = || {
149 Refusal::Target(format!(
150 "{} is not a live DeclareTranche (absent, wrong type, or voided) — see `btctax events list` \
151 for event refs + decision status",
152 target_event_id.canonical()
153 ))
154 };
155 if is_voided(events, target_event_id) {
156 return Err(not_live());
157 }
158 events
159 .iter()
160 .find(|e| e.id == *target_event_id)
161 .and_then(|e| match &e.payload {
162 EventPayload::DeclareTranche(t) => Some(t.clone()),
163 _ => None,
164 })
165 .ok_or_else(not_live)
166}
167
168/// BG-D5: refuse a non-`Purchase` provenance — the closed enumeration, fail-closed, before any
169/// computation. Moved verbatim from `cmd/promote.rs::refuse_non_purchase`.
170fn refuse_non_purchase(provenance: ProvenanceKind) -> Refusal {
171 Refusal::Provenance(format!(
172 "promote-tranche requires purchase provenance: {PROVENANCE_TEXT}. This tranche was declared as \
173 acquired by {label} — a {label} recipient already has a documented, real basis (income \
174 FMV-at-receipt; a §1015 donor carryover for a gift; a §1014 date-of-death basis for an \
175 inheritance) — model the real acquisition instead (a documented \
176 Acquire/Income/gift-received event), not a conservative-filing tranche promote.",
177 label = provenance.label(),
178 ))
179}
180
181/// BG-D3: translate a `filed_basis_for` refusal into a record-time message. Moved verbatim from
182/// `cmd/promote.rs::refuse_no_floor`.
183fn refuse_no_floor(e: PromoteRefusal, window_start: TaxDate, window_end: TaxDate) -> Refusal {
184 let detail = match e {
185 PromoteRefusal::NoCoverage => {
186 "no bundled daily-close price exists anywhere in the window — never fabricate a floor over a \
187 total data gap"
188 }
189 PromoteRefusal::PartialCoverage => {
190 "the window has a gap in bundled daily-close data — the covered-part minimum is not provably \
191 the window's true minimum, so it cannot be filed as a trustworthy floor"
192 }
193 };
194 Refusal::Coverage(format!(
195 "cannot compute a promotion floor for the window [{window_start}, {window_end}]: {detail}. \
196 Narrow the window to a fully-covered range, or leave this tranche at its filed $0 basis."
197 ))
198}
199
200/// A filer-facing caution (SPEC §1, "two honest limits"): a wide acquisition window yields a LOW
201/// ("trivial") floor relative to a tight one. Purely informational, non-gating; conditioned on the
202/// window exceeding one year. Moved verbatim from `cmd/promote.rs::wide_window_note`.
203fn wide_window_note(window_start: TaxDate, window_end: TaxDate) -> Option<String> {
204 let days = (window_end - window_start).whole_days();
205 if days > 365 {
206 Some(format!(
207 "note: this tranche's declared window spans {days} days (over a year). A WIDE window tends \
208 to produce a LOW (\"trivial\") floor relative to a tight one — for some filers it may be \
209 simpler, and just as conservative, to leave this tranche at its filed $0 basis and skip the \
210 Form 8275 disclosure surface entirely."
211 ))
212 } else {
213 None
214 }
215}
216
217/// Thread ONE synthetic `PromoteTranche(tranche_id, filed_basis)` onto `events` (mirrors
218/// `conservative_promote::with_synthetic_promote`, private there). Moved verbatim from
219/// `cmd/promote.rs::with_synthetic_promote`.
220fn with_synthetic_promote(
221 events: &[LedgerEvent],
222 tranche_id: &EventId,
223 filed_basis: Usd,
224 now: OffsetDateTime,
225) -> Vec<LedgerEvent> {
226 let seq = events
227 .iter()
228 .filter_map(|e| match e.id {
229 EventId::Decision { seq } => Some(seq),
230 _ => None,
231 })
232 .max()
233 .map_or(1, |m| m + 1);
234 let mut out = events.to_vec();
235 out.push(LedgerEvent {
236 id: EventId::decision(seq),
237 utc_timestamp: now,
238 original_tz: UtcOffset::UTC,
239 wallet: None,
240 payload: EventPayload::PromoteTranche(PromoteTranche {
241 target: tranche_id.clone(),
242 method: FloorMethod::WindowLowClose,
243 filed_basis,
244 coverage: conservative::Coverage::Full,
245 provenance_attested: true,
246 acknowledgment: Acknowledgment {
247 phrase: String::new(),
248 shown_terms: Vec::new(),
249 provenance_text: String::new(),
250 provenance_version: String::new(),
251 },
252 part_ii_narrative: String::new(),
253 }),
254 });
255 out
256}
257
258/// T9 handoff (progress.md Task 9): `consent_terms`/`Uncomputable` sum the §170(e) charitable-deduction
259/// change and the §1015 gift-basis change into ONE `deduction_delta_usd` figure per year. This re-derives
260/// which flagged years are GIFT-only directly from the SAME with/without fold pair the T8 advisory
261/// already builds. Moved verbatim from `cmd/promote.rs::gift_only_flagged_years`.
262fn gift_only_flagged_years(
263 prices: &dyn PriceProvider,
264 config: &ProjectionConfig,
265 events: &[LedgerEvent],
266 with_events: &[LedgerEvent],
267) -> BTreeSet<i32> {
268 let without_state = project(events, prices, config);
269 let with_state = project(with_events, prices, config);
270
271 let mut years: BTreeSet<i32> = BTreeSet::new();
272 for st in [&with_state, &without_state] {
273 for r in &st.removals {
274 years.insert(r.removed_at.year());
275 }
276 }
277
278 let rem =
279 |st: &btctax_core::LedgerState, y: i32, k: RemovalKind| -> Vec<btctax_core::Removal> {
280 st.removals
281 .iter()
282 .filter(|r| r.removed_at.year() == y && r.kind == k)
283 .cloned()
284 .collect()
285 };
286
287 years
288 .into_iter()
289 .filter(|&y| {
290 let gift_changed =
291 rem(&with_state, y, RemovalKind::Gift) != rem(&without_state, y, RemovalKind::Gift);
292 let don_changed = rem(&with_state, y, RemovalKind::Donation)
293 != rem(&without_state, y, RemovalKind::Donation);
294 gift_changed && !don_changed
295 })
296 .collect()
297}
298
299/// The acknowledgment gate (BG-D6) — a PURE exact-compare, no I/O (mirrors `require_attestation`,
300/// `lib.rs:208`). Moved verbatim from `cmd/promote.rs::require_promote_ack`; now called from
301/// `apply_promote`, fail-closed, BEFORE `would_conflict`/append.
302fn require_promote_ack(acknowledge: Option<&str>) -> Result<(), CliError> {
303 match acknowledge.map(str::trim) {
304 Some(p) if p == PROMOTE_ACK_PHRASE => Ok(()),
305 Some(_) => Err(CliError::Usage(format!(
306 "the acknowledgment phrase did not match. Type it EXACTLY (trimmed, case-sensitive): {PROMOTE_ACK_PHRASE:?}."
307 ))),
308 None => Err(CliError::Usage(format!(
309 "promote-tranche requires acknowledging the estimated-basis risk shown above — pass \
310 --i-acknowledge {PROMOTE_ACK_PHRASE:?} (or type it at the interactive prompt)."
311 ))),
312 }
313}
314
315/// Plan a `PromoteTranche` decision — the DFW-D2 gate order, everything computable BEFORE the filer types
316/// the acknowledgment phrase: resolve-live → BG-D5 provenance → BG-D7 Part II → BG-D3 floor/coverage →
317/// BG-D6 `consent_terms` → synthetic-promote advisory → gift-only relabel. `events`/`prices`/`cfg` are the
318/// caller's own already-loaded state (arch-m-new-3: no `Session` here — the CLI's thin driver and a
319/// future TUI each supply their own).
320pub fn plan_promote(
321 events: &[LedgerEvent],
322 prices: &dyn PriceProvider,
323 cfg: &ProjectionConfig,
324 target: &EventId,
325 provenance: ProvenanceKind,
326 part_ii: &str,
327 now: OffsetDateTime,
328) -> Result<PromotePlan, Refusal> {
329 // Resolve + assert live (BG-D1).
330 let tranche = resolve_live_tranche(events, target)?;
331
332 // BG-D5: purchase provenance only — fail-closed, before any computation.
333 if provenance != ProvenanceKind::Purchase {
334 return Err(refuse_non_purchase(provenance));
335 }
336
337 // BG-D7: an empty/whitespace Part II narrative is refused at record time (present-by-construction).
338 if part_ii.trim().is_empty() {
339 return Err(Refusal::PartII(
340 "promote-tranche requires a non-empty Form 8275 Part II narrative (filer facts, Reg. \
341 §1.6662-4(f) — 'in sufficient detail') — pass --part-ii-file pointing at a file with real \
342 acquisition/window facts, not an empty or blank file"
343 .into(),
344 ));
345 }
346
347 // BG-D3: the computed whole-tranche filed_basis floor — hard-refuse on Partial/No coverage.
348 let floor = conservative_promote::filed_basis_for(
349 prices,
350 tranche.sat,
351 tranche.window_start,
352 tranche.window_end,
353 )
354 .map_err(|e| refuse_no_floor(e, tranche.window_start, tranche.window_end))?;
355
356 // ★ DFW-D6 (the ONE intended behavior change): force pseudo OFF on an own COPY (ProjectionConfig is
357 // Copy) before consent_terms / promote_prior_year_advisory / gift_only_flagged_years — mirrors
358 // `would_conflict` (`project/mod.rs:118`). The recorded Acknowledgment.shown_terms must always
359 // reflect the HONEST (non-synthetic) figures, never a pseudo-active default folded in.
360 let mut honest_cfg = *cfg;
361 honest_cfg.pseudo_reconcile = false;
362
363 let tables = btctax_adapters::BundledTaxTables::load();
364 // A single stored TaxProfile cannot fit the multi-year span this consent/advisory ranges over, so
365 // `None` is passed throughout — mirrors the void-direction path (`cmd/reconcile.rs`
366 // `promote_void_advisory_lines`): the tax-Δ arm falls back to the gain/deduction-Δ sign, and the
367 // amend direction is still correct.
368 let terms = conservative_promote::consent_terms(
369 events,
370 prices,
371 &honest_cfg,
372 target,
373 floor.filed_basis,
374 None,
375 &tables,
376 );
377
378 // Thread ONE synthetic promote so the Direction::Promote advisory AND this layer's own
379 // gift-vs-donation year classification (T9 handoff) see the SAME post-promote fold.
380 let with_events = with_synthetic_promote(events, target, floor.filed_basis, now);
381 let synthetic_id = with_events
382 .last()
383 .expect("with_synthetic_promote always pushes exactly one event")
384 .id
385 .clone();
386
387 // T8 handoff (progress.md): `current` is the injected `now`'s tax year (the BTCTAX_NOW seam) — NEVER
388 // a wall clock. Years `< current` are presumed already filed; the year still being authored
389 // (>= current) is excluded, so it is never told it needs an amended return.
390 let current = tax_date(now, UtcOffset::UTC).year();
391 let advisory_lines = conservative::promote_prior_year_advisory(
392 &with_events,
393 prices,
394 &honest_cfg,
395 &synthetic_id,
396 Direction::Promote,
397 None,
398 &tables,
399 current,
400 );
401
402 // T9 handoff: which flagged years are GIFT-ONLY (no donation) — relabels that year's deduction/basis-Δ
403 // as a §1015 donee-basis change, never Schedule-A, in the consent screen below.
404 let gift_only_years = gift_only_flagged_years(prices, &honest_cfg, events, &with_events);
405
406 let payload = EventPayload::PromoteTranche(PromoteTranche {
407 target: target.clone(),
408 method: FloorMethod::WindowLowClose,
409 filed_basis: floor.filed_basis,
410 coverage: floor.coverage,
411 provenance_attested: true,
412 acknowledgment: Acknowledgment {
413 phrase: PROMOTE_ACK_PHRASE.to_string(),
414 shown_terms: terms.clone(),
415 provenance_text: PROVENANCE_TEXT.to_string(),
416 provenance_version: PROVENANCE_VERSION.to_string(),
417 },
418 part_ii_narrative: part_ii.to_string(),
419 });
420
421 Ok(PromotePlan {
422 target: target.clone(),
423 terms,
424 advisory_lines,
425 gift_only_years,
426 post_consent_note: wide_window_note(tranche.window_start, tranche.window_end),
427 payload,
428 })
429}
430
431/// Re-emit the shipped verb's ordered filer-visible text: `advisory_lines` → the shipped
432/// `render_consent(&plan.terms, &plan.gift_only_years)` (`cmd::promote`) → `post_consent_note` — ★ I-1:
433/// byte-identical to `promote.rs:443-455` when the RESULT is printed via a single
434/// `println!("{}", render_consent(&plan))` (the shipped verb instead used three separate `println!`
435/// calls; a single combined string reproduces the exact same stdout bytes because `println!` always adds
436/// exactly one trailing `\n`). Do NOT collapse this into `plan.terms`/`plan.gift_only_years` alone — the
437/// pre-advisory must land BEFORE the consent screen and the note AFTER it.
438pub fn render_consent(plan: &PromotePlan) -> String {
439 let mut out = String::new();
440 for line in &plan.advisory_lines {
441 out.push_str(line);
442 out.push('\n');
443 }
444 out.push_str(&render_consent_terms(&plan.terms, &plan.gift_only_years));
445 if let Some(note) = &plan.post_consent_note {
446 out.push('\n');
447 out.push_str(note);
448 }
449 out
450}
451
452/// Apply a planned promote: the acknowledgment gate (BG-D6, fail-closed, INSIDE apply) → `would_conflict`
453/// pre-check (BG-D9 — a second live promote on this target, or any other resolver-level conflict; refuses
454/// BEFORE appending, NOT last-wins) → append + save. Reloads `events`/`cfg` fresh from `session`
455/// (arch-m-new-3: `plan_promote` took no `Session`) — a single synchronous CLI/TUI invocation cannot
456/// append anything between `plan_promote` and `apply_promote`, so this is behavior-preserving.
457pub fn apply_promote(
458 session: &mut Session,
459 plan: PromotePlan,
460 acknowledge: Option<&str>,
461 now: OffsetDateTime,
462) -> Result<EventId, CliError> {
463 require_promote_ack(acknowledge)?;
464
465 let events = load_all(session.conn())?;
466 let cfg = session.config()?.to_projection();
467
468 // BG-D9: pre-check `would_conflict` (a second live promote on this target, or any other resolver-level
469 // conflict, e.g. UX-P4-3) — refuse BEFORE appending (fail-closed). NOT last-wins.
470 if let Some(detail) =
471 btctax_core::would_conflict(&events, session.prices(), &cfg, &plan.payload, now)
472 {
473 return Err(CliError::Usage(format!(
474 "cannot record this promote — a decision conflict: {detail}"
475 )));
476 }
477
478 let id = append_decision(session.conn(), plan.payload, now, UtcOffset::UTC, None)?;
479 session.save()?;
480 Ok(id)
481}
482
483// ════════════════════════════════════════════════════════════════════════════════════════════════
484// Task 2 — the DECLARE chokepoint: `plan_declare`/`apply_declare` (module doc has the full contract).
485// ════════════════════════════════════════════════════════════════════════════════════════════════
486
487/// Everything needed to append a `DeclareTranche` decision. Unlike `PromotePlan`, declaring has no
488/// acknowledgment gate or consent screen (DFW-D8 — a plain `$0`, revocable confirmation) — so there is
489/// nothing else to carry beside the payload itself.
490#[derive(Debug, Clone, PartialEq, Eq)]
491pub struct DeclarePlan {
492 /// The `EventPayload::DeclareTranche` `apply_declare` appends verbatim.
493 pub payload: EventPayload,
494}
495
496/// Plan a `DeclareTranche` decision. Gates on the shipped set ALWAYS — `sat>0`, `ws<=we`,
497/// `guard_tranche_vs_allocation` (`cmd::tranche`, the single source of that guard for all four allocation
498/// append sites — NOT duplicated here) — replicating `cmd/tranche.rs:134-154` exactly (arch-m-new-3: no
499/// `Session` — the caller supplies its own already-loaded `events`/`prices`/`cfg`, mirroring
500/// `plan_promote`).
501///
502/// **Iff `target_shortfall = Some(id)`**, ALSO runs the DFW-D5.2 target-scoped clearance shadow: append
503/// the candidate `DeclareTranche` → re-project (pseudo FORCED off on a config COPY, mirroring
504/// `would_conflict`, `project/mod.rs:118`) → assert no `BlockerKind::UncoveredDisposal` remains on `id`;
505/// else `Refusal::Coverage`. **Forcing pseudo off here is load-bearing (arch-I-5):** a synthetic
506/// `SelfTransferMine{$0}` pseudo default must never stand in for a real, documented cover — else a
507/// dashboard candidate could be reported as "clears the shortfall" when only a fictional, non-persisted
508/// default actually covered it.
509///
510/// `target_shortfall = None` (the CLI free-form `declare-tranche` path) never runs the clearance shadow —
511/// the shipped verb's gate set, byte-for-byte (DFW-D8/SPEC §5).
512#[allow(clippy::too_many_arguments)]
513pub fn plan_declare(
514 events: &[LedgerEvent],
515 prices: &dyn PriceProvider,
516 cfg: &ProjectionConfig,
517 sat: Sat,
518 wallet: WalletId,
519 window_start: TaxDate,
520 window_end: TaxDate,
521 target_shortfall: Option<EventId>,
522 now: OffsetDateTime,
523) -> Result<DeclarePlan, Refusal> {
524 // The shipped gate set (cmd/tranche.rs:134-154), byte-for-byte.
525 if sat <= 0 {
526 // A `sat <= 0` tranche would bump `stats.sigma_in` by a non-positive amount (fold.rs),
527 // corrupting Σ-conservation; there is no such thing as declaring zero/negative undocumented BTC.
528 return Err(Refusal::Coverage(format!(
529 "tranche amount must be > 0 sat (got {sat})"
530 )));
531 }
532 if window_start > window_end {
533 return Err(Refusal::Coverage(format!(
534 "tranche window_start ({window_start}) must be <= window_end ({window_end})"
535 )));
536 }
537 crate::cmd::tranche::guard_tranche_vs_allocation(events, window_end).map_err(|e| match e {
538 CliError::Usage(m) => Refusal::Coverage(m),
539 other => Refusal::Coverage(other.to_string()), // unreachable today (the guard only ever returns Usage)
540 })?;
541
542 let payload = EventPayload::DeclareTranche(DeclareTranche {
543 sat,
544 wallet,
545 window_start,
546 window_end,
547 });
548
549 // DFW-D5.2: the target-scoped clearance shadow — ONLY when the caller names a shortfall to cover.
550 if let Some(id) = target_shortfall {
551 // ★ arch-I-5: pseudo FORCED off on a COPY (ProjectionConfig is Copy) — mirrors `would_conflict`
552 // (project/mod.rs:118). Without this, a pseudo-active vault could report a candidate as clearing
553 // a shortfall that only a synthetic, non-persisted SelfTransferMine{$0} default actually covered.
554 let mut honest_cfg = *cfg;
555 honest_cfg.pseudo_reconcile = false;
556
557 // Append the candidate as the resolver would (the next decision seq — mirrors `would_conflict`
558 // and `with_synthetic_promote` above).
559 let next_seq = events
560 .iter()
561 .filter_map(|e| match e.id {
562 EventId::Decision { seq } => Some(seq),
563 _ => None,
564 })
565 .max()
566 .map_or(1, |m| m + 1);
567 let candidate = LedgerEvent {
568 id: EventId::decision(next_seq),
569 utc_timestamp: now,
570 original_tz: UtcOffset::UTC,
571 wallet: None,
572 payload: payload.clone(),
573 };
574 let mut with_candidate = events.to_vec();
575 with_candidate.push(candidate);
576
577 let state = project(&with_candidate, prices, &honest_cfg);
578 let still_uncovered = state
579 .blockers
580 .iter()
581 .any(|b| b.kind == BlockerKind::UncoveredDisposal && b.event.as_ref() == Some(&id));
582 if still_uncovered {
583 return Err(Refusal::Coverage(format!(
584 "this candidate tranche does not clear the shortfall on {} — after adding it, an \
585 UncoveredDisposal blocker still remains on that event. A tranche's synthetic acquisition \
586 lands at window_end and sorts AFTER a same-instant import, so window_end must be \
587 STRICTLY BEFORE the short event's date (and the wallet/sat must actually cover it) to \
588 clear it.",
589 id.canonical()
590 )));
591 }
592 }
593
594 Ok(DeclarePlan { payload })
595}
596
597/// Apply a planned declare: append + save. No acknowledgment gate and no `would_conflict` pre-check
598/// (DFW-D8 — declaring is a plain `$0`, revocable confirmation, unlike promote's typed-phrase tier; the
599/// shipped verb never ran a `would_conflict` check either — `cmd/tranche.rs:166-174` appends immediately
600/// once `guard_tranche_vs_allocation` passes).
601pub fn apply_declare(
602 session: &mut Session,
603 plan: DeclarePlan,
604 now: OffsetDateTime,
605) -> Result<EventId, CliError> {
606 let id = append_decision(session.conn(), plan.payload, now, UtcOffset::UTC, None)?;
607 session.save()?;
608 Ok(id)
609}
610
611// ════════════════════════════════════════════════════════════════════════════════════════════════
612// Task 3 — the EXPORT chokepoint (degenerate trio): `plan_export`/`apply_export`, composed over the
613// ALREADY-SHIPPED `export_irs_pdf_from_session` (`cmd::admin`, ★ arch-C-1) so a future TUI can drive the
614// EXACT SAME gated IRS-PDF export pipeline the CLI does WITHOUT a second `Session::open` (a second open
615// under the TUI's held `VaultLock` deadlocks the editor, `session.rs:662`). Unlike promote/declare,
616// export APPENDS no decision — there is no acknowledgment gate, and `apply_export` takes `&Session`
617// (never `&mut`), mirroring `export_full_return`'s own `&Session` parameterization.
618// ════════════════════════════════════════════════════════════════════════════════════════════════
619
620/// BG-D8's 8275-completeness year enumeration ONLY — extracted verbatim from `promote_export_gate`'s
621/// `None` arm (`cmd/admin.rs`) so the gate and any other 8275-completeness caller single-source it (SPEC
622/// DFW-D11: "single-sourced ... but it is NOT the export set"). A year is included iff a PROMOTED
623/// disposal leg (`lot_id.origin_event_id ∈ state.promoted_origins`) files in it — DISPOSAL legs only; a
624/// promote's REMOVAL (donation/gift) reorder is invisible here by design — that is exactly what
625/// `flagged_years` (`btctax_core::conservative`), below, exists to also catch.
626///
627/// ★ whole-branch arch M-2: `pub(crate)`, not `pub`. Its only production caller is in-crate
628/// (`cmd/admin.rs`'s `promote_export_gate`); it was `pub` solely to serve one integration test, which
629/// would have put it on btctax-cli's v0.10.0 PUBLIC API for good. Public API is far cheaper to narrow
630/// before the first release than after. The disposal-legs-only contract is pinned by the in-crate unit
631/// test below instead.
632pub(crate) fn promoted_filing_years(state: &LedgerState) -> BTreeSet<i32> {
633 let mut years = BTreeSet::new();
634 for d in &state.disposals {
635 if d.legs
636 .iter()
637 .any(|l| state.promoted_origins.contains(&l.lot_id.origin_event_id))
638 {
639 years.insert(d.disposed_at.year());
640 }
641 }
642 years
643}
644
645/// A planned multi-year IRS-PDF export (DFW-D11): the year-set, the shared `--out` base directory, and
646/// the `--forms` slice (honored on a crypto-slice year, ignored on a full-return year —
647/// `IrsPdfReport::forms_ignored_full_return` says so per year). `apply_export` writes each year's packet
648/// into its OWN `out_dir/<year>` subdirectory (never the bare `out_dir` itself) — the crypto-slice
649/// pipeline writes BARE filenames (`f8949.pdf`, `schedule_d.pdf`, …) for exactly one tax year at a time,
650/// so a shared, un-suffixed directory across two exported years would let the second year silently
651/// clobber the first's packet (the same chimera-return hazard `export_full_return`'s sequence-prefixed
652/// names guard against).
653#[derive(Debug, Clone, PartialEq, Eq)]
654pub struct ExportPlan {
655 pub years: BTreeSet<i32>,
656 pub out_dir: PathBuf,
657 pub forms: Vec<FormArg>,
658 /// ★ whole-branch tax M-2: candidate years this build bundles NO IRS form templates for
659 /// (`btctax_forms::SUPPORTED_YEARS`) — held OUT of `years` (so `apply_export` never attempts, and
660 /// never half-writes, a packet that cannot be filled) and surfaced as an informational note rather
661 /// than a per-year FAILURE. Two distinct populations land here and both are honest, not defects:
662 /// the CURRENT year before its forms are bundled (in 2026 that is EVERY filer, whose `x` otherwise
663 /// read "0 of 1 year(s) written — 2026 failed: unsupported tax year 2026"), and a flagged PRIOR year
664 /// outside the bundle — which the filer must still amend, by hand.
665 pub unsupported_years: BTreeSet<i32>,
666}
667
668/// ★ T3-M2 (Task 10): ONE year's `apply_export` outcome — the planned tax year paired with either its
669/// written `IrsPdfReport` or the `CliError` that failed IT ALONE (per-year isolation: a failure here
670/// never aborts the other years' attempts). A named alias (clippy `type_complexity`) so
671/// `persist_defensive_export`/`render_export_status` (`btctax-tui-edit`) can name the SAME shape without
672/// repeating the nested `(i32, Result<..>)`.
673pub type ExportOutcome = (i32, Result<IrsPdfReport, CliError>);
674
675/// `apply_export`'s full per-year outcome set — one [`ExportOutcome`] per `plan.years`, in ascending
676/// order, NEVER an all-or-nothing abort on the first failing year.
677pub type ExportOutcomes = Vec<ExportOutcome>;
678
679/// Plan a multi-year IRS-PDF export (DFW-D11) — gates over already-projected `state` ONLY; NO
680/// consent/acknowledgment (export mutates no events; arch-m-new-3: no `Session` here either — the caller
681/// supplies its own already-loaded `events`/`state`/`prices`/`tables`/`cfg`, mirroring `plan_promote`).
682/// Refuses when `state.pseudo_active()`: unlike `export_irs_pdf`'s CLI/`export-snapshot` attest-phrase
683/// escape hatch, this composed export step NEVER prompts for one (the standing DRAFT-gate policy) — a
684/// pseudo-active vault must resolve/turn off pseudo mode (or approve + attest through the existing
685/// single-year CLI path) before this chokepoint will plan anything.
686///
687/// The CANDIDATE set is `{current_year} ∪ flagged_years(events, state, prices, tables, cfg,
688/// current_year)` — STRICTLY a superset of `promoted_filing_years(state)` (SPEC DFW-D11): the fold-diff
689/// set also catches a promote's HIFO reorder of a prior year's donation/gift with NO promoted disposal
690/// leg in that year at all, AND (★ whole-branch tax I-1) the prior year a `$0`-only `DeclareTranche`
691/// re-filed with no promote anywhere.
692///
693/// ★ whole-branch tax M-2: the candidate set is then PARTITIONED against
694/// `btctax_forms::SUPPORTED_YEARS` — a year with no bundled IRS form templates goes to
695/// `unsupported_years` instead of `years`, so `apply_export` never attempts (and
696/// `export_irs_pdf_from_session` never half-writes) a packet that cannot be filled, and the outcome
697/// reads as "no bundled IRS templates for <year> yet" rather than a per-year FAILURE.
698#[allow(clippy::too_many_arguments)]
699pub fn plan_export(
700 events: &[LedgerEvent],
701 state: &LedgerState,
702 prices: &dyn PriceProvider,
703 tables: &dyn TaxTables,
704 cfg: &ProjectionConfig,
705 current_year: i32,
706 out_dir: PathBuf,
707 forms: Vec<FormArg>,
708) -> Result<ExportPlan, Refusal> {
709 if state.pseudo_active() {
710 return Err(Refusal::Coverage(
711 "cannot export: the ledger is pseudo-reconciled (a synthetic, non-persisted default \
712 contributes to the projection) — this composed export step never prompts for an \
713 attestation override. Run `btctax reconcile pseudo off` (or approve + attest the \
714 defaults through the single-year `export-irs-pdf`/`export-snapshot` CLI path) before \
715 exporting."
716 .to_string(),
717 ));
718 }
719
720 let mut candidates =
721 conservative::flagged_years(events, state, prices, tables, cfg, current_year);
722 candidates.insert(current_year);
723
724 let (years, unsupported_years): (BTreeSet<i32>, BTreeSet<i32>) = candidates
725 .into_iter()
726 .partition(|y| btctax_forms::SUPPORTED_YEARS.contains(y));
727
728 Ok(ExportPlan {
729 years,
730 out_dir,
731 forms,
732 unsupported_years,
733 })
734}
735
736/// Apply a planned export: write ONE IRS-PDF packet per `plan.years`, each into its own `out_dir/<year>`
737/// subdirectory, via the ALREADY-SHIPPED `export_irs_pdf_from_session` (★ arch-C-1, `cmd::admin`) — the
738/// SAME `return_inputs::exists` full-vs-slice dispatch the CLI's `export_irs_pdf` runs, exercised ONCE
739/// per year, INSIDE that fn (never duplicated here — ★ m-new-1). Reloads `events`/`state` fresh from
740/// `session` (arch-m-new-3 pattern) — a single synchronous CLI/TUI invocation cannot append anything
741/// between `plan_export` and `apply_export`, so this is behavior-preserving. `attest: None` throughout:
742/// `plan_export` already refused a pseudo-active state, so the watermark/attestation branch inside
743/// `_from_session` is unreachable here. NO `Session::open` anywhere — `session` is already open and held
744/// by the caller (the CLI's thin driver, or a future TUI); a second open under a held `VaultLock`
745/// deadlocks (`session.rs:662`).
746///
747/// ★ T3-M2 (Task 10) — PER-YEAR ISOLATION: a failure writing ONE year's packet (e.g. a year whose Form
748/// 8275 Part I overflows this revision's 6 rows, or an I/O fault under `out_dir`) does NOT
749/// abort the batch. Every year in `plan.years` is attempted, in ascending order (`plan.years` is a
750/// `BTreeSet`), and its outcome is reported INDIVIDUALLY as `(year, Result<IrsPdfReport, CliError>)`.
751/// Years already written to disk before a LATER year's failure stay correct — nothing already written is
752/// rolled back (each year's packet is an independent, self-contained write; export performs no in-memory
753/// mutation to revert). No unattested/pseudo packet can ever escape a per-year failure: `plan_export`
754/// already refused a pseudo-active ledger up front, and `attest: None` is passed on every call regardless
755/// of outcome. The outer `Result` covers only the ONE failure mode common to every year — re-loading
756/// `events`/`state` from `session` — which, if it fails, means NOTHING could even be attempted.
757///
758/// `plan.unsupported_years` is NOT attempted here (★ whole-branch tax M-2 — `plan_export` already held
759/// those out): they carry no bundled IRS form templates, so an attempt could only ever fail, and the
760/// caller renders them as a "no bundled templates yet" note beside these outcomes.
761pub fn apply_export(session: &Session, plan: ExportPlan) -> Result<ExportOutcomes, CliError> {
762 let (events, state, _cfg) = session.load_events_and_project()?;
763 let mut reports = Vec::with_capacity(plan.years.len());
764 for year in &plan.years {
765 let year_dir = plan.out_dir.join(year.to_string());
766 let outcome = crate::cmd::admin::export_irs_pdf_from_session(
767 session,
768 &state,
769 &events,
770 &year_dir,
771 *year,
772 &plan.forms,
773 None,
774 );
775 reports.push((*year, outcome));
776 }
777 Ok(reports)
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783 use btctax_core::state::{Disposal, DisposalLeg, Term};
784 use btctax_core::{BasisSource, DisposeKind, LotId};
785 use rust_decimal_macros::dec;
786 use time::macros::date;
787
788 fn wallet() -> WalletId {
789 WalletId::SelfCustody {
790 label: "pfy".into(),
791 }
792 }
793
794 fn leg(origin: EventId) -> DisposalLeg {
795 DisposalLeg {
796 lot_id: LotId {
797 origin_event_id: origin,
798 split_sequence: 0,
799 },
800 sat: 1_000_000,
801 proceeds: dec!(100),
802 basis: dec!(0),
803 gain: dec!(100),
804 term: Term::LongTerm,
805 basis_source: BasisSource::EstimatedConservative,
806 gift_zone: None,
807 acquired_at: date!(2016 - 03 - 31),
808 wallet: wallet(),
809 pseudo: false,
810 }
811 }
812
813 fn disposal(year: i32, origin: EventId) -> Disposal {
814 Disposal {
815 event: EventId::decision(90 + year as u64 % 10),
816 kind: DisposeKind::Sell,
817 disposed_at: time::Date::from_calendar_date(year, time::Month::June, 1).unwrap(),
818 legs: vec![leg(origin)],
819 fee_mini_disposition: false,
820 }
821 }
822
823 /// ★ whole-branch arch M-2 (replaces the cross-crate assertion that forced `promoted_filing_years`
824 /// onto btctax-cli's PUBLIC API): the BG-D8 8275-completeness enumeration is DISPOSAL-LEG-scoped and
825 /// PROMOTE-scoped — a year files here iff a disposal leg in it draws a lot whose origin is in
826 /// `state.promoted_origins`. A year whose only change is a REMOVAL (donation/gift) reorder, or whose
827 /// disposal legs draw an UNPROMOTED origin, is correctly absent — that is precisely the gap
828 /// `conservative::flagged_years` (the DFW-D11 export set) exists to cover, and why the gate's set is
829 /// explicitly NOT the export set.
830 ///
831 /// Mutation: drop the `promoted_origins` membership test (enumerate every disposal year) → 2024
832 /// appears → reds.
833 #[test]
834 fn promoted_filing_years_enumerates_promoted_disposal_legs_only() {
835 let promoted = EventId::decision(1);
836 let unpromoted = EventId::decision(2);
837
838 let state = LedgerState {
839 disposals: vec![
840 disposal(2024, unpromoted.clone()),
841 disposal(2025, promoted.clone()),
842 ],
843 promoted_origins: [promoted].into_iter().collect(),
844 ..Default::default()
845 };
846
847 let years = promoted_filing_years(&state);
848 assert_eq!(
849 years.iter().copied().collect::<Vec<_>>(),
850 vec![2025],
851 "only the year holding a PROMOTED disposal leg is enumerated: {years:?}"
852 );
853 assert!(
854 !years.contains(&2024),
855 "an unpromoted disposal leg's year must NOT enter the 8275-completeness set: {years:?}"
856 );
857 }
858}