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::cmd::promote::{
58 render_consent as render_consent_terms, ProvenanceKind, PROMOTE_ACK_PHRASE, PROVENANCE_TEXT,
59 PROVENANCE_VERSION,
60};
61use crate::{CliError, Session};
62use btctax_core::conservative::{self, Direction};
63use btctax_core::conservative_promote::{self, PromoteRefusal};
64use btctax_core::conventions::tax_date;
65use btctax_core::event::{
66 Acknowledgment, ConsentTerm, DeclareTranche, EventPayload, FloorMethod, PromoteTranche,
67};
68use btctax_core::persistence::{append_decision, load_all};
69use btctax_core::price::PriceProvider;
70use btctax_core::project::ProjectionConfig;
71use btctax_core::state::BlockerKind;
72use btctax_core::{
73 project, EventId, LedgerEvent, LedgerState, RemovalKind, Sat, TaxDate, Usd, WalletId,
74};
75use std::collections::BTreeSet;
76use time::{OffsetDateTime, UtcOffset};
77
78/// Everything computed BEFORE the filer types the acknowledgment phrase (the `PromoteTranche` decision
79/// id, `target`, is not yet known — it is assigned at `apply_promote`'s `append_decision`). ★ I-1: the
80/// three ordered fields (`advisory_lines`, `gift_only_years`, `post_consent_note`) let `render_consent`
81/// reproduce the shipped verb's `advisory → consent → note` byte order — do NOT collapse them into one
82/// `Vec` or pre-render `gift_only_years` into a string.
83#[derive(Debug, Clone)]
84pub struct PromotePlan {
85 /// The `DeclareTranche` decision this promotes (BG-D1) — the `PromoteTranche.target` field.
86 pub target: EventId,
87 /// BG-D6 `consent_terms` output — ALSO snapshotted verbatim onto `payload`'s
88 /// `Acknowledgment.shown_terms` (the §6664(c) good-faith artifact).
89 pub terms: Vec<ConsentTerm>,
90 /// The PRE-consent synthetic-promote advisory lines (`promote.rs:443`, `for line in &advisory`).
91 pub advisory_lines: Vec<String>,
92 /// T9 handoff: an INPUT to the shipped `render_consent(terms, gift_only_years)`
93 /// (`promote.rs:333`/`:453`) — NOT a pre-rendered string.
94 pub gift_only_years: BTreeSet<i32>,
95 /// `wide_window_note`, printed AFTER the consent screen (`promote.rs:454`).
96 pub post_consent_note: Option<String>,
97 /// The `PromoteTranche` payload `apply_promote` appends on a successful acknowledgment.
98 pub payload: EventPayload,
99}
100
101/// A `plan_promote` refusal — fail-closed, BEFORE any computation past the failing gate. Each variant
102/// carries the exact filer-facing message (byte-identical to the shipped verb's `CliError::Usage` text),
103/// so mapping to `CliError` (the thin CLI driver) or a distinct TUI error surface is trivial either way.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Refusal {
106 /// The resolve-live gate (`resolve_live_tranche`): `target` is absent, wrong-type, or voided.
107 Target(String),
108 /// BG-D5: a non-`Purchase` provenance.
109 Provenance(String),
110 /// BG-D3 (promote): `filed_basis_for` could not produce a trustworthy floor
111 /// (`NoCoverage`/`PartialCoverage`). ALSO (Task 2, declare): the shipped-set gates (`sat>0`, `ws<=we`,
112 /// `guard_tranche_vs_allocation`) AND the DFW-D5.2 target-scoped clearance shadow (a candidate tranche
113 /// that does not clear the named shortfall) — see the module doc's "Refusal-variant note".
114 Coverage(String),
115 /// BG-D7: an empty/whitespace Form 8275 Part II narrative.
116 PartII(String),
117}
118
119impl From<Refusal> for CliError {
120 fn from(r: Refusal) -> CliError {
121 let msg = match r {
122 Refusal::Target(m) => m,
123 Refusal::Provenance(m) => m,
124 Refusal::Coverage(m) => m,
125 Refusal::PartII(m) => m,
126 };
127 CliError::Usage(msg)
128 }
129}
130
131/// True iff a live (non-voided) `VoidDecisionEvent` names `id`. Moved verbatim from `cmd/promote.rs`.
132fn is_voided(events: &[LedgerEvent], id: &EventId) -> bool {
133 events.iter().any(
134 |e| matches!(&e.payload, EventPayload::VoidDecisionEvent(v) if v.target_event_id == *id),
135 )
136}
137
138/// Resolve `target_event_id` to a LIVE (present, non-voided) `DeclareTranche`, or `Refusal::Target`. A
139/// record-time convenience guard — the engine's own `DecisionConflict` adjudication is the backstop for
140/// any target this misses (moved verbatim from `cmd/promote.rs::resolve_live_tranche`, DFW-D2 gate 1).
141fn resolve_live_tranche(
142 events: &[LedgerEvent],
143 target_event_id: &EventId,
144) -> Result<DeclareTranche, Refusal> {
145 let not_live = || {
146 Refusal::Target(format!(
147 "{} is not a live DeclareTranche (absent, wrong type, or voided) — see `btctax events list` \
148 for event refs + decision status",
149 target_event_id.canonical()
150 ))
151 };
152 if is_voided(events, target_event_id) {
153 return Err(not_live());
154 }
155 events
156 .iter()
157 .find(|e| e.id == *target_event_id)
158 .and_then(|e| match &e.payload {
159 EventPayload::DeclareTranche(t) => Some(t.clone()),
160 _ => None,
161 })
162 .ok_or_else(not_live)
163}
164
165/// BG-D5: refuse a non-`Purchase` provenance — the closed enumeration, fail-closed, before any
166/// computation. Moved verbatim from `cmd/promote.rs::refuse_non_purchase`.
167fn refuse_non_purchase(provenance: ProvenanceKind) -> Refusal {
168 Refusal::Provenance(format!(
169 "promote-tranche requires purchase provenance: {PROVENANCE_TEXT}. This tranche was declared as \
170 acquired by {label} — a {label} recipient already has a documented, real basis (income \
171 FMV-at-receipt; a §1015 donor carryover for a gift; a §1014 date-of-death basis for an \
172 inheritance) — model the real acquisition instead (a documented \
173 Acquire/Income/gift-received event), not a conservative-filing tranche promote.",
174 label = provenance.label(),
175 ))
176}
177
178/// BG-D3: translate a `filed_basis_for` refusal into a record-time message. Moved verbatim from
179/// `cmd/promote.rs::refuse_no_floor`.
180fn refuse_no_floor(e: PromoteRefusal, window_start: TaxDate, window_end: TaxDate) -> Refusal {
181 let detail = match e {
182 PromoteRefusal::NoCoverage => {
183 "no bundled daily-close price exists anywhere in the window — never fabricate a floor over a \
184 total data gap"
185 }
186 PromoteRefusal::PartialCoverage => {
187 "the window has a gap in bundled daily-close data — the covered-part minimum is not provably \
188 the window's true minimum, so it cannot be filed as a trustworthy floor"
189 }
190 };
191 Refusal::Coverage(format!(
192 "cannot compute a promotion floor for the window [{window_start}, {window_end}]: {detail}. \
193 Narrow the window to a fully-covered range, or leave this tranche at its filed $0 basis."
194 ))
195}
196
197/// A filer-facing caution (SPEC §1, "two honest limits"): a wide acquisition window yields a LOW
198/// ("trivial") floor relative to a tight one. Purely informational, non-gating; conditioned on the
199/// window exceeding one year. Moved verbatim from `cmd/promote.rs::wide_window_note`.
200fn wide_window_note(window_start: TaxDate, window_end: TaxDate) -> Option<String> {
201 let days = (window_end - window_start).whole_days();
202 if days > 365 {
203 Some(format!(
204 "note: this tranche's declared window spans {days} days (over a year). A WIDE window tends \
205 to produce a LOW (\"trivial\") floor relative to a tight one — for some filers it may be \
206 simpler, and just as conservative, to leave this tranche at its filed $0 basis and skip the \
207 Form 8275 disclosure surface entirely."
208 ))
209 } else {
210 None
211 }
212}
213
214/// Thread ONE synthetic `PromoteTranche(tranche_id, filed_basis)` onto `events` (mirrors
215/// `conservative_promote::with_synthetic_promote`, private there). Moved verbatim from
216/// `cmd/promote.rs::with_synthetic_promote`.
217fn with_synthetic_promote(
218 events: &[LedgerEvent],
219 tranche_id: &EventId,
220 filed_basis: Usd,
221 now: OffsetDateTime,
222) -> Vec<LedgerEvent> {
223 let seq = events
224 .iter()
225 .filter_map(|e| match e.id {
226 EventId::Decision { seq } => Some(seq),
227 _ => None,
228 })
229 .max()
230 .map_or(1, |m| m + 1);
231 let mut out = events.to_vec();
232 out.push(LedgerEvent {
233 id: EventId::decision(seq),
234 utc_timestamp: now,
235 original_tz: UtcOffset::UTC,
236 wallet: None,
237 payload: EventPayload::PromoteTranche(PromoteTranche {
238 target: tranche_id.clone(),
239 method: FloorMethod::WindowLowClose,
240 filed_basis,
241 coverage: conservative::Coverage::Full,
242 provenance_attested: true,
243 acknowledgment: Acknowledgment {
244 phrase: String::new(),
245 shown_terms: Vec::new(),
246 provenance_text: String::new(),
247 provenance_version: String::new(),
248 },
249 part_ii_narrative: String::new(),
250 }),
251 });
252 out
253}
254
255/// T9 handoff (progress.md Task 9): `consent_terms`/`Uncomputable` sum the §170(e) charitable-deduction
256/// change and the §1015 gift-basis change into ONE `deduction_delta_usd` figure per year. This re-derives
257/// which flagged years are GIFT-only directly from the SAME with/without fold pair the T8 advisory
258/// already builds. Moved verbatim from `cmd/promote.rs::gift_only_flagged_years`.
259fn gift_only_flagged_years(
260 prices: &dyn PriceProvider,
261 config: &ProjectionConfig,
262 events: &[LedgerEvent],
263 with_events: &[LedgerEvent],
264) -> BTreeSet<i32> {
265 let without_state = project(events, prices, config);
266 let with_state = project(with_events, prices, config);
267
268 let mut years: BTreeSet<i32> = BTreeSet::new();
269 for st in [&with_state, &without_state] {
270 for r in &st.removals {
271 years.insert(r.removed_at.year());
272 }
273 }
274
275 let rem =
276 |st: &btctax_core::LedgerState, y: i32, k: RemovalKind| -> Vec<btctax_core::Removal> {
277 st.removals
278 .iter()
279 .filter(|r| r.removed_at.year() == y && r.kind == k)
280 .cloned()
281 .collect()
282 };
283
284 years
285 .into_iter()
286 .filter(|&y| {
287 let gift_changed =
288 rem(&with_state, y, RemovalKind::Gift) != rem(&without_state, y, RemovalKind::Gift);
289 let don_changed = rem(&with_state, y, RemovalKind::Donation)
290 != rem(&without_state, y, RemovalKind::Donation);
291 gift_changed && !don_changed
292 })
293 .collect()
294}
295
296/// The acknowledgment gate (BG-D6) — a PURE exact-compare, no I/O (mirrors `require_attestation`,
297/// `lib.rs:208`). Moved verbatim from `cmd/promote.rs::require_promote_ack`; now called from
298/// `apply_promote`, fail-closed, BEFORE `would_conflict`/append.
299fn require_promote_ack(acknowledge: Option<&str>) -> Result<(), CliError> {
300 match acknowledge.map(str::trim) {
301 Some(p) if p == PROMOTE_ACK_PHRASE => Ok(()),
302 Some(_) => Err(CliError::Usage(format!(
303 "the acknowledgment phrase did not match. Type it EXACTLY (trimmed, case-sensitive): {PROMOTE_ACK_PHRASE:?}."
304 ))),
305 None => Err(CliError::Usage(format!(
306 "promote-tranche requires acknowledging the estimated-basis risk shown above — pass \
307 --i-acknowledge {PROMOTE_ACK_PHRASE:?} (or type it at the interactive prompt)."
308 ))),
309 }
310}
311
312/// Plan a `PromoteTranche` decision — the DFW-D2 gate order, everything computable BEFORE the filer types
313/// the acknowledgment phrase: resolve-live → BG-D5 provenance → BG-D7 Part II → BG-D3 floor/coverage →
314/// BG-D6 `consent_terms` → synthetic-promote advisory → gift-only relabel. `events`/`prices`/`cfg` are the
315/// caller's own already-loaded state (arch-m-new-3: no `Session` here — the CLI's thin driver and a
316/// future TUI each supply their own).
317pub fn plan_promote(
318 events: &[LedgerEvent],
319 prices: &dyn PriceProvider,
320 cfg: &ProjectionConfig,
321 target: &EventId,
322 provenance: ProvenanceKind,
323 part_ii: &str,
324 now: OffsetDateTime,
325) -> Result<PromotePlan, Refusal> {
326 // Resolve + assert live (BG-D1).
327 let tranche = resolve_live_tranche(events, target)?;
328
329 // BG-D5: purchase provenance only — fail-closed, before any computation.
330 if provenance != ProvenanceKind::Purchase {
331 return Err(refuse_non_purchase(provenance));
332 }
333
334 // BG-D7: an empty/whitespace Part II narrative is refused at record time (present-by-construction).
335 if part_ii.trim().is_empty() {
336 return Err(Refusal::PartII(
337 "promote-tranche requires a non-empty Form 8275 Part II narrative (filer facts, Reg. \
338 §1.6662-4(f) — 'in sufficient detail') — pass --part-ii-file pointing at a file with real \
339 acquisition/window facts, not an empty or blank file"
340 .into(),
341 ));
342 }
343
344 // BG-D3: the computed whole-tranche filed_basis floor — hard-refuse on Partial/No coverage.
345 let floor = conservative_promote::filed_basis_for(
346 prices,
347 tranche.sat,
348 tranche.window_start,
349 tranche.window_end,
350 )
351 .map_err(|e| refuse_no_floor(e, tranche.window_start, tranche.window_end))?;
352
353 // ★ DFW-D6 (the ONE intended behavior change): force pseudo OFF on an own COPY (ProjectionConfig is
354 // Copy) before consent_terms / promote_prior_year_advisory / gift_only_flagged_years — mirrors
355 // `would_conflict` (`project/mod.rs:118`). The recorded Acknowledgment.shown_terms must always
356 // reflect the HONEST (non-synthetic) figures, never a pseudo-active default folded in.
357 let mut honest_cfg = *cfg;
358 honest_cfg.pseudo_reconcile = false;
359
360 let tables = btctax_adapters::BundledTaxTables::load();
361 // A single stored TaxProfile cannot fit the multi-year span this consent/advisory ranges over, so
362 // `None` is passed throughout — mirrors the void-direction path (`cmd/reconcile.rs`
363 // `promote_void_advisory_lines`): the tax-Δ arm falls back to the gain/deduction-Δ sign, and the
364 // amend direction is still correct.
365 let terms = conservative_promote::consent_terms(
366 events,
367 prices,
368 &honest_cfg,
369 target,
370 floor.filed_basis,
371 None,
372 &tables,
373 );
374
375 // Thread ONE synthetic promote so the Direction::Promote advisory AND this layer's own
376 // gift-vs-donation year classification (T9 handoff) see the SAME post-promote fold.
377 let with_events = with_synthetic_promote(events, target, floor.filed_basis, now);
378 let synthetic_id = with_events
379 .last()
380 .expect("with_synthetic_promote always pushes exactly one event")
381 .id
382 .clone();
383
384 // T8 handoff (progress.md): `current` is the injected `now`'s tax year (the BTCTAX_NOW seam) — NEVER
385 // a wall clock. Years `< current` are presumed already filed; the year still being authored
386 // (>= current) is excluded, so it is never told it needs an amended return.
387 let current = tax_date(now, UtcOffset::UTC).year();
388 let advisory_lines = conservative::promote_prior_year_advisory(
389 &with_events,
390 prices,
391 &honest_cfg,
392 &synthetic_id,
393 Direction::Promote,
394 None,
395 &tables,
396 current,
397 );
398
399 // T9 handoff: which flagged years are GIFT-ONLY (no donation) — relabels that year's deduction/basis-Δ
400 // as a §1015 donee-basis change, never Schedule-A, in the consent screen below.
401 let gift_only_years = gift_only_flagged_years(prices, &honest_cfg, events, &with_events);
402
403 let payload = EventPayload::PromoteTranche(PromoteTranche {
404 target: target.clone(),
405 method: FloorMethod::WindowLowClose,
406 filed_basis: floor.filed_basis,
407 coverage: floor.coverage,
408 provenance_attested: true,
409 acknowledgment: Acknowledgment {
410 phrase: PROMOTE_ACK_PHRASE.to_string(),
411 shown_terms: terms.clone(),
412 provenance_text: PROVENANCE_TEXT.to_string(),
413 provenance_version: PROVENANCE_VERSION.to_string(),
414 },
415 part_ii_narrative: part_ii.to_string(),
416 });
417
418 Ok(PromotePlan {
419 target: target.clone(),
420 terms,
421 advisory_lines,
422 gift_only_years,
423 post_consent_note: wide_window_note(tranche.window_start, tranche.window_end),
424 payload,
425 })
426}
427
428/// Re-emit the shipped verb's ordered filer-visible text: `advisory_lines` → the shipped
429/// `render_consent(&plan.terms, &plan.gift_only_years)` (`cmd::promote`) → `post_consent_note` — ★ I-1:
430/// byte-identical to `promote.rs:443-455` when the RESULT is printed via a single
431/// `println!("{}", render_consent(&plan))` (the shipped verb instead used three separate `println!`
432/// calls; a single combined string reproduces the exact same stdout bytes because `println!` always adds
433/// exactly one trailing `\n`). Do NOT collapse this into `plan.terms`/`plan.gift_only_years` alone — the
434/// pre-advisory must land BEFORE the consent screen and the note AFTER it.
435pub fn render_consent(plan: &PromotePlan) -> String {
436 let mut out = String::new();
437 for line in &plan.advisory_lines {
438 out.push_str(line);
439 out.push('\n');
440 }
441 out.push_str(&render_consent_terms(&plan.terms, &plan.gift_only_years));
442 if let Some(note) = &plan.post_consent_note {
443 out.push('\n');
444 out.push_str(note);
445 }
446 out
447}
448
449/// Apply a planned promote: the acknowledgment gate (BG-D6, fail-closed, INSIDE apply) → `would_conflict`
450/// pre-check (BG-D9 — a second live promote on this target, or any other resolver-level conflict; refuses
451/// BEFORE appending, NOT last-wins) → append + save. Reloads `events`/`cfg` fresh from `session`
452/// (arch-m-new-3: `plan_promote` took no `Session`) — a single synchronous CLI/TUI invocation cannot
453/// append anything between `plan_promote` and `apply_promote`, so this is behavior-preserving.
454pub fn apply_promote(
455 session: &mut Session,
456 plan: PromotePlan,
457 acknowledge: Option<&str>,
458 now: OffsetDateTime,
459) -> Result<EventId, CliError> {
460 require_promote_ack(acknowledge)?;
461
462 let events = load_all(session.conn())?;
463 let cfg = session.config()?.to_projection();
464
465 // BG-D9: pre-check `would_conflict` (a second live promote on this target, or any other resolver-level
466 // conflict, e.g. UX-P4-3) — refuse BEFORE appending (fail-closed). NOT last-wins.
467 if let Some(detail) =
468 btctax_core::would_conflict(&events, session.prices(), &cfg, &plan.payload, now)
469 {
470 return Err(CliError::Usage(format!(
471 "cannot record this promote — a decision conflict: {detail}"
472 )));
473 }
474
475 let id = append_decision(session.conn(), plan.payload, now, UtcOffset::UTC, None)?;
476 session.save()?;
477 Ok(id)
478}
479
480// ════════════════════════════════════════════════════════════════════════════════════════════════
481// Task 2 — the DECLARE chokepoint: `plan_declare`/`apply_declare` (module doc has the full contract).
482// ════════════════════════════════════════════════════════════════════════════════════════════════
483
484/// Everything needed to append a `DeclareTranche` decision. Unlike `PromotePlan`, declaring has no
485/// acknowledgment gate or consent screen (DFW-D8 — a plain `$0`, revocable confirmation) — so there is
486/// nothing else to carry beside the payload itself.
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub struct DeclarePlan {
489 /// The `EventPayload::DeclareTranche` `apply_declare` appends verbatim.
490 pub payload: EventPayload,
491}
492
493/// Plan a `DeclareTranche` decision. Gates on the shipped set ALWAYS — `sat>0`, `ws<=we`,
494/// `guard_tranche_vs_allocation` (`cmd::tranche`, the single source of that guard for all four allocation
495/// append sites — NOT duplicated here) — replicating `cmd/tranche.rs:134-154` exactly (arch-m-new-3: no
496/// `Session` — the caller supplies its own already-loaded `events`/`prices`/`cfg`, mirroring
497/// `plan_promote`).
498///
499/// **Iff `target_shortfall = Some(id)`**, ALSO runs the DFW-D5.2 target-scoped clearance shadow: append
500/// the candidate `DeclareTranche` → re-project (pseudo FORCED off on a config COPY, mirroring
501/// `would_conflict`, `project/mod.rs:118`) → assert no `BlockerKind::UncoveredDisposal` remains on `id`;
502/// else `Refusal::Coverage`. **Forcing pseudo off here is load-bearing (arch-I-5):** a synthetic
503/// `SelfTransferMine{$0}` pseudo default must never stand in for a real, documented cover — else a
504/// dashboard candidate could be reported as "clears the shortfall" when only a fictional, non-persisted
505/// default actually covered it.
506///
507/// `target_shortfall = None` (the CLI free-form `declare-tranche` path) never runs the clearance shadow —
508/// the shipped verb's gate set, byte-for-byte (DFW-D8/SPEC §5).
509#[allow(clippy::too_many_arguments)]
510pub fn plan_declare(
511 events: &[LedgerEvent],
512 prices: &dyn PriceProvider,
513 cfg: &ProjectionConfig,
514 sat: Sat,
515 wallet: WalletId,
516 window_start: TaxDate,
517 window_end: TaxDate,
518 target_shortfall: Option<EventId>,
519 now: OffsetDateTime,
520) -> Result<DeclarePlan, Refusal> {
521 // The shipped gate set (cmd/tranche.rs:134-154), byte-for-byte.
522 if sat <= 0 {
523 // A `sat <= 0` tranche would bump `stats.sigma_in` by a non-positive amount (fold.rs),
524 // corrupting Σ-conservation; there is no such thing as declaring zero/negative undocumented BTC.
525 return Err(Refusal::Coverage(format!(
526 "tranche amount must be > 0 sat (got {sat})"
527 )));
528 }
529 if window_start > window_end {
530 return Err(Refusal::Coverage(format!(
531 "tranche window_start ({window_start}) must be <= window_end ({window_end})"
532 )));
533 }
534 crate::cmd::tranche::guard_tranche_vs_allocation(events, window_end).map_err(|e| match e {
535 CliError::Usage(m) => Refusal::Coverage(m),
536 other => Refusal::Coverage(other.to_string()), // unreachable today (the guard only ever returns Usage)
537 })?;
538
539 let payload = EventPayload::DeclareTranche(DeclareTranche {
540 sat,
541 wallet,
542 window_start,
543 window_end,
544 });
545
546 // DFW-D5.2: the target-scoped clearance shadow — ONLY when the caller names a shortfall to cover.
547 if let Some(id) = target_shortfall {
548 // ★ arch-I-5: pseudo FORCED off on a COPY (ProjectionConfig is Copy) — mirrors `would_conflict`
549 // (project/mod.rs:118). Without this, a pseudo-active vault could report a candidate as clearing
550 // a shortfall that only a synthetic, non-persisted SelfTransferMine{$0} default actually covered.
551 let mut honest_cfg = *cfg;
552 honest_cfg.pseudo_reconcile = false;
553
554 // Append the candidate as the resolver would (the next decision seq — mirrors `would_conflict`
555 // and `with_synthetic_promote` above).
556 let next_seq = events
557 .iter()
558 .filter_map(|e| match e.id {
559 EventId::Decision { seq } => Some(seq),
560 _ => None,
561 })
562 .max()
563 .map_or(1, |m| m + 1);
564 let candidate = LedgerEvent {
565 id: EventId::decision(next_seq),
566 utc_timestamp: now,
567 original_tz: UtcOffset::UTC,
568 wallet: None,
569 payload: payload.clone(),
570 };
571 let mut with_candidate = events.to_vec();
572 with_candidate.push(candidate);
573
574 let state = project(&with_candidate, prices, &honest_cfg);
575 let still_uncovered = state
576 .blockers
577 .iter()
578 .any(|b| b.kind == BlockerKind::UncoveredDisposal && b.event.as_ref() == Some(&id));
579 if still_uncovered {
580 return Err(Refusal::Coverage(format!(
581 "this candidate tranche does not clear the shortfall on {} — after adding it, an \
582 UncoveredDisposal blocker still remains on that event. A tranche's synthetic acquisition \
583 lands at window_end and sorts AFTER a same-instant import, so window_end must be \
584 STRICTLY BEFORE the short event's date (and the wallet/sat must actually cover it) to \
585 clear it.",
586 id.canonical()
587 )));
588 }
589 }
590
591 Ok(DeclarePlan { payload })
592}
593
594/// Apply a planned declare: append + save. No acknowledgment gate and no `would_conflict` pre-check
595/// (DFW-D8 — declaring is a plain `$0`, revocable confirmation, unlike promote's typed-phrase tier; the
596/// shipped verb never ran a `would_conflict` check either — `cmd/tranche.rs:166-174` appends immediately
597/// once `guard_tranche_vs_allocation` passes).
598pub fn apply_declare(
599 session: &mut Session,
600 plan: DeclarePlan,
601 now: OffsetDateTime,
602) -> Result<EventId, CliError> {
603 let id = append_decision(session.conn(), plan.payload, now, UtcOffset::UTC, None)?;
604 session.save()?;
605 Ok(id)
606}
607
608// ════════════════════════════════════════════════════════════════════════════════════════════════
609// 8275-completeness year enumeration. (This sat inside the Task-3 EXPORT region until 0.13.0; the
610// composed multi-year export trio around it was deleted, but this helper is a LIVE `pub(crate)`
611// dependency of `cmd/admin.rs`'s `promote_export_gate` and keeps its own unit test below.)
612
613/// BG-D8's 8275-completeness year enumeration ONLY — extracted verbatim from `promote_export_gate`'s
614/// `None` arm (`cmd/admin.rs`) so the gate and any other 8275-completeness caller single-source it (SPEC
615/// DFW-D11: "single-sourced ... but it is NOT the export set"). A year is included iff a PROMOTED
616/// disposal leg (`lot_id.origin_event_id ∈ state.promoted_origins`) files in it — DISPOSAL legs only; a
617/// promote's REMOVAL (donation/gift) reorder is invisible here by design — that is exactly what
618/// `flagged_years` (`btctax_core::conservative`), below, exists to also catch.
619///
620/// ★ whole-branch arch M-2: `pub(crate)`, not `pub`. Its only production caller is in-crate
621/// (`cmd/admin.rs`'s `promote_export_gate`); it was `pub` solely to serve one integration test, which
622/// would have put it on btctax-cli's v0.10.0 PUBLIC API for good. Public API is far cheaper to narrow
623/// before the first release than after. The disposal-legs-only contract is pinned by the in-crate unit
624/// test below instead.
625pub(crate) fn promoted_filing_years(state: &LedgerState) -> BTreeSet<i32> {
626 let mut years = BTreeSet::new();
627 for d in &state.disposals {
628 if d.legs
629 .iter()
630 .any(|l| state.promoted_origins.contains(&l.lot_id.origin_event_id))
631 {
632 years.insert(d.disposed_at.year());
633 }
634 }
635 years
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use btctax_core::state::{Disposal, DisposalLeg, Term};
642 use btctax_core::{BasisSource, DisposeKind, LotId};
643 use rust_decimal_macros::dec;
644 use time::macros::date;
645
646 fn wallet() -> WalletId {
647 WalletId::SelfCustody {
648 label: "pfy".into(),
649 }
650 }
651
652 fn leg(origin: EventId) -> DisposalLeg {
653 DisposalLeg {
654 lot_id: LotId {
655 origin_event_id: origin,
656 split_sequence: 0,
657 },
658 sat: 1_000_000,
659 proceeds: dec!(100),
660 basis: dec!(0),
661 gain: dec!(100),
662 term: Term::LongTerm,
663 basis_source: BasisSource::EstimatedConservative,
664 gift_zone: None,
665 acquired_at: date!(2016 - 03 - 31),
666 wallet: wallet(),
667 pseudo: false,
668 }
669 }
670
671 fn disposal(year: i32, origin: EventId) -> Disposal {
672 Disposal {
673 event: EventId::decision(90 + year as u64 % 10),
674 kind: DisposeKind::Sell,
675 disposed_at: time::Date::from_calendar_date(year, time::Month::June, 1).unwrap(),
676 legs: vec![leg(origin)],
677 fee_mini_disposition: false,
678 }
679 }
680
681 /// ★ whole-branch arch M-2 (replaces the cross-crate assertion that forced `promoted_filing_years`
682 /// onto btctax-cli's PUBLIC API): the BG-D8 8275-completeness enumeration is DISPOSAL-LEG-scoped and
683 /// PROMOTE-scoped — a year files here iff a disposal leg in it draws a lot whose origin is in
684 /// `state.promoted_origins`. A year whose only change is a REMOVAL (donation/gift) reorder, or whose
685 /// disposal legs draw an UNPROMOTED origin, is correctly absent — that is precisely the gap
686 /// `conservative::flagged_years` (the DFW-D11 export set) exists to cover, and why the gate's set is
687 /// explicitly NOT the export set.
688 ///
689 /// Mutation: drop the `promoted_origins` membership test (enumerate every disposal year) → 2024
690 /// appears → reds.
691 #[test]
692 fn promoted_filing_years_enumerates_promoted_disposal_legs_only() {
693 let promoted = EventId::decision(1);
694 let unpromoted = EventId::decision(2);
695
696 let state = LedgerState {
697 disposals: vec![
698 disposal(2024, unpromoted.clone()),
699 disposal(2025, promoted.clone()),
700 ],
701 promoted_origins: [promoted].into_iter().collect(),
702 ..Default::default()
703 };
704
705 let years = promoted_filing_years(&state);
706 assert_eq!(
707 years.iter().copied().collect::<Vec<_>>(),
708 vec![2025],
709 "only the year holding a PROMOTED disposal leg is enumerated: {years:?}"
710 );
711 assert!(
712 !years.contains(&2024),
713 "an unpromoted disposal leg's year must NOT enter the 8275-completeness set: {years:?}"
714 );
715 }
716}