Skip to main content

btctax_cli/
render.rs

1//! Text rendering of CLI outputs (FR9 verify, FR4 report/show) + FR10 CSV export. Pure string-building
2//! over engine data — the CLI displays; the engine computes (NFR4/NFR5).
3use crate::config::CliConfig;
4use btctax_adapters::FileReport;
5use btctax_core::conventions::{tax_date, Sat, Usd, TRANSITION_DATE};
6use btctax_core::defensive::discovery::{Shortfall, Triage};
7use btctax_core::defensive::{
8    Advisory, DefensiveFilingView, PoolShort, SavingFlavor, TrancheRow, TrancheStatus,
9};
10use btctax_core::persistence::ImportReport;
11use btctax_core::DonationDetails;
12use btctax_core::{
13    conservation_report, disposal_compliance, form_8283, form_8949, schedule_d,
14    year_donation_deduction, BasisSource, Blocker, BlockerKind, ComplianceStatus,
15    ConservationReport, DisposalCompliance, DisposalLeg, DisposeKind, EventId, EventPayload,
16    Form8283HowAcquired, Form8283Section, Form8949Box, Form8949Part, GiftZone, HarvestReport,
17    HarvestStatus, HarvestTarget, InboundClass, IncomeKind, LedgerEvent, LedgerState, LotMethod,
18    LtcgBracket, OutflowClass, RemovalKind, RemovalLeg, ScheduleDTotals, SeTaxResult, SellReport,
19    SellStatus, Severity, TaxDate, Term, WalletId,
20};
21use btctax_store::fsperms;
22use csv::Writer;
23use std::collections::{BTreeMap, BTreeSet};
24use std::fmt::Write as _;
25use std::path::Path;
26
27// ── Money formatting helper ──────────────────────────────────────────────────────────────────────
28
29/// Format any `Decimal` money value as exactly 2 decimal places (e.g. "0.00", "1747.50").
30///
31/// Load-bearing figures (`ltcg_tax`, `niit`, `total_federal_tax_attributable`) are always
32/// `round_cents`-scaled (scale 2) so they already print with cents. Descriptive level fields
33/// (`st_net`, `lt_net`, `carryforward`, `loss_deduction`, etc.) inherit the source `Decimal`
34/// scale and may print as "7000" or "0" without explicit 2dp formatting. This helper ensures
35/// every dollar figure in the tax report renders consistently with 2 decimal places.
36///
37/// **Equality is unaffected** — this is display only. The underlying `Decimal` value is
38/// unchanged; only the `Display` string gains the forced 2dp format.
39fn fmt_money(d: btctax_core::conventions::Usd) -> String {
40    format!("{d:.2}")
41}
42
43// ── Stable CSV/display tags for core enums ──────────────────────────────────────────────────────
44// These are free functions (not inherent methods) because the CLI crate cannot add methods to
45// core types. Values are human-readable and STABLE — changing them breaks the CSV contract.
46
47fn basis_source_tag(bs: BasisSource) -> &'static str {
48    match bs {
49        BasisSource::ExchangeProvided => "exchange",
50        BasisSource::ComputedFromCost => "cost",
51        BasisSource::FmvAtIncome => "income_fmv",
52        BasisSource::CarriedFromTransfer => "transferred",
53        BasisSource::GiftCarryover => "gift_carryover",
54        BasisSource::GiftFmvFallback => "gift_fmv_fallback",
55        BasisSource::SafeHarborAllocated => "safe_harbor",
56        BasisSource::ReconstructedPerWallet => "reconstructed",
57        BasisSource::SelfTransferInbound => "self_transfer_in",
58        BasisSource::EstimatedConservative => "estimated_conservative",
59    }
60}
61
62/// Pseudo-reconcile (sub-project 2, [R0-I4]): the ON-SCREEN-ONLY `[PSEUDO]` marker for a row whose
63/// existence or basis traces to a synthetic (non-persisted) default. Driven by the DEDICATED `pseudo`
64/// bool on `Lot`/`DisposalLeg`/`RemovalLeg` — NEVER a `BasisSource` variant (which the CSV writers emit
65/// via `basis_source_tag`, and would LEAK "PSEUDO" into lots.csv). The CSV/form writers OMIT this marker
66/// entirely (they never call this helper), so it can never reach any export file (the ★ headline guard).
67fn pseudo_tag(pseudo: bool) -> &'static str {
68    if pseudo {
69        " [PSEUDO]"
70    } else {
71        ""
72    }
73}
74
75fn dispose_kind_tag(dk: DisposeKind) -> &'static str {
76    match dk {
77        DisposeKind::Sell => "sell",
78        DisposeKind::Spend => "spend",
79    }
80}
81
82fn income_kind_tag(ik: IncomeKind) -> &'static str {
83    match ik {
84        IncomeKind::Mining => "mining",
85        IncomeKind::Staking => "staking",
86        IncomeKind::Interest => "interest",
87        IncomeKind::Airdrop => "airdrop",
88        IncomeKind::Reward => "reward",
89    }
90}
91
92/// UX-P4-7: SCREEN-ONLY human summary of an `InboundClass` decision payload (income / received gift /
93/// self-transfer). Replaces the raw `{:?}` Debug dump (`SelfTransferMine { basis: Some(19000.00),
94/// acquired_at: Some(2026-01-01) }`) that the CLI bulk-void preview + TUI void list truncated
95/// mid-field. Like `pseudo_tag` [R0-I4], this is for the terminal ONLY — the CSV/form writers MUST
96/// NOT call it (they emit stable machine tags via `*_tag`), so no human phrasing can leak into an
97/// export file.
98pub fn describe_inbound_class(c: &InboundClass) -> String {
99    match c {
100        InboundClass::Income {
101            kind,
102            fmv,
103            business,
104        } => {
105            let mut s = format!("income {}", income_kind_tag(*kind));
106            if let Some(v) = fmv {
107                let _ = write!(s, ", fmv ${}", fmt_money(*v));
108            }
109            if *business {
110                s.push_str(", business");
111            }
112            s
113        }
114        InboundClass::GiftReceived {
115            donor_basis,
116            donor_acquired_at,
117            fmv_at_gift,
118        } => {
119            let mut s = format!("gift received, fmv ${}", fmt_money(*fmv_at_gift));
120            if let Some(b) = donor_basis {
121                let _ = write!(s, ", donor-basis ${}", fmt_money(*b));
122            }
123            if let Some(d) = donor_acquired_at {
124                let _ = write!(s, ", donor-acquired {d}");
125            }
126            s
127        }
128        InboundClass::SelfTransferMine { basis, acquired_at } => {
129            // `None` is a defaulted field (zero basis / 1yr+1day-before-receipt date), NOT the Debug
130            // `None` — name it "default" so the void preview is legible.
131            let basis_s = match basis {
132                Some(v) => format!("${}", fmt_money(*v)),
133                None => "default".to_string(),
134            };
135            let acq_s = match acquired_at {
136                Some(d) => d.to_string(),
137                None => "default".to_string(),
138            };
139            format!("self-transfer (mine), basis {basis_s}, acquired {acq_s}")
140        }
141    }
142}
143
144/// UX-P4-7: SCREEN-ONLY human summary of an `OutflowClass` decision payload. Same screen-only rule as
145/// [`describe_inbound_class`] — never called by a CSV/form writer.
146pub fn describe_outflow_class(c: &OutflowClass) -> String {
147    match c {
148        OutflowClass::Dispose { kind } => dispose_kind_tag(*kind).to_string(),
149        OutflowClass::GiftOut => "gift".to_string(),
150        OutflowClass::Donate { appraisal_required } => {
151            if *appraisal_required {
152                "donate (appraisal required)".to_string()
153            } else {
154                "donate".to_string()
155            }
156        }
157    }
158}
159
160fn gift_zone_tag(gz: GiftZone) -> &'static str {
161    match gz {
162        GiftZone::Gain => "gain",
163        GiftZone::Loss => "loss",
164        GiftZone::NoGainNoLoss => "no_gain_no_loss",
165    }
166}
167
168fn removal_kind_tag(rk: RemovalKind) -> &'static str {
169    match rk {
170        RemovalKind::Gift => "gift",
171        RemovalKind::Donation => "donation",
172    }
173}
174
175/// Stable term tag: "long" or "short" (not the Debug "LongTerm"/"ShortTerm").
176fn term_tag(t: Term) -> &'static str {
177    match t {
178        Term::ShortTerm => "short",
179        Term::LongTerm => "long",
180    }
181}
182
183/// Stable Form 8949 part tag: "ST" (Part I) / "LT" (Part II). STABLE — part of the form8949.csv contract.
184fn form8949_part_tag(p: Form8949Part) -> &'static str {
185    match p {
186        Form8949Part::ShortTerm => "ST",
187        Form8949Part::LongTerm => "LT",
188    }
189}
190
191/// Stable Form 8949 box tag: pre-TY2025 securities boxes "C" (ST) / "F" (LT), and from TY2025 the
192/// digital-asset boxes "I" (ST) / "L" (LT) — the conservative "not reported on a 1099-B / 1099-DA"
193/// default (D4). We never emit the 1099-reported boxes (A/B/D/E; G/H/J/K): the model carries no
194/// 1099 basis-reported signal.
195fn form8949_box_tag(b: Form8949Box) -> &'static str {
196    match b {
197        Form8949Box::C => "C",
198        Form8949Box::F => "F",
199        Form8949Box::I => "I",
200        Form8949Box::L => "L",
201    }
202}
203
204/// Stable Form 8283 section tag: "A" (deduction ≤ $5,000) / "B" (> $5,000). STABLE — part of the
205/// form8283.csv contract.
206fn form8283_section_tag(s: Form8283Section) -> &'static str {
207    match s {
208        Form8283Section::A => "A",
209        Form8283Section::B => "B",
210    }
211}
212
213/// Stable Form 8283 "how acquired" tag: literal Form 8283 categories (NOT the internal BasisSource
214/// tags). STABLE — part of the form8283.csv contract.
215fn form8283_how_acquired_tag(h: Form8283HowAcquired) -> &'static str {
216    match h {
217        Form8283HowAcquired::Purchased => "Purchased",
218        Form8283HowAcquired::Gift => "Gift",
219        Form8283HowAcquired::Other => "Other",
220        Form8283HowAcquired::Review => "Review",
221    }
222}
223
224/// Stable compliance-status display string, used in `render_verify` and optimizer output
225/// in place of `{:?}` (which would expose unstable Rust Debug formatting).
226///
227/// Values:
228/// - `standing_order:<date>` — in-force standing order effective from `<date>` (YYYY-MM-DD).
229/// - `contemporaneous`       — `LotSelection` recorded on or before the day of sale.
230/// - `attested_recording`    — Mode-1-persisted selection backed by contemporaneous-ID attestation (§C.2).
231/// - `non_compliant`         — no adequate identification; FIFO is the defensible filing position.
232fn compliance_status_tag(cs: &ComplianceStatus) -> String {
233    match cs {
234        ComplianceStatus::StandingOrder { effective_from } => {
235            format!("standing_order:{effective_from}")
236        }
237        ComplianceStatus::Contemporaneous => "contemporaneous".into(),
238        ComplianceStatus::AttestedRecording => "attested_recording".into(),
239        ComplianceStatus::NonCompliant => "non_compliant".into(),
240    }
241}
242
243/// FR1/FR2: per-source drop/unclassified counts + the append/duplicate/conflict tally.
244pub fn render_file_reports(reports: &[FileReport], import: &ImportReport) -> String {
245    let mut out = String::new();
246    let _ = writeln!(out, "Import:");
247    for r in reports {
248        let _ = writeln!(
249            out,
250            "  {} [{}]: parsed {} rows -> {} BTC events ({} dropped no-BTC, {} unclassified)",
251            r.source.tag(),
252            r.label,
253            r.parsed_rows,
254            r.btc_events,
255            r.dropped_no_btc,
256            r.unclassified
257        );
258    }
259    let _ = writeln!(
260        out,
261        "  appended {} | duplicates {} | NEW import-conflicts {}",
262        import.appended, import.duplicates, import.conflicts
263    );
264    if import.conflicts > 0 {
265        let _ = writeln!(
266            out,
267            "  ! resolve conflicts with `reconcile accept-conflict <id>` or `reject-conflict <id>` (see `verify`)"
268        );
269    }
270    out
271}
272
273/// `exchange:provider:account` | `self:label` (the same grammar `eventref::parse_wallet_id` accepts).
274pub fn wallet_label(w: &WalletId) -> String {
275    match w {
276        WalletId::Exchange { provider, account } => format!("exchange:{provider}:{account}"),
277        WalletId::SelfCustody { label } => format!("self:{label}"),
278    }
279}
280
281/// UX-P4-9: the shared "insufficient balance" message for a `what-if sell`/`harvest` whose wallet
282/// pool cannot cover the sale. Names the AVAILABLE balance, the wallet, and the as-of date so the
283/// refusal is legible; `available == 0` is the honest "no BTC" case (an empty wallet), distinct from
284/// mere insufficiency (lots exist but fall short). Used by BOTH the CLI (`cmd::whatif::map_whatif_err`)
285/// and the interactive TUI panel (`btctax_tui::whatif_panel::refusal_message`) so the two surfaces
286/// read identically. SCREEN-ONLY.
287pub fn no_lots_message(wallet: &WalletId, at: TaxDate, available: Sat, requested: Sat) -> String {
288    if available == 0 {
289        format!("no BTC available in {} as of {}", wallet_label(wallet), at)
290    } else {
291        format!(
292            "only {} BTC available in {} as of {} (requested {} BTC)",
293            fmt_btc(available),
294            wallet_label(wallet),
295            at,
296            fmt_btc(requested),
297        )
298    }
299}
300
301fn disposal_year(d: &btctax_core::Disposal) -> i32 {
302    d.disposed_at.year()
303}
304
305/// FR4 render: holdings (always current) + realized disposals/removals/income (year-filtered).
306pub fn render_report(state: &LedgerState, year: Option<i32>) -> String {
307    let mut out = String::new();
308    let yr = |y: i32| year.is_none_or(|f| f == y); // year filter; None => all (is_none_or stable since 1.82)
309
310    let _ = writeln!(out, "Holdings (per wallet):");
311    if state.holdings_by_wallet.is_empty() {
312        let _ = writeln!(out, "  none");
313    }
314    for (w, sat) in &state.holdings_by_wallet {
315        let _ = writeln!(out, "  {}: {} sat", wallet_label(w), sat);
316    }
317    // UX-P4-6: surface unreconciled transfer sats in the holdings view (BTC unit) so `report` no
318    // longer hides what `verify` alone reported. Shown only when sats are actually pending.
319    if state.stats.sigma_pending > 0 {
320        let n = state.pending_reconciliation.len();
321        let plural = if n == 1 { "transfer" } else { "transfers" };
322        let _ = writeln!(
323            out,
324            "  Pending: {} BTC ({n} unreconciled {plural} — see `btctax verify`)",
325            fmt_btc(state.stats.sigma_pending),
326        );
327    }
328
329    let _ = writeln!(out, "Lots:");
330    if state.lots.is_empty() {
331        let _ = writeln!(out, "  none");
332    }
333    for l in &state.lots {
334        let _ = writeln!(
335            out,
336            "  {}#{} {} remaining {} sat | basis {} ({}){}{}",
337            l.lot_id.origin_event_id.canonical(),
338            l.lot_id.split_sequence,
339            wallet_label(&l.wallet),
340            l.remaining_sat,
341            l.usd_basis,
342            basis_source_tag(l.basis_source),
343            if l.basis_pending {
344                " [basis pending]"
345            } else {
346                ""
347            },
348            pseudo_tag(l.pseudo),
349        );
350    }
351
352    let label = match year {
353        Some(y) => format!("(year {y})"),
354        None => "(all years)".to_string(),
355    };
356
357    let disposals: Vec<_> = state
358        .disposals
359        .iter()
360        .filter(|d| yr(disposal_year(d)))
361        .collect();
362    if disposals.is_empty() {
363        let _ = writeln!(out, "Disposals {}: none", label);
364    } else {
365        let _ = writeln!(out, "Disposals {}:", label);
366        for d in disposals {
367            let _ = writeln!(
368                out,
369                "  {} @ {} ({})",
370                dispose_kind_tag(d.kind),
371                d.disposed_at,
372                d.event.canonical()
373            );
374            for leg in &d.legs {
375                render_disposal_leg(&mut out, leg);
376            }
377        }
378    }
379
380    let removals: Vec<_> = state
381        .removals
382        .iter()
383        .filter(|r| yr(r.removed_at.year()))
384        .collect();
385    if removals.is_empty() {
386        let _ = writeln!(out, "Removals {}: none", label);
387    } else {
388        let _ = writeln!(out, "Removals {}:", label);
389        for r in removals {
390            let deduction_tag = match r.claimed_deduction {
391                Some(d) => format!(" [claimed deduction {}]", fmt_money(d)),
392                None => String::new(),
393            };
394            let _ = writeln!(
395                out,
396                "  {} @ {} ({}){}",
397                removal_kind_tag(r.kind),
398                r.removed_at,
399                r.event.canonical(),
400                deduction_tag
401            );
402            for leg in &r.legs {
403                render_removal_leg(&mut out, leg);
404            }
405        }
406    }
407
408    let income: Vec<_> = state
409        .income_recognized
410        .iter()
411        .filter(|i| yr(i.recognized_at.year()))
412        .collect();
413    if income.is_empty() {
414        let _ = writeln!(out, "Income {}: none", label);
415    } else {
416        let _ = writeln!(out, "Income {}:", label);
417        for i in income {
418            let _ = writeln!(
419                out,
420                "  {} @ {} {} sat = {} USD{}{}",
421                income_kind_tag(i.kind),
422                i.recognized_at,
423                i.sat,
424                i.usd_fmv,
425                if i.business { " [business]" } else { "" },
426                pseudo_tag(i.pseudo), // [R0-I2] a pseudo daily-close income FMV is flagged on screen
427            );
428        }
429    }
430
431    // Per-year charitable-deduction total (Schedule A itemized; pre-§170(b) AGI limits).
432    // Σ claimed_deduction over Donation removals in the year-filtered window.
433    let charitable_total: btctax_core::conventions::Usd = state
434        .removals
435        .iter()
436        .filter(|r| yr(r.removed_at.year()) && r.kind == RemovalKind::Donation)
437        .filter_map(|r| r.claimed_deduction)
438        .sum();
439    let _ = writeln!(
440        out,
441        "Charitable deduction {} (Schedule A itemized) — BEFORE §170(b) AGI limits / carryover: {}",
442        label,
443        fmt_money(charitable_total)
444    );
445
446    out
447}
448
449fn render_disposal_leg(out: &mut String, leg: &DisposalLeg) {
450    let zone = leg
451        .gift_zone
452        .map(|z| format!(" gift-zone {}", gift_zone_tag(z)))
453        .unwrap_or_default();
454    let _ = writeln!(
455        out,
456        "    {} sat: proceeds {} basis {} gain {} {}{}{}",
457        leg.sat,
458        leg.proceeds,
459        leg.basis,
460        leg.gain,
461        term_tag(leg.term),
462        zone,
463        pseudo_tag(leg.pseudo),
464    );
465}
466
467fn render_removal_leg(out: &mut String, leg: &RemovalLeg) {
468    let _ = writeln!(
469        out,
470        "    {} sat: basis {} fmv {} {} (zero gain){}",
471        leg.sat,
472        leg.basis,
473        leg.fmv_at_transfer,
474        term_tag(leg.term),
475        pseudo_tag(leg.pseudo),
476    );
477}
478
479// ── FR9 verify ──────────────────────────────────────────────────────────────────────────────────
480
481/// Stable display tag for `FilingStatus` (lowercase, matches the CLI value-enum strings).
482///
483/// Values: "single" | "mfj" | "mfs" | "hoh" | "qss". These mirror the `FilingStatusArg`
484/// `ValueEnum` strings accepted by `--filing-status`, so the `tax-profile --show` output
485/// is round-trip-parseable via the same flag.
486pub fn filing_status_tag(fs: btctax_core::FilingStatus) -> &'static str {
487    use btctax_core::FilingStatus::*;
488    match fs {
489        Single => "single",
490        Mfj => "mfj",
491        Mfs => "mfs",
492        HoH => "hoh",
493        Qss => "qss",
494    }
495}
496
497/// Stable display tag for `LotMethod` (FIFO/LIFO/HIFO — uppercase, human-readable).
498/// UX-P4-12(e): a human label for the TP8 self-transfer fee treatment, instead of the raw Debug
499/// variant name (`TreatmentC`/`TreatmentB`) leaking on screen.
500pub fn fee_treatment_display(t: btctax_core::FeeTreatment) -> &'static str {
501    match t {
502        btctax_core::FeeTreatment::TreatmentC => "non-taxable, basis carries (TP8 c)",
503        btctax_core::FeeTreatment::TreatmentB => "taxable mini-disposition (TP8 b)",
504    }
505}
506
507pub fn lot_method_display(m: LotMethod) -> &'static str {
508    match m {
509        LotMethod::Fifo => "FIFO",
510        LotMethod::Lifo => "LIFO",
511        LotMethod::Hifo => "HIFO",
512    }
513}
514
515/// One entry in the `MethodElection` standing-order history reported by `verify`.
516#[derive(Debug, Clone)]
517pub struct ElectionLine {
518    pub recorded: TaxDate,
519    pub effective_from: TaxDate,
520    pub method: LotMethod,
521    /// `None` = a VAULT-WIDE (global) election; `Some(w)` = scoped to that exchange account only.
522    pub wallet: Option<WalletId>,
523    /// "in force" | "voided" | "backdated/ignored"
524    pub note: &'static str,
525}
526
527/// The set of decision targets that a `VoidDecisionEvent` has voided (for election notes + selection
528/// counting). Shared by `verify` and `config` (UX-P4-12(c)).
529pub fn voided_targets(events: &[LedgerEvent]) -> BTreeSet<EventId> {
530    events
531        .iter()
532        .filter_map(|e| match &e.payload {
533            EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
534            _ => None,
535        })
536        .collect()
537}
538
539/// The `MethodElection` standing-order history, sorted by `decision_seq` for a stable total order;
540/// each `note` marks in-force / voided / backdated. Shared by `verify`'s Standing-orders block and
541/// `config`'s forward-method read-back (UX-P4-12(c)).
542pub fn method_election_lines(
543    events: &[LedgerEvent],
544    voided: &BTreeSet<EventId>,
545) -> Vec<ElectionLine> {
546    let mut election_events: Vec<(u64, &LedgerEvent)> = events
547        .iter()
548        .filter_map(|e| {
549            if let EventId::Decision { seq } = e.id {
550                if matches!(e.payload, EventPayload::MethodElection(_)) {
551                    return Some((seq, e));
552                }
553            }
554            None
555        })
556        .collect();
557    election_events.sort_by_key(|(s, _)| *s);
558
559    election_events
560        .iter()
561        .map(|(_, e)| {
562            let EventPayload::MethodElection(me) = &e.payload else {
563                unreachable!("filtered to MethodElection above")
564            };
565            let recorded = tax_date(e.utc_timestamp, e.original_tz);
566            let note = if voided.contains(&e.id) {
567                "voided"
568            } else if me.effective_from < TRANSITION_DATE || me.effective_from < recorded {
569                "backdated/ignored"
570            } else {
571                "in force"
572            };
573            ElectionLine {
574                recorded,
575                effective_from: me.effective_from,
576                method: me.method,
577                wallet: me.wallet.clone(),
578                note,
579            }
580        })
581        .collect()
582}
583
584/// Structured FR9 outcome (so tests assert on data, not stdout, and `main` keys the exit code).
585#[derive(Debug, Clone)]
586pub struct VerifyReport {
587    pub conservation: ConservationReport,
588    pub hard: Vec<Blocker>,
589    pub advisory: Vec<Blocker>,
590    pub pending: usize,
591    pub unknown_basis_inbounds: usize,
592    pub safe_harbor: String,
593    /// Task 8: declared `pre2025_method` from the CLI config (attested or not).
594    pub declared_pre2025_method: LotMethod,
595    pub pre2025_method_attested: bool,
596    /// Task 8: standing-order history (all `MethodElection` decisions, sorted by decision_seq).
597    pub elections: Vec<ElectionLine>,
598    /// Task 8: count of non-voided `LotSelection` decisions.
599    pub selection_count: usize,
600    /// Task 8: per-disposal compliance (post-2025 only).
601    pub compliance: Vec<DisposalCompliance>,
602    /// Task 11 (BG-D3): per-live-promote verify-drift advisory — the stored filed floor recomputed
603    /// against CURRENT price data (overstated → conditional void+re-promote; understated → surfaced).
604    /// Empty when no live promote drifts. Informational; never gates (the fold still uses the stored
605    /// number).
606    pub drift: Vec<String>,
607}
608
609impl VerifyReport {
610    /// Non-zero exit condition (§7.1): any open hard blocker. (Conservation imbalance always coincides
611    /// with a hard blocker — `uncovered_disposal` — so the hard list is the single source of truth.)
612    pub fn has_hard_blockers(&self) -> bool {
613        !self.hard.is_empty()
614    }
615}
616
617/// 2025-transition status for display: detect effective Path B via lot basis-source, then
618/// advisory blockers (§7.4). Prefer the effective-state signal (SafeHarborAllocated lots) over
619/// the advisory blocker so the attest happy-path (void-prior → re-attest) is not
620/// misreported as time-barred when a stale SafeHarborTimebar advisory remains in state.blockers
621/// from the now-voided inert allocation.
622///
623/// Fix: also OR in disposal/removal legs for SafeHarborAllocated basis-source. When ALL
624/// Path-B allocated lots are fully consumed (remaining_sat==0 → filtered out by `finalize`),
625/// `state.lots` has no SafeHarborAllocated entries, but the disposed/removed legs still carry
626/// the correct basis_source and prove Path B was effective at fold time.
627fn safe_harbor_status(state: &LedgerState, _events: &[LedgerEvent]) -> String {
628    // Effective Path B: seeded SafeHarborAllocated lots at the 2025-01-01 boundary.
629    // Check remaining lots, disposal legs, and removal legs (all three carry basis_source).
630    let effective_path_b = state
631        .lots
632        .iter()
633        .any(|l| l.basis_source == BasisSource::SafeHarborAllocated)
634        || state.disposals.iter().any(|d| {
635            d.legs
636                .iter()
637                .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
638        })
639        || state.removals.iter().any(|r| {
640            r.legs
641                .iter()
642                .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
643        });
644    let unconservable = state
645        .blockers
646        .iter()
647        .any(|b| b.kind == BlockerKind::SafeHarborUnconservable);
648    let timebar = state
649        .blockers
650        .iter()
651        .any(|b| b.kind == BlockerKind::SafeHarborTimebar);
652    // SafeHarborUnconservable is a hard blocker; resolve never seeds effective lots when it fires,
653    // so unconservable wins unconditionally. effective_path_b next: if the engine is on Path B
654    // (lots are present), report it regardless of any stale timebar advisory.
655    if unconservable {
656        "Path B allocation FAILS conservation/eligibility (hard, §7.4) — fix the allocation"
657            .to_string()
658    } else if effective_path_b {
659        "Path B safe-harbor allocation is effective (§7.4)".to_string()
660    } else if timebar {
661        "Path B time-barred -> using Path A (advisory); `reconcile safe-harbor attest` if timely in your books".to_string()
662    } else {
663        "Path A (actual per-wallet reconstruction; default, no election)".to_string()
664    }
665}
666
667pub fn build_verify(
668    state: &LedgerState,
669    events: &[LedgerEvent],
670    prices: &dyn btctax_core::price::PriceProvider,
671    cli: &CliConfig,
672) -> VerifyReport {
673    let conservation = conservation_report(state);
674    let mut hard = Vec::new();
675    let mut advisory = Vec::new();
676    for b in &state.blockers {
677        match b.kind.severity() {
678            Severity::Hard => hard.push(b.clone()),
679            Severity::Advisory => advisory.push(b.clone()),
680        }
681    }
682    let unknown_basis_inbounds = state
683        .blockers
684        .iter()
685        .filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
686        .count();
687
688    // Build the voided set (for election notes and selection counting).
689    let voided = voided_targets(events);
690
691    // Build election history (NFR4: sorted by decision_seq for a stable total order).
692    let elections = method_election_lines(events, &voided);
693
694    // Count non-voided LotSelection decisions.
695    // Note: a `Decision`-id guard is intentionally omitted — `LotSelection` payloads are
696    // exclusively carried by `EventId::Decision` events (appended via `append_decision` in the
697    // CLI); filtering by payload alone is equivalent and sufficient.
698    let selection_count = events
699        .iter()
700        .filter(|e| matches!(e.payload, EventPayload::LotSelection(_)) && !voided.contains(&e.id))
701        .count();
702
703    // Per-disposal compliance (§A.5): side-effect-free projection.
704    let compliance = disposal_compliance(events, state);
705
706    // Task 11 (BG-D3): the per-live-promote verify-drift advisory — recompute each stored floor against
707    // CURRENT prices (overstated → conditional void+re-promote; understated → surfaced). Empty otherwise.
708    let drift = btctax_core::conservative_promote::promote_drift_advisory(events, prices);
709
710    VerifyReport {
711        conservation,
712        hard,
713        advisory,
714        pending: state.pending_reconciliation.len(),
715        unknown_basis_inbounds,
716        safe_harbor: safe_harbor_status(state, events),
717        declared_pre2025_method: cli.pre2025_method,
718        pre2025_method_attested: cli.pre2025_method_attested,
719        elections,
720        selection_count,
721        compliance,
722        drift,
723    }
724}
725
726/// FR10: write the projected ledger as CSV (the NFR2 plaintext exception). One row per disposal/removal
727/// leg (flattened) + one per lot/income record. Exact values (Decimal/i64) as strings (NFR5).
728/// Each CSV is opened via `fsperms::open_owner_only` (0o600 on Unix) so decrypted PII matches the
729/// hardened permissions already applied to `snapshot.sqlite` by the store crate. The out-dir is
730/// created owner-only (0o700) if absent; when the dir PRE-EXISTS, open_owner_only still forces 0o600
731/// on each new CSV file (the hole that `Writer::from_path` + umask would leave).
732///
733/// The `lots`/`disposals`/`removals`/`income` CSVs are all-years (a full projection dump). The
734/// **Form 8949 + Schedule D** filing artifacts are inherently per-tax-year (P2-B): when `tax_year`
735/// is `Some(y)`, `form8949.csv` + `schedule_d.csv` are additionally written, year-scoped to `y`;
736/// when `None`, they are omitted.
737pub fn write_csv_exports(
738    out_dir: &Path,
739    state: &LedgerState,
740    tax_year: Option<i32>,
741    se_result: Option<&SeTaxResult>,
742    donation_details: &BTreeMap<EventId, DonationDetails>,
743) -> Result<(), crate::CliError> {
744    fsperms::mkdir_owner_only(out_dir)?;
745
746    let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("lots.csv"))?);
747    w.write_record([
748        "origin_event",
749        "split",
750        "wallet",
751        "acquired_at",
752        "remaining_sat",
753        "usd_basis",
754        "basis_source",
755        "basis_pending",
756    ])?;
757    for l in &state.lots {
758        w.write_record([
759            l.lot_id.origin_event_id.canonical(),
760            l.lot_id.split_sequence.to_string(),
761            wallet_label(&l.wallet),
762            l.acquired_at.to_string(),
763            l.remaining_sat.to_string(),
764            l.usd_basis.to_string(),
765            basis_source_tag(l.basis_source).to_string(),
766            l.basis_pending.to_string(),
767        ])?;
768    }
769    w.flush()?;
770
771    let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("disposals.csv"))?);
772    w.write_record([
773        "event",
774        "kind",
775        "disposed_at",
776        "lot",
777        "sat",
778        "proceeds",
779        "basis",
780        "gain",
781        "term",
782        "gift_zone",
783        "acquired_at",
784        "wallet",
785    ])?;
786    for d in &state.disposals {
787        for leg in &d.legs {
788            w.write_record([
789                d.event.canonical(),
790                dispose_kind_tag(d.kind).to_string(),
791                d.disposed_at.to_string(),
792                format!(
793                    "{}#{}",
794                    leg.lot_id.origin_event_id.canonical(),
795                    leg.lot_id.split_sequence
796                ),
797                leg.sat.to_string(),
798                leg.proceeds.to_string(),
799                leg.basis.to_string(),
800                leg.gain.to_string(),
801                term_tag(leg.term).to_string(),
802                leg.gift_zone
803                    .map(|z| gift_zone_tag(z).to_string())
804                    .unwrap_or_default(),
805                leg.acquired_at.to_string(),
806                wallet_label(&leg.wallet),
807            ])?;
808        }
809    }
810    w.flush()?;
811
812    let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("removals.csv"))?);
813    // claimed_deduction: §170(e)(1)(A) deduction amount (pre-§170(b) AGI limits) for Donation
814    // rows; empty for Gift rows. See Removal.claimed_deduction in btctax-core/src/state.rs.
815    // Multi-leg donations: the value is a per-REMOVAL total shown on the FIRST leg row only;
816    // subsequent leg rows carry an empty cell so a naive SUM() equals the correct per-donation
817    // total (no double-counting). Do not divide across legs — that would create rounding artifacts.
818    // donee: free-form donee label (Chunk 2); empty when not set.
819    w.write_record([
820        "event",
821        "kind",
822        "removed_at",
823        "lot",
824        "sat",
825        "basis",
826        "fmv_at_transfer",
827        "term",
828        "acquired_at",
829        "claimed_deduction",
830        "donee",
831    ])?;
832    for r in &state.removals {
833        let deduction_first = r
834            .claimed_deduction
835            .map(|d| d.to_string())
836            .unwrap_or_default();
837        let donee_cell = r.donee.clone().unwrap_or_default();
838        for (leg_idx, leg) in r.legs.iter().enumerate() {
839            // Emit claimed_deduction only on leg 0; leave empty on subsequent legs so SUM()
840            // over the column equals the true per-donation total (not N-legs × deduction).
841            let deduction_cell: &str = if leg_idx == 0 { &deduction_first } else { "" };
842            w.write_record([
843                r.event.canonical(),
844                removal_kind_tag(r.kind).to_string(),
845                r.removed_at.to_string(),
846                format!(
847                    "{}#{}",
848                    leg.lot_id.origin_event_id.canonical(),
849                    leg.lot_id.split_sequence
850                ),
851                leg.sat.to_string(),
852                leg.basis.to_string(),
853                leg.fmv_at_transfer.to_string(),
854                term_tag(leg.term).to_string(),
855                leg.acquired_at.to_string(),
856                deduction_cell.to_string(),
857                donee_cell.clone(),
858            ])?;
859        }
860    }
861    w.flush()?;
862
863    let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("income.csv"))?);
864    w.write_record([
865        "event",
866        "kind",
867        "recognized_at",
868        "sat",
869        "usd_fmv",
870        "business",
871    ])?;
872    for i in &state.income_recognized {
873        w.write_record([
874            i.event.canonical(),
875            income_kind_tag(i.kind).to_string(),
876            i.recognized_at.to_string(),
877            i.sat.to_string(),
878            i.usd_fmv.to_string(),
879            i.business.to_string(),
880        ])?;
881    }
882    w.flush()?;
883
884    // P2-B: per-tax-year Form 8949 + Schedule D filing artifacts (year-scoped; omitted when None).
885    // P2-C: per-tax-year Form 8283 donation artifact rides the same year-scoped block.
886    if let Some(year) = tax_year {
887        write_form8949_csv(out_dir, state, year)?;
888        write_schedule_d_csv(out_dir, state, year)?;
889        write_form8283_csv(out_dir, state, year, donation_details)?;
890        write_basis_methodology_txt(out_dir, state, year)?; // P7 / D-4 (mandatory when a tranche is filed)
891                                                            // P2-D / Chunk B: standalone Schedule SE §1401 figure — written only when there IS SE tax
892                                                            // (a computed SeTaxResult); omitted when there is no business SE income OR when the year
893                                                            // is fully expensed (expenses ≥ gross → net_se == 0 → compute_se_tax returns None — [N4]).
894                                                            // The "fully expensed" render advisory (render_schedule_se) surfaces the liability status
895                                                            // ("no §1401 SE tax"); the CSV writer sees None in both the no-income and fully-expensed
896                                                            // cases — same omission, different reason.
897        if let Some(se) = se_result {
898            write_schedule_se_csv(out_dir, se)?;
899        }
900    }
901    Ok(())
902}
903
904/// Write the year-scoped form artifacts for `year` into `out_dir`.
905///
906/// Creates `out_dir` owner-only (0o700) if absent (tolerant `mkdir_owner_only`, mkdir-p);
907/// each file is written via `fsperms::open_owner_only` (0o600).  Writes `form8949.csv`,
908/// `schedule_d.csv`, `form8283.csv`; `schedule_se.csv` when `se_result` is `Some`; and the
909/// mandatory `basis_methodology.txt` (P7 / D-4) when a conservative-filing tranche is in the
910/// year's filed set. The all-years dump CSVs (`lots.csv`, `disposals.csv`, `removals.csv`,
911/// `income.csv`) are NOT written; `export_snapshot` / `snapshot.sqlite` are NEVER called
912/// or written.
913///
914/// Path containment is the CALLER's job (matching `write_csv_exports` / `export_snapshot`
915/// / `backup_key`): callers must pass a freshly-created or trusted directory — this
916/// function truncates the four fixed filenames in `out_dir` (`open_owner_only` is
917/// create-or-truncate and follows symlinks). The TUI's `export.rs` satisfies this via
918/// `mkdir_owner_only_exclusive` (D2).
919pub fn write_form_csvs(
920    out_dir: &Path,
921    state: &LedgerState,
922    year: i32,
923    se_result: Option<&SeTaxResult>,
924    donation_details: &BTreeMap<EventId, DonationDetails>,
925) -> Result<(), crate::CliError> {
926    fsperms::mkdir_owner_only(out_dir)?;
927    write_form8949_csv(out_dir, state, year)?;
928    write_schedule_d_csv(out_dir, state, year)?;
929    write_form8283_csv(out_dir, state, year, donation_details)?;
930    write_basis_methodology_txt(out_dir, state, year)?; // P7 / D-4 (mandatory when a tranche is filed)
931    if let Some(se) = se_result {
932        write_schedule_se_csv(out_dir, se)?;
933    }
934    Ok(())
935}
936
937/// BG-D8 (Task 14): write the Form 8275 disclosure (`form_8275.txt`, 0o600) — by its OWN name — alongside
938/// the year's form artifacts whenever a promoted DISPOSAL leg files in `year`. Writes NOTHING for a year
939/// with no promoted disposal leg (`disclosure_8275` → `None`).
940///
941/// ★ Distinct from [`write_basis_methodology_txt`] in TWO ways the review loop pinned:
942/// - **Its own name.** The gate + the success KAT key on `form_8275.txt`, never a `form_8275.txt ||
943///   basis_methodology.txt` disjunction — `basis_methodology.txt` is written unconditionally for a
944///   promoted year, so the disjunction would be a vacuous assertion (tax r1 I-8).
945/// - **The gate ran first.** The completeness gate ([`crate::cmd::admin::promote_export_gate`]) refuses
946///   BEFORE any bytes when a promoted leg's Part II is empty/incomplete, so a promoted leg reaching HERE
947///   is guaranteed to carry a complete Part II — this always emits a filing-ready disclosure.
948///
949/// `pub` so the `export-snapshot` CSV / `export-irs-pdf` / full-return packet writers (`cmd/admin.rs`)
950/// AND the TUI export path (`btctax-tui::export::do_export`, Approach-B Task 17) emit it at their
951/// `write_basis_methodology_txt` call sites — the TUI reaches it via `btctax_cli::render::…` (no `cmd::`
952/// token, so its KAT-E10 source gate stays green).
953pub fn write_form_8275_txt(
954    out_dir: &Path,
955    state: &LedgerState,
956    events: &[LedgerEvent],
957    year: i32,
958) -> Result<(), crate::CliError> {
959    write_form_8275_txt_named(out_dir, state, events, year, "form_8275.txt")
960}
961
962/// The `write_form_8275_txt` write, under an explicit `filename`. Task 16 / M2: the all-years
963/// `export_snapshot` dump (`tax_year: None`) can have MULTIPLE promoted years in the exported range,
964/// and every one of them would otherwise collide on the same bare `form_8275.txt` — silently
965/// overwriting all but the last. The single-year call sites keep the bare name (`write_form_8275_txt`
966/// above); the all-years path names each file `form_8275_{year}.txt` instead.
967pub(crate) fn write_form_8275_txt_named(
968    out_dir: &Path,
969    state: &LedgerState,
970    events: &[LedgerEvent],
971    year: i32,
972    filename: &str,
973) -> Result<(), crate::CliError> {
974    use std::io::Write as _;
975    if let Some(disc) = btctax_core::tax::form8275::disclosure_8275(events, state, year) {
976        let mut file = fsperms::open_owner_only(&out_dir.join(filename))?;
977        // `render()` already terminates with a newline — write, don't writeln (no trailing blank line).
978        write!(file, "{}", disc.render())?;
979    }
980    Ok(())
981}
982
983/// P7 (D-4): write the MANDATORY conservative-filing methodology disclosure (`basis_methodology.txt`,
984/// 0o600) alongside the year's form artifacts whenever a tranche is in the year's filed set. A no-tranche
985/// year writes NOTHING — the i8949 basis explanation is required only when actual cost is not used.
986/// `pub(crate)` so the `export-irs-pdf` / full-return packet writers (`cmd/admin.rs`) emit it too (I-3).
987pub(crate) fn write_basis_methodology_txt(
988    out_dir: &Path,
989    state: &LedgerState,
990    year: i32,
991) -> Result<(), crate::CliError> {
992    use std::io::Write as _;
993    if let Some(text) = btctax_core::conservative::basis_methodology(state, year) {
994        let mut file = fsperms::open_owner_only(&out_dir.join("basis_methodology.txt"))?;
995        writeln!(file, "{text}")?;
996    }
997    Ok(())
998}
999
1000/// P2-D Task 2: write `schedule_se.csv` — the standalone §1401 SE-tax components for the tax year.
1001/// One data row. Stable snake_case columns; exact `Decimal` string values (NFR5). 0o600 via
1002/// `open_owner_only`. Written only when a `SeTaxResult` exists (business SE income present + table).
1003fn write_schedule_se_csv(out_dir: &Path, se: &SeTaxResult) -> Result<(), crate::CliError> {
1004    let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_se.csv"))?);
1005    w.write_record([
1006        "net_se_earnings",
1007        "se_base_9235",
1008        "ss_component",
1009        "medicare_component",
1010        "additional_medicare_component",
1011        "total_se_tax",
1012        "deductible_half",
1013    ])?;
1014    w.write_record([
1015        se.net_se.to_string(),
1016        se.base.to_string(),
1017        se.ss.to_string(),
1018        se.medicare.to_string(),
1019        se.addl.to_string(),
1020        se.total.to_string(),
1021        se.deductible_half.to_string(),
1022    ])?;
1023    w.flush()?;
1024    Ok(())
1025}
1026
1027/// The standing Section A/B aggregation note — emitted as the first (comment) line of form8283.csv
1028/// and reused by any text/advisory path. Reflects the §170(f)(11)(F) year-aggregate implementation:
1029/// all BTC is "similar property"; the YEAR-total BTC donation deduction determines Section A/B
1030/// uniformly for all rows. CCA 202302012 confirms the readily-valued exception does not apply to
1031/// crypto, so a year-aggregate > $5,000 requires a qualified appraisal.
1032pub const FORM_8283_AGGREGATION_CAVEAT: &str =
1033    "Section A/B reflects the \u{00a7}170(f)(11)(F) year-aggregate for similar property: all BTC \
1034     donations in the year are summed (all BTC is 'similar property'); if the year-total claimed \
1035     deduction exceeds $5,000 every row is Section B (qualified appraisal required), otherwise \
1036     Section A. CCA 202302012: the readily-valued exception does not apply to crypto.";
1037
1038/// §170(f)(11)(F) year-aggregate appraisal advisory (D2) — render-time only.
1039///
1040/// Emits a standalone advisory when the year-aggregate claimed deduction exceeds
1041/// `QUALIFIED_APPRAISAL_THRESHOLD` ($5,000, strict `>`): even if no single BTC donation exceeds
1042/// $5,000, the year-aggregate may require a qualified appraisal (CCA 202302012: the readily-valued
1043/// exception does not apply to crypto; all BTC is "similar property").
1044///
1045/// **Render-time only — does NOT enter `state.advisory` / the blocker set** (consistent with the
1046/// standalone-forms pattern; the per-donation `BlockerKind::QualifiedAppraisalNote` in fold.rs
1047/// is left as-is — this advisory adds the year-aggregate signal without touching the fold).
1048///
1049/// Delegates to `btctax_core::year_donation_deduction` — the **shared helper** that `form_8283`
1050/// uses for the Section A/B decision and that `write_form8283_csv` uses for the [R0-M1] $500 floor
1051/// note. This is the single source of truth: the form, the floor note, and this advisory all call
1052/// the same function, making it structurally impossible for them to diverge.
1053///
1054/// Returns `None` when the year has no donations or the aggregate ≤ $5,000.
1055pub fn render_donation_appraisal_advisory(state: &LedgerState, year: i32) -> Option<String> {
1056    use btctax_core::QUALIFIED_APPRAISAL_THRESHOLD;
1057    let agg = year_donation_deduction(state, year);
1058    if agg <= QUALIFIED_APPRAISAL_THRESHOLD {
1059        return None;
1060    }
1061    // N1: format both the aggregate and the threshold with the same money formatter (fmt_money →
1062    // 2dp, no thousands separator) so the two dollar figures in the advisory are styled uniformly.
1063    let threshold = fmt_money(QUALIFIED_APPRAISAL_THRESHOLD);
1064    Some(format!(
1065        "\u{00a7}170(f)(11)(F): your {year} BTC donations aggregate ${} of claimed deduction \
1066         (> ${threshold}) \u{2014} a qualified appraisal is required for the donated BTC even if \
1067         no single donation exceeds ${threshold} (all BTC is 'similar property'; CCA 202302012 \
1068         \u{2014} no readily-valued exception for crypto).",
1069        fmt_money(agg)
1070    ))
1071}
1072
1073/// P2-C Task 2: write `form8283.csv` — one row per `Donation` `RemovalLeg` contributed in `year`.
1074/// Stable snake_case columns; exact `Decimal`/`i64` string values (NFR5). 0o600 via `open_owner_only`.
1075///
1076/// The file leads with `#`-prefixed comment lines: the standing aggregation note, and — when the
1077/// year's total noncash charitable deduction is ≤ $500 — the [R0-M1] filing-floor note that Form
1078/// 8283 is not required at that level (the rows are still emitted, informationally).
1079fn write_form8283_csv(
1080    out_dir: &Path,
1081    state: &LedgerState,
1082    year: i32,
1083    details: &BTreeMap<EventId, DonationDetails>,
1084) -> Result<(), crate::CliError> {
1085    use std::io::Write as _;
1086    let mut file = fsperms::open_owner_only(&out_dir.join("form8283.csv"))?;
1087
1088    // STANDING aggregation note — CSV header comment line (read with comment=b'#').
1089    writeln!(file, "# {FORM_8283_AGGREGATION_CAVEAT}")?;
1090
1091    // [R0-M1] $500 form-filing floor: Form 8283 is required only when total noncash contributions
1092    // for the year exceed $500. Rows are emitted regardless; add a note when the year's total
1093    // donation deduction is ≤ $500 that Form 8283 is not required at that level.
1094    // Uses btctax_core::year_donation_deduction — the shared helper (single source of truth) that
1095    // form_8283 and render_donation_appraisal_advisory also call.
1096    let total_deduction = year_donation_deduction(state, year);
1097    if total_deduction <= rust_decimal::Decimal::from(500) {
1098        writeln!(
1099            file,
1100            "# [R0-M1] The year's total noncash charitable deduction ({}) is <= $500; Form 8283 is \
1101             NOT required at that level (rows below are informational only).",
1102            fmt_money(total_deduction)
1103        )?;
1104    }
1105
1106    let mut w = Writer::from_writer(file);
1107    w.write_record([
1108        "section",
1109        "description",
1110        "how_acquired",
1111        "date_acquired",
1112        "date_contributed",
1113        "cost_basis",
1114        "fmv",
1115        "claimed_deduction",
1116        "fmv_method",
1117        "donee",
1118        "appraiser",
1119        "needs_review",
1120        // NEW Part III/IV detail columns:
1121        "donee_ein",
1122        "donee_address",
1123        "appraiser_tin",
1124        "appraiser_ptin",
1125        "appraiser_qualifications",
1126        "appraisal_date",
1127    ])?;
1128    for row in form_8283(state, year, details) {
1129        let d = row.details.as_ref();
1130        w.write_record([
1131            row.section
1132                .map(form8283_section_tag)
1133                .unwrap_or("")
1134                .to_string(),
1135            row.description,
1136            form8283_how_acquired_tag(row.how_acquired).to_string(),
1137            row.date_acquired.to_string(),
1138            row.date_contributed.to_string(),
1139            row.cost_basis.to_string(),
1140            row.fmv.to_string(),
1141            row.claimed_deduction
1142                .map(|d| d.to_string())
1143                .unwrap_or_default(),
1144            row.fmv_method,
1145            row.donee,
1146            row.appraiser,
1147            row.needs_review.to_string(),
1148            // NEW:
1149            d.and_then(|d| d.donee_ein.clone()).unwrap_or_default(),
1150            d.and_then(|d| d.donee_address.clone()).unwrap_or_default(),
1151            d.and_then(|d| d.appraiser_tin.clone()).unwrap_or_default(),
1152            d.and_then(|d| d.appraiser_ptin.clone()).unwrap_or_default(),
1153            d.and_then(|d| d.appraiser_qualifications.clone())
1154                .unwrap_or_default(),
1155            d.and_then(|d| d.appraisal_date.map(|dt| dt.to_string()))
1156                .unwrap_or_default(),
1157        ])?;
1158    }
1159    w.flush()?;
1160    Ok(())
1161}
1162
1163/// P2-B Task 2: write `form8949.csv` — one row per `DisposalLeg` disposed in `year`. Stable
1164/// snake_case columns; exact `Decimal`/`i64` string values (NFR5). 0o600 via `open_owner_only`.
1165fn write_form8949_csv(
1166    out_dir: &Path,
1167    state: &LedgerState,
1168    year: i32,
1169) -> Result<(), crate::CliError> {
1170    let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("form8949.csv"))?);
1171    w.write_record([
1172        "part",
1173        "box",
1174        "box_needs_review",
1175        "description",
1176        "date_acquired",
1177        "date_sold",
1178        "proceeds",
1179        "cost_basis",
1180        "adjustment_code",
1181        "adjustment_amount",
1182        "gain",
1183        "wallet",
1184        "disposition_kind",
1185    ])?;
1186    for r in form_8949(state, year) {
1187        w.write_record([
1188            form8949_part_tag(r.part).to_string(),
1189            form8949_box_tag(r.box_).to_string(),
1190            r.box_needs_review.to_string(),
1191            r.description,
1192            r.date_acquired.to_string(),
1193            r.date_sold.to_string(),
1194            r.proceeds.to_string(),
1195            r.cost_basis.to_string(),
1196            r.adjustment_code,
1197            r.adjustment_amount.to_string(),
1198            r.gain.to_string(),
1199            wallet_label(&r.wallet),
1200            dispose_kind_tag(r.disposition_kind).to_string(),
1201        ])?;
1202    }
1203    w.flush()?;
1204    Ok(())
1205}
1206
1207/// P2-B Task 3: write `schedule_d.csv` — the two RAW pre-netting part totals (Part I ST, Part II LT)
1208/// for `year`. §1222/§1211/§1212 netting + carryforward is applied by engine B, not here (D3).
1209fn write_schedule_d_csv(
1210    out_dir: &Path,
1211    state: &LedgerState,
1212    year: i32,
1213) -> Result<(), crate::CliError> {
1214    let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_d.csv"))?);
1215    w.write_record(["part", "proceeds", "cost_basis", "gain"])?;
1216    let totals = schedule_d(state, year);
1217    for (part, p) in [("ST", &totals.st), ("LT", &totals.lt)] {
1218        w.write_record([
1219            part.to_string(),
1220            p.proceeds.to_string(),
1221            p.cost_basis.to_string(),
1222            p.gain.to_string(),
1223        ])?;
1224    }
1225    w.flush()?;
1226    Ok(())
1227}
1228
1229/// UX-P4-1: the pseudo-disclosure channel for a tax-year figure — which, if any, deliberately-synthetic
1230/// input the number rides on. Carries the FULL §3.1 predicate (`pseudo_active() OR PseudoPlaceholder`) so a
1231/// caller cannot thread a single disjunct and silently drop the other (SPEC r2-N3). The two active channels
1232/// are mutually exclusive by PRECEDENCE — `Synthetic` (a pseudo synthetic lot/FMV; `pseudo_active()`, i.e.
1233/// `pseudo_synthetic_count > 0`) is chosen ahead of `Placeholder` (computed on the all-$0 pseudo placeholder
1234/// profile; mode on, nothing stored, `count == 0`) — even though the underlying states can co-occur.
1235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1236pub enum PseudoDisclosure {
1237    /// Not pseudo-contributed — no banner, no suffix.
1238    None,
1239    /// A pseudo synthetic lot/FMV feeds the figure (`pseudo_active()`).
1240    Synthetic,
1241    /// Computed on the all-$0 pseudo placeholder profile (mode on, nothing stored, `count == 0`).
1242    Placeholder,
1243}
1244
1245impl PseudoDisclosure {
1246    /// True iff the figure is pseudo-contributed (either active channel).
1247    pub fn contributed(self) -> bool {
1248        self != PseudoDisclosure::None
1249    }
1250    /// The ` [PSEUDO]` suffix for a headline total line (leading space kept so a last-field scraper reads
1251    /// `[PSEUDO]` and fails loud), or `""` when not contributed.
1252    pub fn suffix(self) -> &'static str {
1253        if self.contributed() {
1254            " [PSEUDO]"
1255        } else {
1256            ""
1257        }
1258    }
1259    /// The channel-aware top banner (with a trailing newline), or `""` when not contributed. Each clause is
1260    /// true for its channel; the remedy pointers are live only for the channel that fires them (SPEC §3.1).
1261    pub fn banner(self) -> &'static str {
1262        match self {
1263            PseudoDisclosure::None => "",
1264            PseudoDisclosure::Synthetic => {
1265                "⚠ [PSEUDO] This vault has pseudo-reconciled (deliberately-synthetic) entries; figures shown \
1266                 are an ESTIMATE, not filing-ready. See '[PSEUDO]' rows in 'btctax report' and the \
1267                 [PseudoReconcileActive] advisory in 'btctax verify'; resolve them before filing.\n"
1268            }
1269            PseudoDisclosure::Placeholder => {
1270                "⚠ [PSEUDO] These figures are estimated on a synthetic $0 placeholder profile — no tax \
1271                 profile or full-return inputs are stored for this year. This is an ESTIMATE, not \
1272                 filing-ready. Set a tax profile ('btctax tax-profile --year <Y> …' — setting is the \
1273                 default; '--show' inverts), import inputs ('btctax income import'), or turn pseudo mode off \
1274                 ('btctax reconcile pseudo off').\n"
1275            }
1276        }
1277    }
1278}
1279
1280/// Task 9 (B.5) + Task 10 (M4): render the `TaxOutcome` for `report --tax-year <y>`. Exact Decimal
1281/// Display; no float (NFR5). B-M2 fold: surfaces the ordinary-rate attributable delta so the three
1282/// printed attributable components visibly reconcile to `total_federal_tax_attributable`.
1283///
1284/// `advisory` is the optional M4 carryforward-consistency warning string (Task 10). When `Some`,
1285/// it is printed as a non-gating advisory line that does not affect the exit code.
1286///
1287/// `pseudo` (UX-P4-1): the disclosure channel. When contributed, an unconditional top banner is emitted and
1288/// the TOTAL line is ` [PSEUDO]`-suffixed — so neither a human nor a single-line scraper reads the
1289/// authoritative number without the flag.
1290pub fn render_tax_outcome(
1291    year: i32,
1292    out: &btctax_core::TaxOutcome,
1293    advisory: Option<&str>,
1294    pseudo: PseudoDisclosure,
1295) -> String {
1296    use btctax_core::TaxOutcome::*;
1297    let mut s = String::new();
1298    s.push_str(pseudo.banner());
1299    let _ = writeln!(s, "Federal tax attributable to crypto — tax year {year}");
1300    match out {
1301        NotComputable(b) => {
1302            let _ = writeln!(s, "  NOT COMPUTABLE [{:?}]: {}", b.kind, b.detail);
1303        }
1304        Computed(r) => {
1305            let _ = writeln!(
1306                s,
1307                "  net short-term: {}   net long-term: {}",
1308                fmt_money(r.st_net),
1309                fmt_money(r.lt_net)
1310            );
1311            let _ = writeln!(
1312                s,
1313                "  crypto ordinary income (level): {}",
1314                fmt_money(r.ordinary_from_crypto)
1315            );
1316            // B-M2: surface the ordinary-rate attributable DELTA so the three attributable components
1317            // visibly reconcile to TOTAL. By the pinned identity this equals (ord_with − ord_without) exactly.
1318            let ordinary_rate_attributable = r.total_federal_tax_attributable - r.ltcg_tax - r.niit;
1319            let _ = writeln!(
1320                s,
1321                "  ordinary-rate tax (attributable): {}",
1322                fmt_money(ordinary_rate_attributable)
1323            );
1324            let _ = writeln!(
1325                s,
1326                "  LTCG tax (attributable): {}   NIIT (attributable): {}",
1327                fmt_money(r.ltcg_tax),
1328                fmt_money(r.niit)
1329            );
1330            let _ = writeln!(
1331                s,
1332                "  TOTAL federal tax attributable to crypto (delta): {}{}   \
1333                (= ordinary-rate + LTCG + NIIT attributable)",
1334                fmt_money(r.total_federal_tax_attributable),
1335                pseudo.suffix()
1336            );
1337            let _ = writeln!(
1338                s,
1339                "  §1211 loss deduction (level): {}   carryforward out: short {} / long {}",
1340                fmt_money(r.loss_deduction),
1341                fmt_money(r.carryforward_out.short),
1342                fmt_money(r.carryforward_out.long)
1343            );
1344            let _ = writeln!(
1345                s,
1346                "  marginal rates: ordinary {} / LTCG {} / NIIT increased by crypto: {}",
1347                r.marginal_rates.ordinary,
1348                r.marginal_rates.ltcg,
1349                if r.marginal_rates.niit_applies {
1350                    "yes"
1351                } else {
1352                    "no"
1353                }
1354            );
1355            let _ = writeln!(
1356                s,
1357                "  (incremental ceteris-paribus delta on the minimal profile; \
1358                excludes AGI-driven SS/IRMAA/AMT/QBI/phaseout effects — I5. §1411 NIIT reduces NII by the \
1359                §1211(b)-allowed net capital loss (≤ $3,000 / $1,500 MFS — Form 8960 line 5a / §1.1411-4(d)) \
1360                and is floored at $0; crypto ordinary income (mining/staking/airdrops/rewards) is correctly \
1361                excluded from NII; crypto-lending interest income (§1411(c)(1)(A)(i)) is INCLUDED in NII; \
1362                mining/staking/airdrops/rewards remain excluded (SE income per §1411(c)(6) or non-NII other income).)"
1363            );
1364        }
1365    }
1366    // M4 (Task 10): non-gating advisory — render after the main block so it is visible
1367    // regardless of whether the outcome is Computed or NotComputable.
1368    if let Some(msg) = advisory {
1369        let _ = writeln!(s, "  ADVISORY (M4): {msg}");
1370    }
1371    s
1372}
1373
1374/// The house wrap width for advisory text (the widest line the tool prints anywhere).
1375pub(crate) const ADVISORY_WRAP_COLS: usize = 92;
1376
1377/// Wrap `text` under a `  • ` bullet, with continuation lines hanging under the bullet's TEXT (a
1378/// 4-space indent) rather than under the bullet glyph. Breaks on whitespace only; a word longer than
1379/// the line (a URL, say) is left to overflow rather than being cut mid-token — a broken citation is
1380/// worse than a long line.
1381pub(crate) fn wrap_bulleted(text: &str) -> String {
1382    const BULLET: &str = "  \u{2022} ";
1383    const HANG: &str = "    ";
1384    let mut out = String::new();
1385    let mut line = String::from(BULLET);
1386    let mut have_word = false;
1387
1388    for word in text.split_whitespace() {
1389        let prospective = line.chars().count() + usize::from(have_word) + word.chars().count();
1390        if have_word && prospective > ADVISORY_WRAP_COLS {
1391            out.push_str(line.trim_end());
1392            out.push('\n');
1393            line = String::from(HANG);
1394            have_word = false;
1395        }
1396        if have_word {
1397            line.push(' ');
1398        }
1399        line.push_str(word);
1400        have_word = true;
1401    }
1402    out.push_str(line.trim_end());
1403    out
1404}
1405
1406/// Render the Phase-5 full-return **advisories** (SPEC §3.4 / §9.2) — the loud, non-gating notes that a
1407/// favorable credit was omitted conservatively (your tax is OVERSTATED), or that a disclosure is yours to
1408/// make. Never changes a number and never changes the exit code.
1409pub fn render_advisories(advisories: &[btctax_core::tax::advisories::Advisory]) -> String {
1410    let mut s = String::new();
1411    if advisories.is_empty() {
1412        return s;
1413    }
1414    let _ = writeln!(s, "\n  ── ADVISORIES ({}) ──", advisories.len());
1415    for a in advisories {
1416        // Wrap each message under its bullet (`p5-n5`): an advisory is a 300–400-character sentence, and
1417        // an unwrapped one is unreadable in an 80-column terminal. The message text itself is
1418        // single-sourced in core — this only decides where the line breaks are.
1419        let _ = writeln!(s, "{}", wrap_bulleted(&a.message()));
1420    }
1421    let _ = writeln!(
1422        s,
1423        "  (Advisories never change a number and never fail the command. See `btctax limitations`.)"
1424    );
1425    s
1426}
1427
1428/// The §4.12 provenance label for the resolved profile — printed on the full-return output so a
1429/// reviewer can audit which source produced the figures (`p2-provenance-printing`).
1430pub fn provenance_label(p: crate::resolve::Provenance) -> &'static str {
1431    use crate::resolve::Provenance::*;
1432    match p {
1433        ReturnInputs => "ReturnInputs (derived from line items)",
1434        StoredProfile => "stored TaxProfile (raw override)",
1435        PseudoPlaceholder => "pseudo-reconcile placeholder ($0)",
1436        Missing => "none (TaxProfileMissing)",
1437    }
1438}
1439
1440/// Render the **§6 dual report**: the absolute filed-return liability (Form 1040, WITH crypto) and the
1441/// crypto-attribution DELTA are **different questions** — shown together, labeled, and NEVER reconciled to
1442/// the dollar (SPEC §6). Only produced for a `ReturnInputs`-provenance year (a full 1040 exists). `delta`
1443/// is the same `TaxOutcome` the crypto-delta block already showed above. Provenance is printed here (§4.12).
1444/// ★ The absolute block renders the **PRINTED** figures — the whole-dollar, cross-footing lines the filed
1445/// PDF carries — not the exact-cents computation behind them (ARCH-P6 Q3).
1446///
1447/// The clinching case is line 37. "Amount you owe" is not an analytical figure; it is an instruction to
1448/// write a check. A tool that says $12,345.67 in the terminal and prints $12,347 on the filed form has
1449/// produced TWO authoritative answers to "what do I pay", and no LIMITATIONS paragraph repairs that. The
1450/// moment a report line is labelled with a form-line citation, it has promised the FORM's figure.
1451///
1452/// The crypto-DELTA block below stays in exact cents: it is not a filed figure — it answers a different
1453/// question (§6), and the frozen engine computes it in cents.
1454pub fn render_dual_report(
1455    year: i32,
1456    ar: &btctax_core::AbsoluteReturn,
1457    printed: &btctax_core::tax::packet::PrintedForms,
1458    delta: &btctax_core::TaxOutcome,
1459    provenance: crate::resolve::Provenance,
1460    pseudo: PseudoDisclosure,
1461) -> String {
1462    let f = &printed.f1040;
1463    let mut s = String::new();
1464    let _ = writeln!(
1465        s,
1466        "\n═══ Absolute filed return (Form 1040) — tax year {year} ═══"
1467    );
1468    let _ = writeln!(s, "  Profile source: {}", provenance_label(provenance));
1469    let _ = writeln!(s, "  Total income (1040 L9):   {}", fmt_money(f.line9));
1470    let _ = writeln!(s, "  Adjustments (L10):        {}", fmt_money(f.line10));
1471    let _ = writeln!(s, "  AGI (L11):                {}", fmt_money(f.line11));
1472    let ded_kind = if ar.deduction_is_itemized {
1473        "itemized"
1474    } else {
1475        "standard"
1476    };
1477    let _ = writeln!(s, "  Deduction (L12, {ded_kind}): {}", fmt_money(f.line12));
1478    if ar.qbi_deduction > Usd::ZERO {
1479        let _ = writeln!(s, "  QBI deduction (L13):      {}", fmt_money(f.line13));
1480    }
1481    let _ = writeln!(s, "  Taxable income (L15):     {}", fmt_money(f.line15));
1482    let _ = writeln!(s, "  Tax (L16):                {}", fmt_money(f.line16));
1483    if ar.foreign_tax_credit > Usd::ZERO {
1484        let _ = writeln!(
1485            s,
1486            "  Foreign tax credit (Sch 3 L1): {}",
1487            fmt_money(printed.sch_3.map_or(Usd::ZERO, |s3| s3.line1))
1488        );
1489    }
1490    if ar.se_tax_sch2_l4 > Usd::ZERO {
1491        let _ = writeln!(
1492            s,
1493            "  Self-employment tax (Sch 2 L4): {}",
1494            fmt_money(printed.sch_2.map_or(Usd::ZERO, |s2| s2.line4))
1495        );
1496    }
1497    if ar.additional_medicare.additional_medicare_tax > Usd::ZERO {
1498        let _ = writeln!(
1499            s,
1500            "  Additional Medicare (Form 8959 → Sch 2 L11): {}",
1501            fmt_money(printed.f8959.line18)
1502        );
1503    }
1504    if ar.niit.tax > Usd::ZERO {
1505        let _ = writeln!(
1506            s,
1507            "  Net Investment Income Tax (Form 8960 → Sch 2 L12): {}",
1508            fmt_money(printed.f8960.map_or(Usd::ZERO, |f| f.line17))
1509        );
1510    }
1511    // ★ Form 6251. Shown whenever AMTI EXCEEDS THE EXEMPTION (line 6 > 0) — i.e. exactly when the
1512    // filer would otherwise wonder why a high-income return went through. The point is that they can
1513    // SEE the comparison that cleared them (line 7 vs line 10), not just be told there is no AMT.
1514    //
1515    // NOT gated on the screening worksheet: that worksheet has been off every production path since
1516    // v0.14.0, and this block's own condition is the honest description of who sees it. It is a wider
1517    // audience than the screen would flag — roughly MFJ above ~$133k of AMTI, Single above ~$85k —
1518    // which is deliberate: a cleared comparison is reassuring, not noisy, and the block is three
1519    // lines. (Whole-branch review flagged the old comment as describing a gate that was never here.)
1520    //
1521    // ★ Coverage note. J10's golden pins this block, and dropping it reds `examples_golden_matches_
1522    // committed`. It CANNOT discriminate line 9 from line 7, because every bundled journey has no
1523    // foreign tax credit, and line 9 = line 7 − line 8 collapses to line 7 when line 8 is zero
1524    // (verified: swapping `line9` for `line7` here leaves the golden green). That distinction is held
1525    // instead by the vector KAT — fixture V9 carries a $600 §904(j) credit, where line 7 = 2,244,338
1526    // and line 9 = 2,243,738 diverge, and `every_vector_reproduces_the_form_line_by_line` asserts each
1527    // line separately. Do not "simplify" this to line 7 on the strength of a green golden.
1528    if ar.amt.line6 > Usd::ZERO {
1529        let _ = writeln!(
1530            s,
1531            "  Alternative Minimum Tax (Form 6251 → Sch 2 L2): {}\n    \
1532             AMTI (L4) {} · exemption (L5) {} · tentative minimum tax (L9) {} · regular tax (L10) {}\n    \
1533             {}",
1534            fmt_money(ar.amt.line11),
1535            fmt_money(ar.amt.line4),
1536            fmt_money(ar.amt.line5),
1537            fmt_money(ar.amt.line9),
1538            fmt_money(ar.amt.line10),
1539            if ar.amt.must_attach() {
1540                "Form 6251 line 7 exceeds line 10 → the form MUST be attached (i6251, Who Must File, \
1541                 condition 1)."
1542            } else {
1543                "Line 7 does not exceed line 10 → no Form 6251 attachment is required."
1544            }
1545        );
1546    }
1547    let _ = writeln!(
1548        s,
1549        "  TOTAL TAX (L24):          {}{}",
1550        fmt_money(f.line24),
1551        pseudo.suffix()
1552    );
1553    let _ = writeln!(s, "  Total payments (L33):     {}", fmt_money(f.line33));
1554    if f.line34 > Usd::ZERO {
1555        let _ = writeln!(s, "  → REFUND (L35a):          {}", fmt_money(f.line34));
1556    } else {
1557        let _ = writeln!(s, "  → AMOUNT OWED (L37):      {}", fmt_money(f.line37));
1558    }
1559    // §6: the two figures answer different questions and are NEVER reconciled.
1560    let delta_str = match delta {
1561        btctax_core::TaxOutcome::Computed(r) => fmt_money(r.total_federal_tax_attributable),
1562        btctax_core::TaxOutcome::NotComputable(_) => "not computable".to_string(),
1563    };
1564    let _ = writeln!(
1565        s,
1566        "\n  ── Two DIFFERENT questions — NOT reconciled (SPEC §6) ──"
1567    );
1568    let _ = writeln!(
1569        s,
1570        "  • Absolute TOTAL TAX (this filed return, WITH crypto): {}{}",
1571        fmt_money(f.line24),
1572        pseudo.suffix()
1573    );
1574    let _ = writeln!(
1575        s,
1576        "  • Crypto-attributable tax (DELTA, shown above):        {delta_str}"
1577    );
1578    let _ = writeln!(
1579        s,
1580        "  The delta's implied deduction is fixed at derivation time (non-crypto AGI), so it is \
1581         APPROXIMATE where a\n  deduction is AGI-sensitive (e.g. the 7.5% medical floor); the two do NOT \
1582         reconcile to the dollar."
1583    );
1584    s
1585}
1586
1587/// P2-B Task 3: render the RAW pre-netting Schedule D part totals (Part I ST, Part II LT) for
1588/// `year`, mirroring `render_tax_outcome`. These are the Form 8949/Schedule D part totals BEFORE
1589/// §1222/§1211/§1212 netting + carryforward — that netting is applied in the tax computation
1590/// (`report --tax-year`), and the netted figures are shown by `render_tax_outcome` above.
1591///
1592/// When `outcome` is `Computed`, the standard netting note is shown. When `outcome` is
1593/// `NotComputable`, a caveat is printed instead: the raw totals are valid disposal sums but are
1594/// informational — no netting or carryforward is applied because the tax is not computable.
1595/// The raw totals are ALWAYS shown (never suppressed); only the trailing note differs.
1596pub fn render_schedule_d(
1597    year: i32,
1598    totals: &ScheduleDTotals,
1599    outcome: &btctax_core::TaxOutcome,
1600) -> String {
1601    let mut s = String::new();
1602    let _ = writeln!(
1603        s,
1604        "Schedule D (raw pre-netting part totals) — tax year {year}"
1605    );
1606    let _ = writeln!(
1607        s,
1608        "  Part I  (short-term): proceeds {}   cost basis {}   gain {}",
1609        fmt_money(totals.st.proceeds),
1610        fmt_money(totals.st.cost_basis),
1611        fmt_money(totals.st.gain)
1612    );
1613    let _ = writeln!(
1614        s,
1615        "  Part II (long-term):  proceeds {}   cost basis {}   gain {}",
1616        fmt_money(totals.lt.proceeds),
1617        fmt_money(totals.lt.cost_basis),
1618        fmt_money(totals.lt.gain)
1619    );
1620    match outcome {
1621        btctax_core::TaxOutcome::NotComputable(_) => {
1622            let _ = writeln!(
1623                s,
1624                "  (raw disposition totals shown above; the year's tax is NOT COMPUTABLE until \
1625                 the blocker is resolved — these Form 8949/Schedule D part totals are \
1626                 informational and are not netted/carried until the tax computes)."
1627            );
1628        }
1629        btctax_core::TaxOutcome::Computed(_) => {
1630            let _ = writeln!(
1631                s,
1632                "  Note: §1222/§1211/§1212 netting + carryforward are applied in the tax \
1633                 computation (report --tax-year); these are the raw pre-netting Form \
1634                 8949/Schedule D part totals."
1635            );
1636        }
1637    }
1638    s
1639}
1640
1641/// P2-D Task 2 / Chunk B (Schedule SE): render the standalone §1401 SE-tax block for `year` as an
1642/// informational block that does NOT feed engine B (`TaxResult::total_federal_tax_attributable` is
1643/// UNCHANGED by SE tax).
1644///
1645/// Three-way `None` split [R0-I1] (no silent drop — mirrors P2-C's m6):
1646/// - `gross_se == 0` → `None` (no business SE income → no Schedule SE section at all).
1647/// - `gross_se > 0 && !table_present` → a "SS wage base unavailable for {year}" note (business SE
1648///   income exists but the year has no bundled table → the wage base is unknown; the §1401 tax is
1649///   NOT computed rather than silently dropped).
1650/// - `gross_se > 0 && table_present && result == None` → a "fully expensed" line (expenses ≥ gross
1651///   → net_se == 0 → no §1401 SE tax owed; distinct from the "wage base unavailable" case).
1652/// - `result = Some(r)` → the full Schedule SE section (breakout or $0 note, components, total,
1653///   §164(f) advisory, W-2 coordination, the Chunk-B expense advisory, and the [D5] standalone note).
1654///
1655/// # Parameters
1656/// - `gross_se`: `se_net_income(state, year)` — the GROSS SE income before expenses (caller computes).
1657/// - `table_present`: `tables.table_for(year).is_some()` (caller has this from the `and_then` chain).
1658/// - `schedule_c_expenses`: from `TaxProfile.schedule_c_expenses` (≥ 0). When > 0 triggers the
1659///   breakout line and the Chunk-B ordinary-income advisory.
1660/// - `w2_ss_wages` / `w2_medicare_wages`: from `TaxProfile` (both ≥ 0). When either is > $0 the
1661///   W-2 coordinated disclosure is rendered; when both are $0 the short $0-assumed note is shown.
1662pub fn render_schedule_se(
1663    year: i32,
1664    result: Option<&SeTaxResult>,
1665    gross_se: Usd,
1666    table_present: bool,
1667    schedule_c_expenses: Usd,
1668    w2_ss_wages: Usd,
1669    w2_medicare_wages: Usd,
1670) -> Option<String> {
1671    match result {
1672        Some(r) => {
1673            let mut s = String::new();
1674            let _ = writeln!(
1675                s,
1676                "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1677            );
1678            // [Chunk B] Breakout line or $0 note depending on whether expenses were supplied.
1679            if schedule_c_expenses > Usd::ZERO {
1680                // The gross for display = net_se + expenses (since net_se = max(0, gross − expenses)
1681                // and net_se > 0 here, gross = net_se + expenses exactly).
1682                let gross_display = r.net_se + schedule_c_expenses;
1683                let _ = writeln!(
1684                    s,
1685                    "  gross business income {} \u{2212} Schedule C expenses {} = net SE earnings {}",
1686                    fmt_money(gross_display),
1687                    fmt_money(schedule_c_expenses),
1688                    fmt_money(r.net_se)
1689                );
1690                // [Chunk B / I3-mechanism] Ordinary-income advisory — correct mechanism; NO OTI-edit prescription.
1691                let _ = writeln!(
1692                    s,
1693                    "  (Schedule C advisory) Schedule C expenses also reduce your ORDINARY taxable \
1694                     income, but the income-tax total above uses GROSS crypto income \u{2014} to first \
1695                     order it OVERSTATES your tax by your marginal ordinary rate applied to {}. The tax \
1696                     profile cannot express this (an `ordinary_taxable_income` edit would shift both \
1697                     legs of the crypto-attributable delta); the engine-side coordination is deferred \
1698                     \u{2014} coordinate it on your actual return.",
1699                    fmt_money(schedule_c_expenses)
1700                );
1701            } else {
1702                let _ = writeln!(
1703                    s,
1704                    "  net self-employment income (business crypto, Interest excluded): {}",
1705                    fmt_money(r.net_se)
1706                );
1707                let _ = writeln!(
1708                    s,
1709                    "  (Schedule C) no Schedule C expenses supplied (--schedule-c-expenses)"
1710                );
1711            }
1712            let _ = writeln!(
1713                s,
1714                "  \u{00d7} 92.35% net-earnings factor (\u{00a7}1402(a)) = net SE earnings: {}",
1715                fmt_money(r.base)
1716            );
1717            let _ = writeln!(
1718                s,
1719                "  Social Security component (12.4%, §1401(a); capped at the SS wage base): {}",
1720                fmt_money(r.ss)
1721            );
1722            let _ = writeln!(
1723                s,
1724                "  Medicare component (2.9%, §1401(b); uncapped): {}",
1725                fmt_money(r.medicare)
1726            );
1727            let _ = writeln!(
1728                s,
1729                "  Additional Medicare component (0.9%, §1401(b)(2)): {}",
1730                fmt_money(r.addl)
1731            );
1732            let _ = writeln!(
1733                s,
1734                "  TOTAL self-employment tax (§1401): {}",
1735                fmt_money(r.total)
1736            );
1737            let _ = writeln!(
1738                s,
1739                "  §164(f) one-half-SE-tax deduction (above-the-line; EXCLUDES Additional Medicare per \
1740                 §164(f)(1)): {}",
1741                fmt_money(r.deductible_half)
1742            );
1743            // [Chunk A / R0-I3] §164(f) advisory — quantified first-order overstatement; NO prescription
1744            // to edit ordinary_taxable_income (wrong mechanism — see spec D3/R0-I3 rationale).
1745            let _ = writeln!(
1746                s,
1747                "  (§164(f) advisory) The §164(f) deduction ({}) is NOT auto-coordinated into the \
1748                 income-tax total above — to first order, that total overstates your combined tax by \
1749                 your marginal ordinary rate applied to {}. The tax profile cannot express this deduction \
1750                 directly (reducing `ordinary_taxable_income` would shift BOTH legs of the \
1751                 crypto-attributable delta and only correct the bracket differential, not the level) — \
1752                 coordinate it on your actual return.",
1753                fmt_money(r.deductible_half),
1754                fmt_money(r.deductible_half)
1755            );
1756            // [Chunk A / D3] W-2 coordination disclosure — accurate when W-2 values are set;
1757            // short $0-assumed note otherwise. REMOVES the old OVERSTATED/UNDERSTATED hedging.
1758            if w2_ss_wages > Usd::ZERO || w2_medicare_wages > Usd::ZERO {
1759                let _ = writeln!(
1760                    s,
1761                    "  (W-2 coordination applied) SS cap = max(0, wage base \u{2212} {}) (Box 3+7); \
1762                     Additional-Medicare threshold reduced (not below 0) by {} (Box 5, \
1763                     §1401(b)(2)(B)/Form 8959 Part II).",
1764                    fmt_money(w2_ss_wages),
1765                    fmt_money(w2_medicare_wages)
1766                );
1767            } else {
1768                let _ = writeln!(
1769                    s,
1770                    "  (W-2) assumes $0 W-2 wages (set --w2-ss-wages/--w2-medicare-wages on the tax \
1771                     profile if you had a wage job)."
1772                );
1773            }
1774            // [burndown-3 D2] §6017 $400 filing floor — the test is on the ×0.9235 base (§1402(a),
1775            // which includes the §1402(a)(12) 7.65% reduction), NOT the pre-factor net_se.
1776            if r.base < rust_decimal::Decimal::from(400) {
1777                let _ = writeln!(
1778                    s,
1779                    "  (§6017 filing floor) Net earnings from self-employment ({base}) are below $400: \
1780                     a Schedule SE filing is required on account of this income only when net earnings \
1781                     from self-employment (the ×92.35% base, §1402(a)) are $400 or more (§6017), and \
1782                     below that floor no §1401 SE tax is imposed (§1402(b)(2); church employee income \
1783                     excepted — §1402(j)(2), not modeled) — the figures above are shown for \
1784                     transparency (other self-employment activities, if any, combine on your actual \
1785                     Schedule SE).",
1786                    base = fmt_money(r.base)
1787                );
1788            }
1789            // [D5] standalone note — SE tax is a SEPARATE liability, not in the income-tax + NIIT total.
1790            let _ = writeln!(
1791                s,
1792                "  (standalone) This §1401 SE tax is a SEPARATE federal liability, NOT included in the \
1793                 income-tax + NIIT total above; the §164(f) one-half-SE-tax deduction is not \
1794                 auto-coordinated into that total."
1795            );
1796            Some(s)
1797        }
1798        None => {
1799            if gross_se.is_zero() {
1800                None // no business SE income → no Schedule SE section
1801            } else if !table_present {
1802                // Business SE income present but no bundled table → wage base unknown; do NOT drop.
1803                let mut s = String::new();
1804                let _ = writeln!(
1805                    s,
1806                    "Schedule SE (§1401 self-employment tax) — tax year {year}"
1807                );
1808                let _ = writeln!(
1809                    s,
1810                    "  SS wage base unavailable for {year}: business self-employment income is present \
1811                     but no bundled tax table (ss_wage_base) exists for {year}; the §1401 SE tax was \
1812                     NOT computed (no silent drop)."
1813                );
1814                Some(s)
1815            } else {
1816                // Business SE income present + table available + net_se == 0: fully expensed.
1817                // [R0-I1] The liability status is "no tax owed", NOT "couldn't compute".
1818                let mut s = String::new();
1819                let _ = writeln!(
1820                    s,
1821                    "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1822                );
1823                let _ = writeln!(
1824                    s,
1825                    "  fully expensed: gross {} \u{2212} Schedule C expenses {} \u{2264} $0 \
1826                     \u{2192} no \u{00a7}1401 SE tax for {year}.",
1827                    fmt_money(gross_se),
1828                    fmt_money(schedule_c_expenses)
1829                );
1830                Some(s)
1831            }
1832        }
1833    }
1834}
1835
1836/// P2-C Task 3 (D3): Form 709 gift **per-donee advisory** (§2503(b) annual exclusion applied
1837/// independently per donee, not in aggregate).
1838///
1839/// Groups `Removal{Gift}` legs by their `donee` label for `year`:
1840/// - **`None`** when there are NO Gift removals in the year (even with a table present). [R0-I2]
1841/// - **`Some(note)` [R0-m6]** when gifts ARE present but the year has NO bundled table (exclusion
1842///   unavailable): Form 709 exposure is NOT evaluated — do NOT silently return `None`.
1843/// - **`Some(advisory)`** for all other cases: per-labeled-donee §2503(b) breakdown (each donee's
1844///   total vs the per-donee exclusion; filing trigger fires when ANY labeled donee exceeds the
1845///   exclusion), plus an unlabeled-bucket caveat when any `None`-donee gifts exist.
1846///
1847/// **Why per-donee matters (§2503(b)):** the exclusion applies to each recipient independently.
1848/// Two donees at $15k each with a $19k exclusion → $0 taxable (the old aggregate was WRONG: it
1849/// would flag the $30k combined total, even though neither donee exceeded their exclusion).
1850///
1851/// **Unlabeled bucket:** `None`-donee gifts cannot have per-donee exclusion applied; a conservative
1852/// aggregate-vs-one-exclusion signal is emitted with an explicit caveat. Nothing is silently dropped.
1853///
1854/// **Donations excluded:** `Removal{Donation}` (§170) must NOT appear here — this advisory is for
1855/// §2503(b) Gifts only. `kind == Gift` filter enforces this.
1856///
1857/// Standalone informational artifact — does NOT feed `compute_tax_year` / engine B.
1858pub fn render_gift_advisory(
1859    state: &LedgerState,
1860    year: i32,
1861    prior_taxable_gifts: btctax_core::conventions::Usd,
1862    tables: &impl btctax_core::TaxTables,
1863) -> Option<String> {
1864    use std::collections::BTreeMap;
1865
1866    // [R0-I2] Preserve safety: any_gift guard — Donation removals do NOT count here.
1867    let any_gift = state
1868        .removals
1869        .iter()
1870        .any(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year);
1871    if !any_gift {
1872        return None; // (a) no Gift removals in the year
1873    }
1874
1875    // [R0-m6] gifts present but no bundled table → emit the note, never None.
1876    let t = match tables.table_for(year) {
1877        None => {
1878            let total: btctax_core::conventions::Usd = state
1879                .removals
1880                .iter()
1881                .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1882                .flat_map(|r| r.legs.iter())
1883                .map(|leg| leg.fmv_at_transfer)
1884                .sum();
1885            return Some(format!(
1886                "Form 709 gift advisory ({year}): gift annual-exclusion table unavailable for \
1887                 {year}; Form 709 exposure not evaluated — ${} in gifts recorded.",
1888                fmt_money(total)
1889            ));
1890        }
1891        Some(t) => t,
1892    };
1893    let excl = t.gift_annual_exclusion;
1894
1895    // Group Gift removals by donee label (BTreeMap → deterministic order; None → unlabeled bucket).
1896    let mut labeled: BTreeMap<String, btctax_core::conventions::Usd> = BTreeMap::new();
1897    let mut unlabeled_count: usize = 0;
1898    let mut unlabeled_total: btctax_core::conventions::Usd = Default::default();
1899
1900    for r in state
1901        .removals
1902        .iter()
1903        .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1904    {
1905        let fmv: btctax_core::conventions::Usd = r.legs.iter().map(|l| l.fmv_at_transfer).sum();
1906        match &r.donee {
1907            Some(label) => {
1908                *labeled.entry(label.clone()).or_default() += fmv;
1909            }
1910            None => {
1911                unlabeled_count += 1;
1912                unlabeled_total += fmv;
1913            }
1914        }
1915    }
1916
1917    // Per-donee §2503(b) analysis.
1918    let mut filing_required_donees: Vec<String> = Vec::new();
1919    let mut total_taxable: btctax_core::conventions::Usd = Default::default();
1920    let mut s = format!("Form 709 gift advisory ({year}):");
1921
1922    if !labeled.is_empty() {
1923        s.push_str(&format!(
1924            "\n§2503(b) per-donee annual exclusion analysis (TY{year}, exclusion ${}):",
1925            fmt_money(excl)
1926        ));
1927        for (donee, &total) in &labeled {
1928            let applied = if total < excl { total } else { excl };
1929            let taxable: btctax_core::conventions::Usd = if total > excl {
1930                total - excl
1931            } else {
1932                Default::default()
1933            };
1934            s.push_str(&format!(
1935                "\n  {donee}: total ${}, exclusion applied ${}, taxable ${}",
1936                fmt_money(total),
1937                fmt_money(applied),
1938                fmt_money(taxable)
1939            ));
1940            if total > excl {
1941                filing_required_donees.push(donee.clone());
1942                total_taxable += taxable;
1943            }
1944        }
1945        if !filing_required_donees.is_empty() {
1946            s.push_str(&format!(
1947                "\nForm 709 filing required (donee(s): {}). Total taxable gifts: ${}.",
1948                filing_required_donees.join(", "),
1949                fmt_money(total_taxable)
1950            ));
1951        } else {
1952            s.push_str(&format!(
1953                "\nNo Form 709 filing required based on per-donee totals \
1954                 (each ≤ ${} exclusion). Total taxable gifts: $0.00.",
1955                fmt_money(excl)
1956            ));
1957        }
1958    }
1959
1960    if unlabeled_count > 0 {
1961        s.push_str(&format!(
1962            "\nNOTE: {unlabeled_count} gift(s) totalling ${} have no donee label — the §2503(b) \
1963             annual exclusion is PER DONEE and cannot be applied without one; label them via \
1964             `reconcile reclassify-outflow --donee`. Shown as a single conservative aggregate.",
1965            fmt_money(unlabeled_total)
1966        ));
1967        // Conservative aggregate signal (old per-bucket logic): keep the signal so nothing is
1968        // silently dropped; mark it explicitly as a conservative estimate (may span multiple donees).
1969        if unlabeled_total > excl {
1970            s.push_str(&format!(
1971                "\n  Conservative aggregate ${} > ${} (one exclusion); \
1972                 verify per-donee totals after labelling.",
1973                fmt_money(unlabeled_total),
1974                fmt_money(excl)
1975            ));
1976        } else {
1977            s.push_str(&format!(
1978                "\n  Conservative aggregate ${} \u{2264} ${} (one exclusion); \
1979                 per-donee exposure unverifiable without labels.",
1980                fmt_money(unlabeled_total),
1981                fmt_money(excl)
1982            ));
1983        }
1984    }
1985
1986    // [D3 / R0-I1] §2505 lifetime (basic) exclusion consumption block.
1987    // current_year_taxable = total_taxable (Σ LABELED-donee taxable, per Chunk-2 design).
1988    // unlabeled gifts are excluded from this figure (their per-donee taxable is unknown).
1989    let lifetime_excl = t.gift_lifetime_exclusion;
1990    let cumulative_taxable = prior_taxable_gifts + total_taxable;
1991
1992    // Emit block only when cumulative > 0 (covers prior-only [M4] and over-annual cases).
1993    if cumulative_taxable > btctax_core::conventions::Usd::ZERO {
1994        let remaining = if cumulative_taxable >= lifetime_excl {
1995            btctax_core::conventions::Usd::ZERO
1996        } else {
1997            lifetime_excl - cumulative_taxable
1998        };
1999        s.push_str(&format!(
2000            "\n§2505 lifetime (basic) exclusion: you have used ${} of your ${} ({year}) lifetime \
2001             exclusion (${} remaining). No gift tax is DUE until cumulative taxable gifts exceed \
2002             the lifetime exclusion.",
2003            fmt_money(cumulative_taxable),
2004            fmt_money(lifetime_excl),
2005            fmt_money(remaining),
2006        ));
2007        // Strict `>`: at exactly the exclusion → remaining $0, NOT exceeded.
2008        if cumulative_taxable > lifetime_excl {
2009            let excess = cumulative_taxable - lifetime_excl;
2010            s.push_str(&format!(
2011                "\nlifetime exclusion EXCEEDED — gift tax may be due on ${} \
2012                 (the excess base past the unified credit, not a computed tax); \
2013                 consult a professional.",
2014                fmt_money(excess),
2015            ));
2016        }
2017        // [R0-I2] Unlabeled-omission disclosure: when unlabeled gifts exist, §2505 consumption
2018        // is understated (unlabeled gifts could have taxable amounts not reflected here).
2019        if unlabeled_count > 0 {
2020            s.push_str(&format!(
2021                "\n§2505 consumption reflects LABELED-donee taxable gifts only; \
2022                 {unlabeled_count} unlabeled gift(s) totalling ${} are NOT included — \
2023                 label them via `--donee` for a complete figure; consumption may be \
2024                 understated / remaining overstated.",
2025                fmt_money(unlabeled_total),
2026            ));
2027        }
2028    }
2029
2030    // [R0-I1] Updated caveats: stale "§2505 … later chunk (Chunk 3)" removed; §2513 and
2031    // future-interest caveats preserved; §2505-specific caveats added.
2032    s.push_str(
2033        "\nCaveats: §2513 gift-splitting (MFJ) not modeled (single-filer advisory only); \
2034         future-interest gifts (which require Form 709 filing regardless of amount) not \
2035         detectable; §2505 figures are advisory only — no portability/DSUE (§2010(c)(4)) \
2036         applied; prior cumulative taxable gifts are user-supplied (default $0 if \
2037         --prior-taxable-gifts not given).",
2038    );
2039
2040    Some(s)
2041}
2042
2043// ── Sub-project C: optimize run ─────────────────────────────────────────────────────────────────
2044
2045/// Format a lot-pick slice as comma-separated `"<event>#<split>:<sat>"` entries for proposal display.
2046/// Mirrors the grammar `eventref::parse_lot_pick` accepts, so picks are both human-readable and
2047/// round-trip-parseable. An empty pick list renders as `"(none)"`.
2048fn picks_str(picks: &[btctax_core::LotPick]) -> String {
2049    if picks.is_empty() {
2050        return "(none)".to_string();
2051    }
2052    picks
2053        .iter()
2054        .map(|p| {
2055            format!(
2056                "{}#{}:{}",
2057                p.lot.origin_event_id.canonical(),
2058                p.lot.split_sequence,
2059                p.sat
2060            )
2061        })
2062        .collect::<Vec<_>>()
2063        .join(", ")
2064}
2065
2066/// Render a `OptimizeProposal` (Mode-1 what-if) for the `optimize run` command. Returns a String
2067/// containing the proposal header, any approximate banner, the aggregate tax delta, per-disposal
2068/// rows (with proposed selection + compliance status + persistability), and the R0-M2 caveat footer.
2069///
2070/// **Approximate banner (R0-C1/C3/R2-C1):** when `p.approximate == true`, a ⚠ APPROXIMATE banner
2071/// and the specific `approx_reason` are printed. When `false`, no banner is printed (proven global
2072/// minimum — do NOT add a banner for this case).
2073///
2074/// **R2-M1 no-change rows:** a disposal whose `proposed_selection == current_selection` has nothing
2075/// to attest or persist (the optimizer is NOT asking to change it). The persistability line is
2076/// suppressed and a "no change — already optimal" note is shown instead, preventing a misleading
2077/// "needs --attest" prompt on a row the user does not need to act on.
2078pub fn render_optimize_proposal(p: &btctax_core::OptimizeProposal) -> String {
2079    use btctax_core::{ApproxReason, Persistability};
2080    let mut s = String::new();
2081    let _ = writeln!(
2082        s,
2083        "Optimize (what-if) — tax year {} — NOTHING is filed or bound by running this.",
2084        p.year
2085    );
2086    // R0-C1/C3: a non-fully-enumerated result is NEVER presented as "the optimum" without this banner.
2087    if p.approximate {
2088        let why = match p.approx_reason {
2089            Some(ApproxReason::ComboCapExceeded { combos, cap }) => format!(
2090                "input exceeded the exhaustive bound ({combos} combos > {cap}); \
2091                 a coordinate-descent fallback ran"
2092            ),
2093            Some(ApproxReason::ContentionUnenumerated { contended, .. }) => format!(
2094                "{contended} contended same-wallet disposal(s) could not be fully joint-enumerated"
2095            ),
2096            Some(ApproxReason::PoolHeuristic { lots, bound }) => format!(
2097                "a pool of {lots} lots exceeds the {bound}-lot exhaustive-enumeration bound; \
2098                 only a deterministic heuristic SUBSET of that pool's identifications was searched"
2099            ),
2100            None => "approximate".to_string(),
2101        };
2102        let _ = writeln!(
2103            s,
2104            "  \u{26a0} APPROXIMATE \u{2014} NOT a guaranteed global minimum: {why}."
2105        );
2106        let _ = writeln!(
2107            s,
2108            "    The true least-tax assignment may be lower; this is a disclosed improvement over your"
2109        );
2110        let _ = writeln!(
2111            s,
2112            "    current filing position (delta \u{2264} 0), NOT \u{2018}the least tax.\u{2019}"
2113        );
2114    }
2115    let _ = writeln!(
2116        s,
2117        "  current federal tax (attributable): {}",
2118        p.baseline_tax
2119    );
2120    let _ = writeln!(
2121        s,
2122        "  optimized federal tax (attributable): {}",
2123        p.optimized_tax
2124    );
2125    let _ = writeln!(
2126        s,
2127        "  delta (optimized \u{2212} current): {}  (negative = saving; always \u{2264} 0)",
2128        p.delta
2129    );
2130    for d in &p.per_disposal {
2131        let _ = writeln!(
2132            s,
2133            "  {} @ {} [{}] :: {}",
2134            d.disposal.canonical(),
2135            d.date,
2136            wallet_label(&d.wallet),
2137            compliance_status_tag(&d.status)
2138        );
2139        // R2-M1: a NO-CHANGE row (proposed == current) has nothing to attest/persist — `accept` SKIPS it
2140        // ("already optimal under current identification"). Do NOT print a persistability line here: a
2141        // `NeedsAttestation` "needs --attest" line on a disposal the optimizer is NOT asking to change is
2142        // misleading and invites a pointless/contradictory attestation. Show a no-change note instead.
2143        if d.proposed_selection == d.current_selection {
2144            let _ = writeln!(
2145                s,
2146                "      proposed: {}  [no change \u{2014} already optimal under current identification]",
2147                picks_str(&d.proposed_selection)
2148            );
2149            continue;
2150        }
2151        let persist = match d.persistable {
2152            Persistability::ContemporaneousNow => {
2153                "persistable now (made \u{2264} sale \u{2192} Contemporaneous)"
2154            }
2155            Persistability::NeedsAttestation => {
2156                "already executed \u{2014} needs `optimize accept --disposal <ref> \
2157                 --attest \"\u{2026}\"` (genuine contemporaneous ID only)"
2158            }
2159            Persistability::ForbiddenBroker2027 => {
2160                "2027+ broker-held \u{2014} CANNOT be persisted (own-books insufficient); \
2161                 FIFO is the defensible position"
2162            }
2163        };
2164        let _ = writeln!(
2165            s,
2166            "      proposed: {}  [{}]",
2167            picks_str(&d.proposed_selection),
2168            persist
2169        );
2170    }
2171    // R0-M2: surface the vertex-granularity limitation in OUTPUT, not only in docs.
2172    let _ = writeln!(
2173        s,
2174        "  (vertex-granularity identification: a multi-partial split landing exactly on a \
2175         tax-bracket kink is out of scope.)"
2176    );
2177    let _ = writeln!(
2178        s,
2179        "  (this is the tax IF you had identified thus; adequate ID must exist by the time \
2180         of sale \u{2014} \u{a7}1.1012-1(j))"
2181    );
2182    // C-M3: document the optimizer scope boundary (mirrors R0-M2 vertex-granularity caveat).
2183    let _ = writeln!(
2184        s,
2185        "  (scope: global over taxable-disposal lot selections; self-transfer lot routing is \
2186         held at its baseline position and is not re-optimized.)"
2187    );
2188    s
2189}
2190
2191/// Render an `AcceptOutcome` (Task 10 `optimize accept`): one line per persisted `LotSelection`
2192/// (with the appended decision id to pass to `reconcile void` for revocation, and the §A.5 basis
2193/// label) and one line per skipped disposal (with the gate reason). A persisted attestation is noted
2194/// inline on the `AttestedRecording` rows.
2195pub fn render_accept_outcome(o: &crate::cmd::optimize::AcceptOutcome) -> String {
2196    let mut s = String::new();
2197    let _ = writeln!(
2198        s,
2199        "Optimize accept \u{2014} {} persisted, {} skipped.",
2200        o.persisted.len(),
2201        o.skipped.len()
2202    );
2203    for (disposal, decision, basis) in &o.persisted {
2204        let _ = writeln!(
2205            s,
2206            "  PERSISTED {} \u{2192} LotSelection {} [{}]{}",
2207            disposal.canonical(),
2208            decision.canonical(),
2209            basis,
2210            if *basis == "AttestedRecording" {
2211                " (+ attestation recorded; revoke with `reconcile void`)"
2212            } else {
2213                " (revoke with `reconcile void`)"
2214            }
2215        );
2216    }
2217    for (disposal, reason) in &o.skipped {
2218        let _ = writeln!(s, "  skipped {}: {}", disposal.canonical(), reason);
2219    }
2220    if o.persisted.is_empty() && o.skipped.is_empty() {
2221        let _ = writeln!(s, "  (no disposals matched)");
2222    }
2223    s
2224}
2225
2226/// Render a `ConsultReport` (Task 11 / §C.3 Mode-2 read-only pre-trade what-if) for the
2227/// `optimize consult` command. Returns a String with:
2228///   - The hypothetical sale header (sat amount, wallet, date).
2229///   - The proposed lot selection (the tax-minimizing picks).
2230///   - The ST/LT gain split and the federal tax attributable to this contemplated sale.
2231///   - When `timing.is_some()`: the ST→LT crossover line (crossover date + saving), OMITTED when None.
2232///   - A footer: tax decision-support only, not investment advice.
2233///
2234/// **READ-ONLY:** this function only renders; it never writes any event or side-table row.
2235pub fn render_consult(r: &btctax_core::ConsultReport) -> String {
2236    let mut s = String::new();
2237    let _ = writeln!(
2238        s,
2239        "Consult (read-only what-if): sell {} sat from {} on {}",
2240        r.req.sell_sat,
2241        wallet_label(&r.req.wallet),
2242        r.req.at
2243    );
2244    // C-M2: for large pools (>12 lots) the candidate set is a heuristic subset — disclose it.
2245    if r.approximate {
2246        let _ = writeln!(
2247            s,
2248            "  \u{26a0} heuristic \u{2014} searched a subset of a large (>12-lot) pool; \
2249             the proposed selection may not be the exact minimum."
2250        );
2251    }
2252    let _ = writeln!(
2253        s,
2254        "  proposed selection: {}",
2255        picks_str(&r.proposed_selection)
2256    );
2257    let _ = writeln!(
2258        s,
2259        "  short-term gain: {}   long-term gain: {}",
2260        r.st_gain, r.lt_gain
2261    );
2262    // [consult fix] Headline the sale's OWN marginal effect (withhyp − baseline); keep the whole-year
2263    // figure clearly relabeled below it (on a year with real disposals the two DIFFER).
2264    let _ = writeln!(s, "  marginal federal tax (this sale): {}", r.marginal_tax);
2265    let _ = writeln!(
2266        s,
2267        "  whole-year federal tax attributable (with this sale): {}",
2268        r.total_federal_tax_attributable
2269    );
2270    if let Some(t) = &r.timing {
2271        let _ = writeln!(
2272            s,
2273            "  timing: {} sat of the best selection is short-term until {}; \
2274             selling on/after then would be taxed long-term, a \u{2248} {} difference.",
2275            t.st_sat_in_selection, t.latest_crossover, t.saving_if_waited
2276        );
2277    }
2278    let _ = writeln!(
2279        s,
2280        "Tax decision-support only \u{2014} consequences of a contemplated sale; \
2281         not investment advice (no buy/sell/hold recommendation)."
2282    );
2283    s
2284}
2285
2286/// The §1(h) 0/15/20 rate zone label for the sale's preferential dollars.
2287fn ltcg_bracket_label(b: LtcgBracket) -> &'static str {
2288    match b {
2289        LtcgBracket::Zero => "0%",
2290        LtcgBracket::Fifteen => "15%",
2291        LtcgBracket::Twenty => "20%",
2292    }
2293}
2294
2295/// Render a `what-if sell` `SellReport` (task #43). Headlines the MARGINAL federal tax (the sale's OWN
2296/// effect — `withhyp − baseline`), then the §1(h) bracket + room, the effective rate (or n/a for a loss),
2297/// the §1212 carryforward disclosure (delta-based — the this-year ordinary offset AND the amount carried,
2298/// NEVER a hard-coded $3,000), and the §1411 NIIT delta with its sign. `magi_caveat` prints the
2299/// ad-hoc-profile "MAGI assumed = ordinary income" note. Read-only; the vault is never touched.
2300pub fn render_whatif_sell(r: &SellReport, magi_caveat: bool) -> String {
2301    let mut s = String::new();
2302    let _ = writeln!(
2303        s,
2304        "What-if (read-only): sell {} sat from {} on {}",
2305        r.req.sell_sat,
2306        wallet_label(&r.req.wallet),
2307        r.req.at
2308    );
2309    if magi_caveat {
2310        let _ = writeln!(
2311            s,
2312            "  \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
2313        );
2314    }
2315    let _ = writeln!(s, "  proceeds: {}", r.proceeds);
2316    let _ = writeln!(s, "  lots consumed:");
2317    for leg in &r.lots {
2318        let term = match leg.term {
2319            Term::ShortTerm => "ST",
2320            Term::LongTerm => "LT",
2321        };
2322        let _ = writeln!(
2323            s,
2324            "    {}#{}  {} sat  basis {}  {} \u{2192} {}  {}  gain {}",
2325            leg.lot_id.origin_event_id.canonical(),
2326            leg.lot_id.split_sequence,
2327            leg.sat,
2328            leg.basis,
2329            leg.acquired_at,
2330            leg.sold_at,
2331            term,
2332            leg.gain
2333        );
2334    }
2335    let _ = writeln!(
2336        s,
2337        "  short-term gain: {}   long-term gain: {}",
2338        r.st_gain, r.lt_gain
2339    );
2340    // §1(h) bracket + headroom to the next breakpoint.
2341    match r.bracket_room {
2342        Some(room) => {
2343            let _ = writeln!(
2344                s,
2345                "  \u{00a7}1(h) LTCG bracket: {} (room {} before the next breakpoint)",
2346                ltcg_bracket_label(r.bracket),
2347                room
2348            );
2349        }
2350        None => {
2351            let _ = writeln!(
2352                s,
2353                "  \u{00a7}1(h) LTCG bracket: {} (top bracket \u{2014} no headroom)",
2354                ltcg_bracket_label(r.bracket)
2355            );
2356        }
2357    }
2358    // The headline: the sale's OWN marginal federal tax.
2359    let _ = writeln!(s, "  marginal federal tax (this sale): {}", r.marginal_tax);
2360    match r.effective_rate {
2361        Some(rate) => {
2362            let _ = writeln!(s, "  effective rate: {rate}");
2363        }
2364        None => {
2365            let _ = writeln!(
2366                s,
2367                "  effective rate: n/a (a loss/zero-gain sale \u{2014} its value is the carryforward, \
2368                 not this-year tax)"
2369            );
2370        }
2371    }
2372    // §1212 disclosure — delta-based, NEVER a hard-coded $3,000. Shown whenever a loss is carried OR the
2373    // sale unlocks a this-year ordinary offset.
2374    let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2375    if carried != Usd::ZERO || r.ordinary_offset_delta != Usd::ZERO {
2376        let _ = writeln!(
2377            s,
2378            "  \u{00a7}1212: {} offsets ordinary income this year, {} carried to next year \
2379             (short {} / long {})",
2380            r.ordinary_offset_delta, carried, r.carryforward_delta.short, r.carryforward_delta.long
2381        );
2382    }
2383    // §1411 NIIT delta (with its sign) — only when the sale actually moved NIIT.
2384    if r.niit_applies {
2385        let dir = if r.niit_incremental < Usd::ZERO {
2386            "decrease"
2387        } else {
2388            "increase"
2389        };
2390        let _ = writeln!(
2391            s,
2392            "  \u{00a7}1411 NIIT: {} ({dir}) attributable to this sale",
2393            r.niit_incremental
2394        );
2395    }
2396    let status = match r.status {
2397        SellStatus::Gain => "net gain",
2398        SellStatus::Loss => "net loss (the carryforward is the value \u{2014} not this-year tax)",
2399    };
2400    let _ = writeln!(s, "  status: {status}");
2401    let _ = writeln!(
2402        s,
2403        "Tax decision-support only \u{2014} consequences of a contemplated sale; \
2404         not investment advice (no buy/sell/hold recommendation)."
2405    );
2406    s
2407}
2408
2409/// A human label for a harvest target.
2410fn harvest_target_label(t: &HarvestTarget) -> String {
2411    match t {
2412        HarvestTarget::ZeroLtcg => {
2413            "zero-ltcg (all gain in the \u{00a7}1(h) 0% bracket)".to_string()
2414        }
2415        HarvestTarget::FifteenLtcg => "fifteen-ltcg (stay at/under 15%)".to_string(),
2416        HarvestTarget::Gain(x) => format!("gain \u{2264} {x}"),
2417        HarvestTarget::Tax(x) => format!("marginal tax \u{2264} {x}"),
2418    }
2419}
2420
2421/// Render a `what-if harvest` `HarvestReport` (task #43). Headlines the MAX BTC to sell (N*), the binding
2422/// constraint, the realized ST/LT split at N*, which §1(h) bracket the surviving preferential dollars
2423/// land in, the exact marginal federal tax, and the MANDATORY disclosures — the §1212(b) carryforward
2424/// delta/burn, the §1411 NIIT kink (a 0%/15% answer can still cost +3.8%), and the plateau note. The
2425/// answer is engine-verified. `magi_caveat` prints the ad-hoc "MAGI assumed = ordinary income" note.
2426pub fn render_whatif_harvest(r: &HarvestReport, magi_caveat: bool) -> String {
2427    let mut s = String::new();
2428    let _ = writeln!(
2429        s,
2430        "What-if HARVEST (read-only): {} from {} on {}",
2431        harvest_target_label(&r.req.target),
2432        wallet_label(&r.req.wallet),
2433        r.req.at
2434    );
2435    if magi_caveat {
2436        let _ = writeln!(
2437            s,
2438            "  \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
2439        );
2440    }
2441    let status = match &r.status {
2442        HarvestStatus::Found => "FOUND (the target binds)",
2443        HarvestStatus::NotBinding => "NOT BINDING (the whole position fits)",
2444        HarvestStatus::AlreadyBreached => "ALREADY BREACHED at N=0 (nothing can be harvested)",
2445        HarvestStatus::NoLots => "NO LOTS (nothing to harvest from that wallet)",
2446        HarvestStatus::ProceedsRequired => "PROCEEDS REQUIRED",
2447        HarvestStatus::PreTransitionYear => "PRE-2025 (a closed year, not a plan)",
2448        HarvestStatus::YearNotComputable(_) => "YEAR NOT COMPUTABLE",
2449    };
2450    let _ = writeln!(s, "  status: {status}");
2451    let _ = writeln!(s, "  \u{2192} sell up to {} BTC ({} sat)", r.n_btc, r.n_sat);
2452    let _ = writeln!(s, "  bound by: {}", r.binding_constraint);
2453    let _ = writeln!(
2454        s,
2455        "  realized gain at N*: short-term {}   long-term {}",
2456        r.st_gain, r.lt_gain
2457    );
2458    // §1(h) bracket of the surviving preferential dollars at N*.
2459    let ps = &r.with_result.pref_split;
2460    let bracket = if ps.at_20 > Usd::ZERO {
2461        "20%"
2462    } else if ps.at_15 > Usd::ZERO {
2463        "15%"
2464    } else {
2465        "0%"
2466    };
2467    let _ = writeln!(
2468        s,
2469        "  \u{00a7}1(h) preferential dollars at N*: {} in 0% / {} in 15% / {} in 20% (top bracket: {})",
2470        ps.at_0, ps.at_15, ps.at_20, bracket
2471    );
2472    let _ = writeln!(s, "  marginal federal tax at N*: {}", r.marginal_tax);
2473    // §1212 carryforward delta (burn = a gain absorbing a carried loss).
2474    let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2475    if carried != Usd::ZERO {
2476        let dir = if carried < Usd::ZERO {
2477            "burned (spent)"
2478        } else {
2479            "carried to next year"
2480        };
2481        let _ = writeln!(
2482            s,
2483            "  \u{00a7}1212 carryforward {}: {} (short {} / long {})",
2484            dir, carried, r.carryforward_delta.short, r.carryforward_delta.long
2485        );
2486    }
2487    // §1411 NIIT kink — surfaced on bracket targets too (a 0%/15% answer can still cost +3.8%).
2488    if r.niit_applies {
2489        let dir = if r.niit_incremental < Usd::ZERO {
2490            "decrease"
2491        } else {
2492            "increase"
2493        };
2494        let _ = writeln!(
2495            s,
2496            "  \u{00a7}1411 NIIT: {} ({dir}) at N* \u{2014} the +3.8% kink applies even inside a 0%/15% bracket answer",
2497            r.niit_incremental
2498        );
2499    }
2500    if let Some(note) = &r.plateau_note {
2501        let _ = writeln!(s, "  \u{2139} {note}");
2502    }
2503    let _ = writeln!(
2504        s,
2505        "Tax decision-support only \u{2014} the engine-verified consequences of a contemplated harvest; \
2506         not investment advice (no buy/sell/hold recommendation)."
2507    );
2508    s
2509}
2510
2511pub fn render_verify(r: &VerifyReport) -> String {
2512    let mut out = String::new();
2513    let c = &r.conservation;
2514    let _ = writeln!(
2515        out,
2516        "Conservation (FR9): {}",
2517        if c.balanced { "BALANCED" } else { "DRIFT" }
2518    );
2519    let _ = writeln!(
2520        out,
2521        "  in {} = disposed {} + removed {} + held {} + fee-sats {} + pending {}{}",
2522        c.sigma_in,
2523        c.sigma_disposed,
2524        c.sigma_removed,
2525        c.sigma_held,
2526        c.sigma_fee_sats,
2527        c.sigma_pending,
2528        if c.has_uncovered {
2529            "  [identity undefined: uncovered disposal open]"
2530        } else {
2531            ""
2532        }
2533    );
2534    let _ = writeln!(out, "2025 transition: {}", r.safe_harbor);
2535    let _ = writeln!(
2536        out,
2537        "Pending reconciliation: {} transfer(s); unknown-basis inbounds: {}",
2538        r.pending, r.unknown_basis_inbounds
2539    );
2540
2541    let _ = writeln!(
2542        out,
2543        "Hard blockers (gate tax computation): {}",
2544        r.hard.len()
2545    );
2546    for b in &r.hard {
2547        let evt = b
2548            .event
2549            .as_ref()
2550            .map(|e| e.canonical())
2551            .unwrap_or_else(|| "-".to_string());
2552        let _ = writeln!(out, "  [{:?}] {} :: {}", b.kind, evt, b.detail);
2553        // #41 Part C: an FMV gap can be a missing daily close — point at the separate updater (a STRING
2554        // only; the tax binaries never fetch). Pseudo mode can also fill it from the cache (Part B).
2555        if b.kind == BlockerKind::FmvMissing {
2556            let _ = writeln!(out, "         ↳ {}", crate::price_cache::UPDATE_PRICES_HINT);
2557        }
2558    }
2559    let _ = writeln!(out, "Advisory blockers: {}", r.advisory.len());
2560    for b in &r.advisory {
2561        let evt = b
2562            .event
2563            .as_ref()
2564            .map(|e| e.canonical())
2565            .unwrap_or_else(|| "-".to_string());
2566        let _ = writeln!(out, "  [{:?}] {} :: {}", b.kind, evt, b.detail);
2567    }
2568    let _ = writeln!(
2569        out,
2570        "Pre-2025 method (attested historical fact): {} (attested: {})",
2571        lot_method_display(r.declared_pre2025_method),
2572        r.pre2025_method_attested
2573    );
2574    let _ = writeln!(
2575        out,
2576        "Standing orders (MethodElection): {}",
2577        r.elections.len()
2578    );
2579    for e in &r.elections {
2580        let _ = writeln!(
2581            out,
2582            "  recorded {} effective {} -> {} [{}]",
2583            e.recorded,
2584            e.effective_from,
2585            lot_method_display(e.method),
2586            e.note
2587        );
2588    }
2589    let _ = writeln!(out, "Lot selections recorded: {}", r.selection_count);
2590    let _ = writeln!(
2591        out,
2592        "Per-disposal compliance (post-2025): {}",
2593        r.compliance.len()
2594    );
2595    for c in &r.compliance {
2596        let _ = writeln!(
2597            out,
2598            "  {} @ {} :: {}",
2599            c.disposal.canonical(),
2600            c.date,
2601            compliance_status_tag(&c.status)
2602        );
2603    }
2604    // Task 11 (BG-D3): the per-live-promote verify-drift advisory (a stored floor that recomputes away
2605    // from current price data). Informational — never gates.
2606    let _ = writeln!(out, "Promote-basis drift advisories: {}", r.drift.len());
2607    for d in &r.drift {
2608        let _ = writeln!(out, "  {d}");
2609    }
2610    out
2611}
2612
2613#[cfg(test)]
2614mod gift_advisory_tests {
2615    //! P2-C Task 3 KATs — `render_gift_advisory` (per-donee §2503(b) refactor, Chunk 2).
2616    //!
2617    //! Direct-state `Removal{Gift}` fixtures + a `BTreeMap<i32, TaxTable>` table double so the
2618    //! exclusion + no-table cases are under exact control. Exclusion = $19,000 (TY2025) throughout.
2619    //! PRIVACY: synthetic values only.
2620    use super::*;
2621    use btctax_core::conventions::Usd;
2622    use btctax_core::{EventId, LotId, Removal, RemovalLeg, TaxTable};
2623    use rust_decimal_macros::dec;
2624    use std::collections::BTreeMap;
2625    use time::macros::date;
2626
2627    /// Build an unlabeled (`donee: None`) Gift removal with a single leg of the given FMV.
2628    fn gift_removal(seq: u64, removed_at: TaxDate, fmv: Usd) -> Removal {
2629        Removal {
2630            event: EventId::decision(seq),
2631            kind: RemovalKind::Gift,
2632            removed_at,
2633            legs: vec![RemovalLeg {
2634                lot_id: LotId {
2635                    origin_event_id: EventId::decision(seq),
2636                    split_sequence: 0,
2637                },
2638                sat: 100,
2639                basis: dec!(0),
2640                fmv_at_transfer: fmv,
2641                term: Term::LongTerm,
2642                basis_source: BasisSource::ComputedFromCost,
2643                acquired_at: date!(2024 - 01 - 01),
2644                pseudo: false,
2645            }],
2646            appraisal_required: false,
2647            donor_acquired_at: None,
2648            claimed_deduction: None,
2649            donee: None,
2650        }
2651    }
2652    /// Build a labeled Gift removal (donee = `Some(label)`) using the same single-leg structure.
2653    fn gift_removal_labeled(seq: u64, removed_at: TaxDate, fmv: Usd, label: &str) -> Removal {
2654        Removal {
2655            donee: Some(label.to_string()),
2656            ..gift_removal(seq, removed_at, fmv)
2657        }
2658    }
2659    fn state_with(removals: Vec<Removal>) -> LedgerState {
2660        LedgerState {
2661            removals,
2662            ..Default::default()
2663        }
2664    }
2665    /// A table double carrying only the gift_annual_exclusion (ordinary/ltcg empty — unread here).
2666    /// Uses TY2025 lifetime exclusion ($13,990,000) as default; tests that need a different
2667    /// lifetime exclusion can use `tables_with_lifetime`.
2668    fn tables_with(year: i32, excl: Usd) -> BTreeMap<i32, TaxTable> {
2669        tables_with_lifetime(year, excl, dec!(13_990_000))
2670    }
2671
2672    /// Like `tables_with` but with an explicit `lifetime_excl` for §2505 boundary tests.
2673    fn tables_with_lifetime(year: i32, excl: Usd, lifetime_excl: Usd) -> BTreeMap<i32, TaxTable> {
2674        let mut m = BTreeMap::new();
2675        m.insert(
2676            year,
2677            TaxTable {
2678                year,
2679                source: "TEST",
2680                ordinary: BTreeMap::new(),
2681                ltcg: BTreeMap::new(),
2682                gift_annual_exclusion: excl,
2683                ss_wage_base: dec!(176100),
2684                gift_lifetime_exclusion: lifetime_excl,
2685            },
2686        );
2687        m
2688    }
2689
2690    // ── Preserved safety branches ────────────────────────────────────────────────────────────────
2691
2692    /// No gifts in the year → None (even with a table present). [R0-I2] safety preserved.
2693    #[test]
2694    fn no_gifts_is_none() {
2695        let st = state_with(vec![]);
2696        let tables = tables_with(2025, dec!(19000));
2697        assert!(render_gift_advisory(&st, 2025, dec!(0), &tables).is_none());
2698    }
2699
2700    /// [R0-m6] gifts present but NO bundled table → Some(note), NOT None (no silent skip).
2701    /// The no-table note records the total gifts so nothing is silently dropped.
2702    #[test]
2703    fn gifts_present_but_no_table_emits_note_not_none() {
2704        // Unlabeled gift — the no-table branch fires before per-donee grouping.
2705        let st = state_with(vec![gift_removal(1, date!(2026 - 06 - 01), dec!(50000))]);
2706        // Table double has 2025 only → table_for(2026) == None.
2707        let tables = tables_with(2025, dec!(19000));
2708        let msg =
2709            render_gift_advisory(&st, 2026, dec!(0), &tables).expect("note expected, not None");
2710        assert!(msg.contains("unavailable"), "{msg}");
2711        assert!(msg.contains("Form 709 exposure not evaluated"), "{msg}");
2712        assert!(
2713            msg.contains("50000.00"),
2714            "must record the gift total: {msg}"
2715        );
2716    }
2717
2718    // ── Labeled-donee over-exclusion ─────────────────────────────────────────────────────────────
2719
2720    /// A labeled donee over the exclusion → filing required advisory with the per-donee breakdown.
2721    /// (Replaces the stale `over_exclusion_emits_advisory_with_total_and_caveat` which asserted the
2722    /// now-removed "donee identity is not modeled" / "total-exposure signal" phrases.)
2723    #[test]
2724    fn labeled_donee_over_exclusion_emits_advisory() {
2725        let st = state_with(vec![gift_removal_labeled(
2726            1,
2727            date!(2025 - 06 - 01),
2728            dec!(20000),
2729            "Alice",
2730        )]);
2731        let tables = tables_with(2025, dec!(19000));
2732        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2733        assert!(msg.contains("20000.00"), "must show Alice's total: {msg}");
2734        assert!(msg.contains("19000.00"), "must show the exclusion: {msg}");
2735        assert!(
2736            msg.contains("Form 709 filing required"),
2737            "must flag filing required: {msg}"
2738        );
2739        assert!(msg.contains("Alice"), "must name Alice: {msg}");
2740        // taxable = 20000 − 19000 = 1000.
2741        assert!(msg.contains("1000.00"), "taxable must be $1000.00: {msg}");
2742        // The stale "donee identity is not modeled" caveat must be gone.
2743        assert!(
2744            !msg.contains("donee identity is not modeled"),
2745            "stale aggregate caveat must not appear: {msg}"
2746        );
2747    }
2748
2749    /// A labeled donee under the exclusion → advisory with "no filing required" (not None).
2750    /// (Replaces the stale `under_exclusion_is_none` which tested None for an unlabeled gift.)
2751    #[test]
2752    fn labeled_donee_under_exclusion_no_filing_required() {
2753        let st = state_with(vec![gift_removal_labeled(
2754            1,
2755            date!(2025 - 06 - 01),
2756            dec!(10000),
2757            "Alice",
2758        )]);
2759        let tables = tables_with(2025, dec!(19000));
2760        // Gifts present + labeled donee → always Some (per-donee breakdown shown).
2761        let msg =
2762            render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected, not None");
2763        assert!(
2764            msg.contains("No Form 709 filing required"),
2765            "must say no filing required: {msg}"
2766        );
2767        assert!(msg.contains("Alice"), "must mention Alice: {msg}");
2768        assert!(msg.contains("10000.00"), "must show Alice's total: {msg}");
2769    }
2770
2771    // ── KATs (hand-verified; TY2025 gift_annual_exclusion $19,000) ──────────────────────────────
2772
2773    /// KEY LOCK — per-donee under exclusion: Alice $15,000 + Bob $15,000 (aggregate $30,000 > $19k,
2774    /// but each < $19k) → NO filing required, $0 taxable. The OLD aggregate rule wrongly flagged
2775    /// this — this test proves per-donee §2503(b) is correctly applied.
2776    #[test]
2777    fn per_donee_under_exclusion_two_donees_no_filing_required() {
2778        let st = state_with(vec![
2779            gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(15000), "Alice"),
2780            gift_removal_labeled(2, date!(2025 - 06 - 01), dec!(15000), "Bob"),
2781        ]);
2782        let tables = tables_with(2025, dec!(19000));
2783        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2784        // No filing required — neither Alice nor Bob exceeds $19,000.
2785        assert!(
2786            msg.contains("No Form 709 filing required"),
2787            "neither donee exceeds exclusion → no filing required: {msg}"
2788        );
2789        // Both donees appear in the per-donee breakdown.
2790        assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2791        assert!(msg.contains("Bob"), "Bob must appear: {msg}");
2792        // Both totals shown ($15,000 each).
2793        assert!(msg.contains("15000.00"), "donee total must appear: {msg}");
2794        // No labeled donee triggered the filing trigger.
2795        assert!(
2796            !msg.contains("Form 709 filing required (donee(s):"),
2797            "filing trigger must NOT fire: {msg}"
2798        );
2799        // Total taxable = $0 for both donees.
2800        assert!(
2801            msg.contains("Total taxable gifts: $0.00"),
2802            "total taxable must be $0.00: {msg}"
2803        );
2804    }
2805
2806    /// One labeled donee over exclusion: Alice $25,000 → filing required, taxable $6,000
2807    /// (= $25,000 − $19,000). Exact figures are hand-verified KAT values.
2808    #[test]
2809    fn one_donee_over_exclusion_filing_required() {
2810        let st = state_with(vec![gift_removal_labeled(
2811            1,
2812            date!(2025 - 06 - 01),
2813            dec!(25000),
2814            "Alice",
2815        )]);
2816        let tables = tables_with(2025, dec!(19000));
2817        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2818        assert!(
2819            msg.contains("Form 709 filing required (donee(s): Alice)"),
2820            "must trigger filing required for Alice: {msg}"
2821        );
2822        assert!(msg.contains("25000.00"), "Alice total must appear: {msg}");
2823        assert!(
2824            msg.contains("19000.00"),
2825            "exclusion applied must appear: {msg}"
2826        );
2827        // taxable = 25000 − 19000 = 6000.
2828        assert!(
2829            msg.contains("6000.00"),
2830            "taxable $6,000.00 must appear: {msg}"
2831        );
2832    }
2833
2834    /// Unlabeled bucket: a None-donee gift $30,000 → the unlabeled caveat + conservative aggregate
2835    /// signal (per-donee cannot be applied without a label). $30,000 > $19,000 → conservative signal.
2836    #[test]
2837    fn unlabeled_bucket_caveat_with_conservative_aggregate() {
2838        let st = state_with(vec![gift_removal(1, date!(2025 - 06 - 01), dec!(30000))]);
2839        let tables = tables_with(2025, dec!(19000));
2840        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2841        // Unlabeled caveat must appear.
2842        assert!(
2843            msg.contains("no donee label"),
2844            "unlabeled caveat must appear: {msg}"
2845        );
2846        assert!(
2847            msg.contains("30000.00"),
2848            "unlabeled total must appear: {msg}"
2849        );
2850        // Conservative aggregate signal: $30,000 > $19,000 (one exclusion).
2851        assert!(
2852            msg.contains("Conservative aggregate"),
2853            "conservative aggregate signal must appear: {msg}"
2854        );
2855        assert!(
2856            msg.contains("19000.00"),
2857            "one-exclusion comparison must appear: {msg}"
2858        );
2859        // No labeled-donee filing trigger must have fired.
2860        assert!(
2861            !msg.contains("Form 709 filing required (donee(s):"),
2862            "labeled filing trigger must NOT fire for unlabeled gifts: {msg}"
2863        );
2864    }
2865
2866    /// Mixed: Alice $25,000 (over exclusion) + unlabeled $5,000 → filing required for Alice +
2867    /// the unlabeled caveat for the $5,000 (which cannot have per-donee exclusion applied).
2868    #[test]
2869    fn mixed_labeled_over_and_unlabeled_shows_both() {
2870        let st = state_with(vec![
2871            gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(25000), "Alice"),
2872            gift_removal(2, date!(2025 - 06 - 01), dec!(5000)), // unlabeled
2873        ]);
2874        let tables = tables_with(2025, dec!(19000));
2875        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2876        // Alice triggers the filing required signal.
2877        assert!(
2878            msg.contains("Form 709 filing required"),
2879            "filing required for Alice: {msg}"
2880        );
2881        assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2882        // Unlabeled caveat must also appear for the $5,000 gift.
2883        assert!(
2884            msg.contains("no donee label"),
2885            "unlabeled caveat must appear: {msg}"
2886        );
2887        assert!(
2888            msg.contains("5000.00"),
2889            "unlabeled total must appear: {msg}"
2890        );
2891    }
2892
2893    /// Donations excluded: a `Removal{Donation}` does NOT count as a Gift → advisory returns None
2894    /// (no Gift events in the year). Form 709 is §2503(b) — Gifts only; §170 Donations are separate.
2895    #[test]
2896    fn donations_excluded_from_form709_advisory() {
2897        let donation_removal = Removal {
2898            event: EventId::decision(1),
2899            kind: RemovalKind::Donation,
2900            removed_at: date!(2025 - 06 - 01),
2901            legs: vec![RemovalLeg {
2902                lot_id: LotId {
2903                    origin_event_id: EventId::decision(1),
2904                    split_sequence: 0,
2905                },
2906                sat: 100,
2907                basis: dec!(0),
2908                fmv_at_transfer: dec!(50000), // large FMV — must NOT trigger the advisory
2909                term: Term::LongTerm,
2910                basis_source: BasisSource::ComputedFromCost,
2911                acquired_at: date!(2024 - 01 - 01),
2912                pseudo: false,
2913            }],
2914            appraisal_required: false,
2915            donor_acquired_at: None,
2916            claimed_deduction: Some(dec!(50000)),
2917            donee: Some("Charity X".to_string()),
2918        };
2919        let st = state_with(vec![donation_removal]);
2920        let tables = tables_with(2025, dec!(19000));
2921        // A Donation is NOT a Gift → any_gift == false → advisory returns None.
2922        assert!(
2923            render_gift_advisory(&st, 2025, dec!(0), &tables).is_none(),
2924            "Donation must be excluded from the Form 709 advisory"
2925        );
2926    }
2927
2928    // ── Chunk-3a §2505 KATs (hand-verified; TY2025: annual $19,000, lifetime $13,990,000) ────────
2929
2930    /// [KAT-U] Under lifetime — Alice $100,000 gift, prior $0.
2931    /// current-year taxable = $81,000 (100k − 19k); used $81,000; remaining $13,909,000.
2932    /// No "EXCEEDED" line.
2933    #[test]
2934    fn section_2505_under_lifetime_shows_used_and_remaining() {
2935        let st = state_with(vec![gift_removal_labeled(
2936            1,
2937            date!(2025 - 06 - 01),
2938            dec!(100000),
2939            "Alice",
2940        )]);
2941        let tables = tables_with(2025, dec!(19000)); // lifetime = $13,990,000 via tables_with
2942        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2943        // current-year taxable = 100000 − 19000 = 81000
2944        assert!(
2945            msg.contains("81000.00"),
2946            "taxable $81,000 must appear: {msg}"
2947        );
2948        // §2505 block: used $81,000 of $13,990,000
2949        assert!(
2950            msg.contains("§2505 lifetime (basic) exclusion"),
2951            "§2505 block must appear: {msg}"
2952        );
2953        assert!(
2954            msg.contains("13990000.00"),
2955            "lifetime exclusion $13,990,000 must appear: {msg}"
2956        );
2957        // remaining = 13,990,000 − 81,000 = 13,909,000
2958        assert!(
2959            msg.contains("13909000.00"),
2960            "remaining $13,909,000 must appear: {msg}"
2961        );
2962        // No "EXCEEDED" — still under lifetime
2963        assert!(
2964            !msg.contains("EXCEEDED"),
2965            "must NOT say EXCEEDED when under limit: {msg}"
2966        );
2967        // [I1] stale Chunk-3 caveat is gone
2968        assert!(
2969            !msg.contains("later chunk (Chunk 3)"),
2970            "stale Chunk-3 caveat must be absent: {msg}"
2971        );
2972    }
2973
2974    /// [KAT-P] Prior gifts accumulate — Alice $100,000, prior $13,900,000.
2975    /// cumulative = 13,900,000 + 81,000 = 13,981,000; remaining = $9,000; no tax.
2976    #[test]
2977    fn section_2505_prior_gifts_accumulate() {
2978        let st = state_with(vec![gift_removal_labeled(
2979            1,
2980            date!(2025 - 06 - 01),
2981            dec!(100000),
2982            "Alice",
2983        )]);
2984        let tables = tables_with(2025, dec!(19000));
2985        let msg =
2986            render_gift_advisory(&st, 2025, dec!(13_900_000), &tables).expect("advisory expected");
2987        // cumulative = 13,900,000 + 81,000 = 13,981,000
2988        assert!(
2989            msg.contains("13981000.00"),
2990            "cumulative $13,981,000 must appear: {msg}"
2991        );
2992        // remaining = 13,990,000 − 13,981,000 = 9,000
2993        assert!(
2994            msg.contains("9000.00"),
2995            "remaining $9,000 must appear: {msg}"
2996        );
2997        assert!(
2998            !msg.contains("EXCEEDED"),
2999            "must NOT say EXCEEDED when under limit: {msg}"
3000        );
3001    }
3002
3003    /// [KAT-E] Exceeds lifetime — Alice $100,000, prior $13,950,000.
3004    /// cumulative = 13,950,000 + 81,000 = 14,031,000 > 13,990,000.
3005    /// excess = 14,031,000 − 13,990,000 = 41,000.
3006    #[test]
3007    fn section_2505_exceeds_lifetime_shows_exceeded_and_excess() {
3008        let st = state_with(vec![gift_removal_labeled(
3009            1,
3010            date!(2025 - 06 - 01),
3011            dec!(100000),
3012            "Alice",
3013        )]);
3014        let tables = tables_with(2025, dec!(19000));
3015        let msg =
3016            render_gift_advisory(&st, 2025, dec!(13_950_000), &tables).expect("advisory expected");
3017        assert!(
3018            msg.contains("14031000.00"),
3019            "cumulative $14,031,000 must appear: {msg}"
3020        );
3021        assert!(
3022            msg.contains("EXCEEDED"),
3023            "must say EXCEEDED when over lifetime limit: {msg}"
3024        );
3025        // excess = 41,000
3026        assert!(
3027            msg.contains("41000.00"),
3028            "excess $41,000 must appear: {msg}"
3029        );
3030    }
3031
3032    /// [KAT-B / R0-M2] Exact boundary — cumulative EXACTLY $13,990,000.
3033    /// Alice $100,000, prior = 13,990,000 − 81,000 = 13,909,000.
3034    /// remaining = $0; NOT "EXCEEDED" (strict `>`, not `>=`).
3035    #[test]
3036    fn section_2505_exact_boundary_remaining_zero_not_exceeded() {
3037        let st = state_with(vec![gift_removal_labeled(
3038            1,
3039            date!(2025 - 06 - 01),
3040            dec!(100000),
3041            "Alice",
3042        )]);
3043        let tables = tables_with(2025, dec!(19000));
3044        // prior = 13,990,000 − 81,000 = 13,909,000 → cumulative = 13,990,000 exactly
3045        let msg =
3046            render_gift_advisory(&st, 2025, dec!(13_909_000), &tables).expect("advisory expected");
3047        assert!(
3048            msg.contains("13990000.00"),
3049            "cumulative $13,990,000 must appear: {msg}"
3050        );
3051        // remaining = 0 — assert the exact phrasing so "13990000.00" cannot satisfy this
3052        assert!(
3053            msg.contains("($0.00 remaining)"),
3054            "remaining $0.00 in exact phrasing '($0.00 remaining)' must appear: {msg}"
3055        );
3056        // strict >: at exactly the limit, NOT exceeded
3057        assert!(
3058            !msg.contains("EXCEEDED"),
3059            "must NOT say EXCEEDED at exactly the limit: {msg}"
3060        );
3061    }
3062
3063    /// [KAT-P4 / R0-M4] Prior-only edge — prior $5,000,000, all current donees under annual.
3064    /// Alice $10,000 gift (under $19k annual) → current taxable $0.
3065    /// cumulative = 5,000,000 + 0 = 5,000,000 > 0 → §2505 block SHOWS.
3066    #[test]
3067    fn section_2505_prior_only_block_shows_even_when_current_taxable_zero() {
3068        let st = state_with(vec![gift_removal_labeled(
3069            1,
3070            date!(2025 - 06 - 01),
3071            dec!(10000), // under $19k annual exclusion → current taxable = 0
3072            "Alice",
3073        )]);
3074        let tables = tables_with(2025, dec!(19000));
3075        let msg =
3076            render_gift_advisory(&st, 2025, dec!(5_000_000), &tables).expect("advisory expected");
3077        // cumulative = 5,000,000 (from prior; current taxable = 0)
3078        assert!(
3079            msg.contains("5000000.00"),
3080            "cumulative $5,000,000 must appear: {msg}"
3081        );
3082        assert!(
3083            msg.contains("§2505 lifetime (basic) exclusion"),
3084            "§2505 block must appear for prior-only case: {msg}"
3085        );
3086        assert!(!msg.contains("EXCEEDED"), "must NOT say EXCEEDED: {msg}");
3087    }
3088
3089    /// [KAT-N] No taxable gifts → no §2505 block.
3090    /// Alice $10,000 (under annual), prior $0 → cumulative = $0 → no §2505 line.
3091    #[test]
3092    fn section_2505_no_block_when_cumulative_zero() {
3093        let st = state_with(vec![gift_removal_labeled(
3094            1,
3095            date!(2025 - 06 - 01),
3096            dec!(10000), // under $19k annual exclusion
3097            "Alice",
3098        )]);
3099        let tables = tables_with(2025, dec!(19000));
3100        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3101        assert!(
3102            !msg.contains("§2505 lifetime"),
3103            "§2505 block must NOT appear when cumulative = 0: {msg}"
3104        );
3105    }
3106
3107    /// [KAT-D0] Default $0 prior — no flag → prior $0 + the new caveats present (no stale Chunk-3).
3108    #[test]
3109    fn section_2505_default_zero_prior_shows_caveats() {
3110        let st = state_with(vec![gift_removal_labeled(
3111            1,
3112            date!(2025 - 06 - 01),
3113            dec!(100000), // taxable $81k
3114            "Alice",
3115        )]);
3116        let tables = tables_with(2025, dec!(19000));
3117        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3118        // [I1] stale Chunk-3 caveat is ABSENT
3119        assert!(
3120            !msg.contains("later chunk (Chunk 3)"),
3121            "stale 'later chunk (Chunk 3)' must be absent: {msg}"
3122        );
3123        // New caveats present
3124        assert!(
3125            msg.contains("§2513 gift-splitting"),
3126            "§2513 caveat must be present: {msg}"
3127        );
3128        assert!(
3129            msg.contains("portability/DSUE"),
3130            "portability/DSUE caveat must be present: {msg}"
3131        );
3132        assert!(
3133            msg.contains("prior cumulative taxable gifts are user-supplied"),
3134            "prior-cumulative disclosure caveat must be present: {msg}"
3135        );
3136    }
3137
3138    /// [KAT-I2] Mixed/unlabeled — Alice $100,000 (taxable $81k) + unlabeled $50,000.
3139    /// §2505 block shows used $81k AND the unlabeled-omission disclosure line.
3140    #[test]
3141    fn section_2505_mixed_shows_omission_disclosure_for_unlabeled() {
3142        let st = state_with(vec![
3143            gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(100000), "Alice"),
3144            gift_removal(2, date!(2025 - 06 - 01), dec!(50000)), // unlabeled
3145        ]);
3146        let tables = tables_with(2025, dec!(19000));
3147        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3148        // §2505 block shows used $81,000 (LABELED only)
3149        assert!(
3150            msg.contains("§2505 lifetime (basic) exclusion"),
3151            "§2505 block must appear: {msg}"
3152        );
3153        assert!(
3154            msg.contains("81000.00"),
3155            "used $81,000 (labeled only) must appear: {msg}"
3156        );
3157        // [I2] Unlabeled-omission disclosure in §2505 block
3158        assert!(
3159            msg.contains("§2505 consumption reflects LABELED-donee taxable gifts only"),
3160            "omission disclosure must appear: {msg}"
3161        );
3162        assert!(
3163            msg.contains("50000.00"),
3164            "unlabeled total $50,000 must appear in omission disclosure: {msg}"
3165        );
3166        assert!(
3167            msg.contains("consumption may be understated"),
3168            "under-stated warning must appear: {msg}"
3169        );
3170    }
3171
3172    /// [KAT-I1] Absence — the stale "§2505 … later chunk (Chunk 3)" string is GONE from output.
3173    #[test]
3174    fn section_2505_stale_chunk3_caveat_is_absent() {
3175        let st = state_with(vec![gift_removal_labeled(
3176            1,
3177            date!(2025 - 06 - 01),
3178            dec!(20000),
3179            "Alice",
3180        )]);
3181        let tables = tables_with(2025, dec!(19000));
3182        let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3183        assert!(
3184            !msg.contains("later chunk (Chunk 3)"),
3185            "stale Chunk-3 caveat must be absent: {msg}"
3186        );
3187        assert!(
3188            !msg.contains("§2505 lifetime exemption is a later chunk"),
3189            "stale §2505 future-chunk phrase must be absent: {msg}"
3190        );
3191    }
3192}
3193
3194#[cfg(test)]
3195mod schedule_se_tests {
3196    //! P2-D Task 2 / Chunk A + Chunk B KATs — `render_schedule_se` + `schedule_se.csv`.
3197    //! The rendered figures reuse hand-verified SeTaxResult fixtures (see btctax-core se.rs KATs).
3198    //! PRIVACY: synthetic values only.
3199    use super::*;
3200    use rust_decimal_macros::dec;
3201
3202    /// Golden 1 SeTaxResult (Single, $100,000 business mining, no W-2, no expenses).
3203    fn golden1() -> SeTaxResult {
3204        SeTaxResult {
3205            net_se: dec!(100000),
3206            base: dec!(92350.00),
3207            ss: dec!(11451.40),
3208            medicare: dec!(2678.15),
3209            addl: dec!(0.00),
3210            total: dec!(14129.55),
3211            deductible_half: dec!(7064.78),
3212        }
3213    }
3214
3215    /// [Chunk A] W-2 SeTaxResult: Single, mining $100k, w2_ss $150k, w2_medicare $150k.
3216    fn w2_headline() -> SeTaxResult {
3217        SeTaxResult {
3218            net_se: dec!(100000),
3219            base: dec!(92350.00),
3220            ss: dec!(3236.40),
3221            medicare: dec!(2678.15),
3222            addl: dec!(381.15),
3223            total: dec!(6295.70),
3224            deductible_half: dec!(2957.28),
3225        }
3226    }
3227
3228    /// [Chunk A] Asymmetric SeTaxResult: w2_ss $150k, w2_medicare $0.
3229    fn w2_asymmetric() -> SeTaxResult {
3230        SeTaxResult {
3231            net_se: dec!(100000),
3232            base: dec!(92350.00),
3233            ss: dec!(3236.40),
3234            medicare: dec!(2678.15),
3235            addl: dec!(0.00),
3236            total: dec!(5914.55),
3237            deductible_half: dec!(2957.28),
3238        }
3239    }
3240
3241    /// [Chunk B] Headline expenses SeTaxResult: Single, mining $100k, expenses $20k, no W-2.
3242    /// net_se = 80,000; base = 80,000 × 0.9235 = 73,880.00; ss = 12.4% × 73,880 = 9,161.12;
3243    /// medicare = 2.9% × 73,880 = 2,142.52; addl = 0; total = 11,303.64;
3244    /// deductible_half = (9,161.12 + 2,142.52)/2 = 5,651.82.
3245    fn expenses_headline() -> SeTaxResult {
3246        SeTaxResult {
3247            net_se: dec!(80000),
3248            base: dec!(73880.00),
3249            ss: dec!(9161.12),
3250            medicare: dec!(2142.52),
3251            addl: dec!(0.00),
3252            total: dec!(11303.64),
3253            deductible_half: dec!(5651.82),
3254        }
3255    }
3256
3257    /// [Chunk B] W-2 + expenses SeTaxResult: Single, mining $100k, expenses $20k, w2_ss $150k,
3258    /// w2_medicare $150k.
3259    /// net_se = 80,000; base = 73,880.00; ss_cap = max(0, 176,100 − 150,000) = 26,100 →
3260    /// ss = 12.4% × min(73,880, 26,100) = 12.4% × 26,100 = 3,236.40;
3261    /// medicare = 2.9% × 73,880 = 2,142.52;
3262    /// addl_threshold = max(0, 200,000 − 150,000) = 50,000; over = 73,880 − 50,000 = 23,880 →
3263    /// addl = 0.9% × 23,880 = 214.92;
3264    /// total = 3,236.40 + 2,142.52 + 214.92 = 5,593.84;
3265    /// deductible_half = (3,236.40 + 2,142.52)/2 = 2,689.46.
3266    fn expenses_w2_combined() -> SeTaxResult {
3267        SeTaxResult {
3268            net_se: dec!(80000),
3269            base: dec!(73880.00),
3270            ss: dec!(3236.40),
3271            medicare: dec!(2142.52),
3272            addl: dec!(214.92),
3273            total: dec!(5593.84),
3274            deductible_half: dec!(2689.46),
3275        }
3276    }
3277
3278    /// Business-mining year → full Schedule SE section: components + total + deductible half +
3279    /// [Chunk A] the $0-W-2 short note + the §164(f) advisory + the [D5] standalone note.
3280    /// [Chunk B] expenses $0 → "no Schedule C expenses supplied" note (old "not modeled" GONE).
3281    #[test]
3282    fn business_mining_year_renders_full_section() {
3283        let r = golden1();
3284        let s = render_schedule_se(
3285            2025,
3286            Some(&r),
3287            dec!(100000),
3288            true,
3289            Usd::ZERO,
3290            Usd::ZERO,
3291            Usd::ZERO,
3292        )
3293        .expect("SE section expected");
3294        // Components + total + §164(f) half.
3295        assert!(s.contains("92350.00"), "net SE earnings base: {s}");
3296        assert!(s.contains("11451.40"), "SS component: {s}");
3297        assert!(s.contains("2678.15"), "Medicare component: {s}");
3298        assert!(s.contains("14129.55"), "total SE tax: {s}");
3299        assert!(s.contains("7064.78"), "§164(f) deductible half: {s}");
3300        assert!(
3301            s.contains("Additional Medicare"),
3302            "addl component labeled: {s}"
3303        );
3304        // [Chunk A / R0-I2] NEW $0-W-2 short note present; old OVERSTATED/UNDERSTATED GONE.
3305        assert!(
3306            s.contains("$0 W-2 wages"),
3307            "short $0-W-2 note must appear (both W-2 = 0): {s}"
3308        );
3309        assert!(
3310            s.contains("--w2-ss-wages"),
3311            "$0 note must mention --w2-ss-wages flag: {s}"
3312        );
3313        assert!(
3314            !s.contains("OVERSTATED"),
3315            "old OVERSTATED text must be absent (Chunk A regression): {s}"
3316        );
3317        assert!(
3318            !s.contains("UNDERSTATED"),
3319            "old UNDERSTATED text must be absent (Chunk A regression): {s}"
3320        );
3321        // [Chunk A / R0-I3] §164(f) advisory present.
3322        assert!(
3323            s.contains("NOT auto-coordinated"),
3324            "§164(f) advisory must appear: {s}"
3325        );
3326        assert!(
3327            s.contains("coordinate it on your actual return"),
3328            "§164(f) advisory must include coordination instruction: {s}"
3329        );
3330        // [D5] standalone note.
3331        assert!(
3332            s.contains("SEPARATE federal liability"),
3333            "standalone note: {s}"
3334        );
3335        assert!(
3336            s.contains("not") && s.contains("§164(f)"),
3337            "notes §164(f) not auto-coordinated: {s}"
3338        );
3339        // [Chunk B] $0-expenses note replaces the old "not modeled" caveat.
3340        assert!(
3341            s.contains("no Schedule C expenses supplied"),
3342            "Chunk B $0-expenses note must appear: {s}"
3343        );
3344        assert!(
3345            s.contains("--schedule-c-expenses"),
3346            "$0 note must mention --schedule-c-expenses flag: {s}"
3347        );
3348        assert!(
3349            !s.contains("not modeled"),
3350            "old 'not modeled' caveat must be absent (replaced by Chunk B): {s}"
3351        );
3352    }
3353
3354    /// [Chunk A / D3] When W-2 values are set, the coordinated disclosure appears with §1401(b)(2)(B).
3355    #[test]
3356    fn w2_set_renders_coordinated_disclosure() {
3357        let r = w2_headline();
3358        let s = render_schedule_se(
3359            2025,
3360            Some(&r),
3361            dec!(100000),
3362            true,
3363            Usd::ZERO,
3364            dec!(150000),
3365            dec!(150000),
3366        )
3367        .expect("SE section expected");
3368        // [D3] Coordinated text present.
3369        assert!(
3370            s.contains("W-2 coordination applied"),
3371            "coordinated disclosure must appear: {s}"
3372        );
3373        assert!(
3374            s.contains("§1401(b)(2)(B)"),
3375            "must cite §1401(b)(2)(B): {s}"
3376        );
3377        assert!(
3378            s.contains("Form 8959 Part II"),
3379            "must cite Form 8959 Part II: {s}"
3380        );
3381        // The W-2 amounts appear in the disclosure text.
3382        assert!(s.contains("150000"), "w2_ss_wages amount must appear: {s}");
3383        // Old OVERSTATED/UNDERSTATED text ABSENT even in W-2 mode (expenses = 0).
3384        assert!(!s.contains("OVERSTATED"), "OVERSTATED must be absent: {s}");
3385        assert!(
3386            !s.contains("UNDERSTATED"),
3387            "UNDERSTATED must be absent: {s}"
3388        );
3389        // Figures correct.
3390        assert!(s.contains("3236.40"), "reduced SS component: {s}");
3391        assert!(s.contains("381.15"), "non-zero addl: {s}");
3392        assert!(s.contains("6295.70"), "reduced total: {s}");
3393        assert!(s.contains("2957.28"), "deductible_half: {s}");
3394    }
3395
3396    /// [Chunk A / I4] Asymmetric-W-2 transposition guard (render level): w2_ss $150k, w2_medicare $0 →
3397    /// ss == $3,236.40 AND addl == $0.00 in the rendered text.
3398    /// A swapped (w2_medicare, w2_ss) argument order at the call site would flip both values.
3399    #[test]
3400    fn w2_asymmetric_render_transposition_guard() {
3401        let r = w2_asymmetric();
3402        let s = render_schedule_se(
3403            2025,
3404            Some(&r),
3405            dec!(100000),
3406            true,
3407            Usd::ZERO,
3408            dec!(150000),
3409            Usd::ZERO,
3410        )
3411        .expect("SE section expected");
3412        // W-2 coordination text must appear (w2_ss > 0).
3413        assert!(
3414            s.contains("W-2 coordination applied"),
3415            "coordinated disclosure must appear: {s}"
3416        );
3417        // ss is reduced, addl is 0 — not transposed values.
3418        assert!(s.contains("3236.40"), "ss must be 3236.40 (reduced): {s}");
3419        assert!(
3420            s.contains("0.00"),
3421            "addl must be 0.00 (threshold un-reduced): {s}"
3422        );
3423        // The old OVERSTATED/UNDERSTATED is absent.
3424        assert!(!s.contains("OVERSTATED"), "{s}");
3425        assert!(!s.contains("UNDERSTATED"), "{s}");
3426    }
3427
3428    /// No business SE income → no Schedule SE section (None). [gross_se == 0 path]
3429    #[test]
3430    fn no_business_income_no_section() {
3431        assert!(
3432            render_schedule_se(2025, None, Usd::ZERO, true, Usd::ZERO, Usd::ZERO, Usd::ZERO)
3433                .is_none()
3434        );
3435    }
3436
3437    /// Business SE income present but no bundled table → the "SS wage base unavailable" note (no
3438    /// silent drop). [gross_se > 0 && !table_present path]
3439    #[test]
3440    fn business_income_but_no_table_emits_note() {
3441        let s = render_schedule_se(
3442            2099,
3443            None,
3444            dec!(100000),
3445            false,
3446            Usd::ZERO,
3447            Usd::ZERO,
3448            Usd::ZERO,
3449        )
3450        .expect("wage-base-unavailable note expected");
3451        assert!(s.contains("SS wage base unavailable"), "{s}");
3452        assert!(s.contains("2099"), "names the year: {s}");
3453        assert!(s.contains("no silent drop"), "{s}");
3454    }
3455
3456    // ── Chunk B golden KATs ────────────────────────────────────────────────────────────────────
3457
3458    /// [Chunk B] Headline: expenses $20k, no W-2 → breakout line + Schedule C advisory.
3459    /// Verifies: gross = net_se + expenses shown, advisory text present, NO old "not modeled" caveat.
3460    #[test]
3461    fn expenses_20k_no_w2_renders_breakout_and_advisory() {
3462        let r = expenses_headline(); // net_se = 80,000; expenses = 20,000 → gross = 100,000
3463        let s = render_schedule_se(
3464            2025,
3465            Some(&r),
3466            dec!(100000), // gross_se
3467            true,
3468            dec!(20000), // schedule_c_expenses
3469            Usd::ZERO,
3470            Usd::ZERO,
3471        )
3472        .expect("SE section expected");
3473        // Breakout line: gross − expenses = net SE
3474        assert!(
3475            s.contains("gross business income"),
3476            "breakout line must appear: {s}"
3477        );
3478        assert!(
3479            s.contains("100000.00"),
3480            "gross ($100k) must appear in breakout: {s}"
3481        );
3482        assert!(
3483            s.contains("20000.00"),
3484            "expenses ($20k) must appear in breakout: {s}"
3485        );
3486        assert!(
3487            s.contains("80000.00"),
3488            "net_se ($80k) must appear in breakout: {s}"
3489        );
3490        // Schedule C advisory: OVERSTATES text present; NO OTI-edit prescription.
3491        assert!(
3492            s.contains("OVERSTATES"),
3493            "Schedule C advisory OVERSTATES text: {s}"
3494        );
3495        assert!(
3496            s.contains("ORDINARY taxable income"),
3497            "advisory must mention ORDINARY taxable income: {s}"
3498        );
3499        assert!(
3500            s.contains("engine-side coordination is deferred"),
3501            "advisory must mention deferred coordination: {s}"
3502        );
3503        // NO OTI-edit prescription: must NOT say "reduce your ordinary_taxable_income" (spec D3).
3504        assert!(
3505            !s.contains("reduce your ordinary_taxable_income"),
3506            "NO OTI-edit prescription allowed (spec D3): {s}"
3507        );
3508        assert!(
3509            !s.contains("set --ordinary-taxable-income"),
3510            "NO OTI-edit prescription allowed (spec D3): {s}"
3511        );
3512        // Golden figures: base, ss, medicare, total, deductible_half.
3513        assert!(s.contains("73880.00"), "base $73,880: {s}");
3514        assert!(s.contains("9161.12"), "ss $9,161.12: {s}");
3515        assert!(s.contains("2142.52"), "medicare $2,142.52: {s}");
3516        assert!(s.contains("11303.64"), "total $11,303.64: {s}");
3517        assert!(s.contains("5651.82"), "deductible_half $5,651.82: {s}");
3518        // Old "not modeled" caveat is ABSENT.
3519        assert!(
3520            !s.contains("not modeled"),
3521            "old 'not modeled' caveat must be absent: {s}"
3522        );
3523    }
3524
3525    /// [Chunk B / R0-I1] Fully expensed (gross > 0, table present, net_se == 0) → the NEW
3526    /// "fully expensed" line; the "SS wage base unavailable" note ABSENT.
3527    #[test]
3528    fn fully_expensed_shows_new_line_not_wage_base_note() {
3529        // mining $10,000, expenses $15,000 → net_se = 0 → compute_se_tax returns None.
3530        // Render with gross_se = 10,000 and table_present = true.
3531        let s = render_schedule_se(
3532            2025,
3533            None,
3534            dec!(10000), // gross_se
3535            true,        // table_present = true
3536            dec!(15000), // schedule_c_expenses
3537            Usd::ZERO,
3538            Usd::ZERO,
3539        )
3540        .expect("fully-expensed section expected (not None)");
3541        // The new "fully expensed" line is present.
3542        assert!(
3543            s.contains("fully expensed"),
3544            "fully-expensed line must appear: {s}"
3545        );
3546        assert!(
3547            s.contains("10000.00"),
3548            "gross $10k must appear in fully-expensed line: {s}"
3549        );
3550        assert!(
3551            s.contains("15000.00"),
3552            "expenses $15k must appear in fully-expensed line: {s}"
3553        );
3554        assert!(
3555            s.contains("no §1401 SE tax"),
3556            "must state no SE tax owed: {s}"
3557        );
3558        assert!(s.contains("2025"), "must name the year: {s}");
3559        // The "SS wage base unavailable" note is ABSENT (negative assertion per [R0-I1]).
3560        assert!(
3561            !s.contains("SS wage base unavailable"),
3562            "wage-base-unavailable note must be ABSENT for fully-expensed case: {s}"
3563        );
3564    }
3565
3566    /// [Chunk B] W-2 + expenses combined render: breakout and W-2 coordination both appear.
3567    #[test]
3568    fn expenses_w2_combined_renders_both() {
3569        let r = expenses_w2_combined(); // net_se = 80,000; gross = 100,000; expenses = 20,000
3570        let s = render_schedule_se(
3571            2025,
3572            Some(&r),
3573            dec!(100000),
3574            true,
3575            dec!(20000),  // schedule_c_expenses
3576            dec!(150000), // w2_ss_wages
3577            dec!(150000), // w2_medicare_wages
3578        )
3579        .expect("SE section expected");
3580        // Breakout line.
3581        assert!(s.contains("gross business income"), "breakout line: {s}");
3582        assert!(s.contains("80000.00"), "net_se in breakout: {s}");
3583        // Schedule C advisory.
3584        assert!(s.contains("OVERSTATES"), "Schedule C advisory: {s}");
3585        // W-2 coordination also present.
3586        assert!(
3587            s.contains("W-2 coordination applied"),
3588            "W-2 coordination: {s}"
3589        );
3590        // Figures correct.
3591        assert!(s.contains("73880.00"), "base: {s}");
3592        assert!(s.contains("3236.40"), "ss (reduced by W-2 cap): {s}");
3593        assert!(s.contains("2142.52"), "medicare: {s}");
3594        assert!(s.contains("214.92"), "addl: {s}");
3595        assert!(s.contains("5593.84"), "total: {s}");
3596        assert!(s.contains("2689.46"), "deductible_half: {s}");
3597    }
3598
3599    /// `schedule_se.csv` columns + values (year-scoped; written when a SeTaxResult exists).
3600    #[test]
3601    fn schedule_se_csv_columns_and_values() {
3602        let dir = tempfile::tempdir().unwrap();
3603        let out = dir.path().join("export");
3604        let st = LedgerState::default();
3605        let r = golden1();
3606        write_csv_exports(&out, &st, Some(2025), Some(&r), &BTreeMap::new()).unwrap();
3607
3608        let path = out.join("schedule_se.csv");
3609        assert!(path.exists(), "schedule_se.csv must be written");
3610        let mut rdr = csv::Reader::from_reader(std::fs::File::open(&path).unwrap());
3611        let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3612        assert_eq!(
3613            headers,
3614            vec![
3615                "net_se_earnings",
3616                "se_base_9235",
3617                "ss_component",
3618                "medicare_component",
3619                "additional_medicare_component",
3620                "total_se_tax",
3621                "deductible_half",
3622            ]
3623        );
3624        let rec = rdr
3625            .records()
3626            .next()
3627            .expect("one data row")
3628            .expect("readable");
3629        assert_eq!(&rec[0], "100000"); // net_se_earnings
3630        assert_eq!(&rec[1], "92350.00"); // se_base_9235
3631        assert_eq!(&rec[2], "11451.40"); // ss_component
3632        assert_eq!(&rec[3], "2678.15"); // medicare_component
3633        assert_eq!(&rec[4], "0.00"); // additional_medicare_component
3634        assert_eq!(&rec[5], "14129.55"); // total_se_tax
3635        assert_eq!(&rec[6], "7064.78"); // deductible_half
3636    }
3637
3638    /// No SeTaxResult → schedule_se.csv is NOT written (nothing to file; also covers fully-expensed).
3639    #[test]
3640    fn schedule_se_csv_omitted_when_no_se_tax() {
3641        let dir = tempfile::tempdir().unwrap();
3642        let out = dir.path().join("export");
3643        let st = LedgerState::default();
3644        write_csv_exports(&out, &st, Some(2025), None, &BTreeMap::new()).unwrap();
3645        assert!(!out.join("schedule_se.csv").exists());
3646    }
3647}
3648
3649#[cfg(test)]
3650mod form8283_csv_tests {
3651    //! P2-C / Chunk-3b Task 2 unit KATs — `write_form8283_csv` Part III/IV detail columns.
3652    //! Direct-state fixtures; pure unit (no vault). PRIVACY: synthetic values only.
3653    use super::*;
3654
3655    /// form8283.csv — new Part III/IV detail columns populated when details are present.
3656    #[test]
3657    fn form8283_csv_detail_columns_present_and_empty() {
3658        use btctax_core::{
3659            BasisSource, DonationDetails, EventId, LedgerState, Removal, RemovalKind, RemovalLeg,
3660            Term,
3661        };
3662        use time::macros::date;
3663
3664        let dir = tempfile::tempdir().unwrap();
3665        let out = dir.path().join("export");
3666
3667        // Build a minimal state with one Section-B donation.
3668        let event = EventId::decision(99);
3669        let leg = RemovalLeg {
3670            lot_id: btctax_core::LotId {
3671                origin_event_id: event.clone(),
3672                split_sequence: 0,
3673            },
3674            sat: 100_000_000,
3675            basis: rust_decimal::Decimal::ZERO,
3676            fmv_at_transfer: rust_decimal::Decimal::from(52000),
3677            term: Term::LongTerm,
3678            basis_source: BasisSource::ComputedFromCost,
3679            acquired_at: date!(2025 - 01 - 01),
3680            pseudo: false,
3681        };
3682        let removal = Removal {
3683            event: event.clone(),
3684            kind: RemovalKind::Donation,
3685            removed_at: date!(2025 - 03 - 01),
3686            legs: vec![leg],
3687            appraisal_required: false,
3688            donor_acquired_at: None,
3689            claimed_deduction: Some(rust_decimal::Decimal::from(52000)),
3690            donee: Some("Test Charity Two".into()),
3691        };
3692        // N1: second removal with NO details in dmap — locks the empty-half of the 6 new columns.
3693        let event2 = EventId::decision(100);
3694        let leg2 = RemovalLeg {
3695            lot_id: btctax_core::LotId {
3696                origin_event_id: event2.clone(),
3697                split_sequence: 0,
3698            },
3699            sat: 10_000_000,
3700            basis: rust_decimal::Decimal::ZERO,
3701            fmv_at_transfer: rust_decimal::Decimal::from(8000),
3702            term: Term::LongTerm,
3703            basis_source: BasisSource::ComputedFromCost,
3704            acquired_at: date!(2025 - 01 - 15),
3705            pseudo: false,
3706        };
3707        let removal2 = Removal {
3708            event: event2.clone(),
3709            kind: RemovalKind::Donation,
3710            removed_at: date!(2025 - 05 - 01),
3711            legs: vec![leg2],
3712            appraisal_required: false,
3713            donor_acquired_at: None,
3714            claimed_deduction: Some(rust_decimal::Decimal::from(8000)),
3715            donee: Some("No Details Org".into()),
3716        };
3717
3718        let st = LedgerState {
3719            removals: vec![removal, removal2],
3720            ..Default::default()
3721        };
3722
3723        let mut dmap: BTreeMap<EventId, DonationDetails> = BTreeMap::new();
3724        dmap.insert(
3725            event,
3726            DonationDetails {
3727                donee_name: "Test Charity".into(),
3728                donee_ein: Some("12-3456789".into()),
3729                donee_address: Some("123 Main".into()),
3730                appraiser_name: "Test Appraiser".into(),
3731                appraiser_tin: Some("987654321".into()),
3732                appraiser_ptin: Some("P01234567".into()),
3733                appraiser_qualifications: Some("Certified".into()),
3734                appraisal_date: Some(date!(2025 - 06 - 01)),
3735                appraiser_address: None,
3736                fmv_method_override: None,
3737            },
3738        );
3739        // event2 intentionally NOT inserted — exercises the empty-column path.
3740
3741        write_csv_exports(&out, &st, Some(2025), None, &dmap).unwrap();
3742
3743        let path = out.join("form8283.csv");
3744        assert!(path.exists(), "form8283.csv must exist");
3745
3746        let mut rdr = csv::ReaderBuilder::new()
3747            .comment(Some(b'#'))
3748            .from_path(&path)
3749            .unwrap();
3750        let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3751        let idx = |name: &str| {
3752            headers
3753                .iter()
3754                .position(|h| h == name)
3755                .unwrap_or_else(|| panic!("header {name} not found"))
3756        };
3757        // Collect both rows. form_8283 sorts by (removed_at, event, lot_id):
3758        //   records[0] = removal (removed_at 2025-03-01, event decision(99)) — WITH details.
3759        //   records[1] = removal2 (removed_at 2025-05-01, event decision(100)) — NO details.
3760        let all_recs: Vec<csv::StringRecord> = rdr.records().map(|r| r.unwrap()).collect();
3761        assert_eq!(
3762            all_recs.len(),
3763            2,
3764            "must have exactly two data rows (one per removal)"
3765        );
3766        let rec = &all_recs[0];
3767        let no_details_rec = &all_recs[1];
3768
3769        // WITH-details half: all 6 new columns populated.
3770        assert_eq!(&rec[idx("donee")], "Test Charity");
3771        assert_eq!(&rec[idx("appraiser")], "Test Appraiser");
3772        assert_eq!(&rec[idx("donee_ein")], "12-3456789");
3773        assert_eq!(&rec[idx("donee_address")], "123 Main");
3774        assert_eq!(&rec[idx("appraiser_tin")], "987654321");
3775        assert_eq!(&rec[idx("appraiser_ptin")], "P01234567");
3776        assert_eq!(&rec[idx("appraiser_qualifications")], "Certified");
3777        assert_eq!(&rec[idx("appraisal_date")], "2025-06-01");
3778        assert_eq!(&rec[idx("needs_review")], "false");
3779
3780        // N1: EMPTY half — no-details removal has all 6 new columns blank.
3781        assert_eq!(
3782            &no_details_rec[idx("donee_ein")],
3783            "",
3784            "no-details row: donee_ein must be empty"
3785        );
3786        assert_eq!(
3787            &no_details_rec[idx("donee_address")],
3788            "",
3789            "no-details row: donee_address must be empty"
3790        );
3791        assert_eq!(
3792            &no_details_rec[idx("appraiser_tin")],
3793            "",
3794            "no-details row: appraiser_tin must be empty"
3795        );
3796        assert_eq!(
3797            &no_details_rec[idx("appraiser_ptin")],
3798            "",
3799            "no-details row: appraiser_ptin must be empty"
3800        );
3801        assert_eq!(
3802            &no_details_rec[idx("appraiser_qualifications")],
3803            "",
3804            "no-details row: appraiser_qualifications must be empty"
3805        );
3806        assert_eq!(
3807            &no_details_rec[idx("appraisal_date")],
3808            "",
3809            "no-details row: appraisal_date must be empty"
3810        );
3811        assert_eq!(
3812            &no_details_rec[idx("needs_review")],
3813            "true",
3814            "no-details carrier row: needs_review must be true"
3815        );
3816    }
3817}
3818
3819/// UX-P4-11: one row of `events list` — a decidable event and its decision status. The `reff` is the
3820/// canonical event reference (`EventId::canonical()`) a `reconcile` verb accepts verbatim.
3821pub struct EventRow {
3822    /// The canonical event ref (pasteable into a reconcile verb). Named `reff` — `ref` is reserved.
3823    pub reff: String,
3824    /// A stable human kind tag: transfer-in | transfer-out | unclassified | import-conflict | income.
3825    pub kind: &'static str,
3826    /// The event's tax-timezone calendar date.
3827    pub date: TaxDate,
3828    /// Principal sats, when the payload carries a structured amount (None for unclassified/conflict).
3829    pub sat: Option<btctax_core::Sat>,
3830    /// USD value at the event-date close (stored FMV for income; else priced), when resolvable.
3831    pub usd: Option<Usd>,
3832    /// `Some("decision|N")` when a live (non-voided) decision targets this event; `None` = still
3833    /// decidable (a pseudo-defaulted event is decidable — its default is never persisted).
3834    pub decision_ref: Option<String>,
3835}
3836
3837/// Format sats as a BTC amount with 8 decimals (integer math — no float).
3838fn fmt_btc(sat: btctax_core::Sat) -> String {
3839    let whole = sat / 100_000_000;
3840    let frac = (sat % 100_000_000).unsigned_abs();
3841    format!("{whole}.{frac:08}")
3842}
3843
3844/// UX-P4-11: render the `events list` table. Ref-first per row (so it is trivially copyable), then
3845/// kind @ date, amount, and the bracketed decision status. Read-only display.
3846pub fn render_events_list(rows: &[EventRow]) -> String {
3847    let mut out = String::new();
3848    if rows.is_empty() {
3849        let _ = writeln!(out, "No decidable events.");
3850        return out;
3851    }
3852    let decided = rows.iter().filter(|r| r.decision_ref.is_some()).count();
3853    let _ = writeln!(
3854        out,
3855        "Decidable events — {} ({} decided, {} open):",
3856        rows.len(),
3857        decided,
3858        rows.len() - decided
3859    );
3860    for r in rows {
3861        let amount = match (r.sat, r.usd) {
3862            (Some(s), Some(u)) => format!("{} BTC (~${})", fmt_btc(s), fmt_money(u)),
3863            (Some(s), None) => format!("{} BTC", fmt_btc(s)),
3864            (None, _) => "—".to_string(),
3865        };
3866        let status = match &r.decision_ref {
3867            Some(d) => format!("[decided: {d}]"),
3868            None => "[decidable]".to_string(),
3869        };
3870        let _ = writeln!(
3871            out,
3872            "  {}  {} @ {}  {}  {}",
3873            r.reff, r.kind, r.date, amount, status
3874        );
3875    }
3876    out
3877}
3878
3879#[cfg(test)]
3880mod advisory_wrap_tests {
3881    use super::*;
3882
3883    /// `p5-n5-advisory-line-wrapping`: an advisory is a 300–400-character sentence, and the house style
3884    /// wraps everywhere else. An unwrapped one is unreadable in an 80-column terminal — and it is the ONE
3885    /// place the tool explains a conservative omission, so it is the text most worth reading.
3886    #[test]
3887    fn advisories_wrap_to_the_house_width_with_a_hanging_indent() {
3888        use btctax_core::tax::advisories::Advisory;
3889        let out = render_advisories(&[Advisory::CtcOdcOmitted { dependents: 2 }]);
3890
3891        for line in out.lines() {
3892            assert!(
3893                line.chars().count() <= ADVISORY_WRAP_COLS,
3894                "line is {} cols, over the {}-col house width: {line:?}",
3895                line.chars().count(),
3896                ADVISORY_WRAP_COLS
3897            );
3898        }
3899        // Continuation lines hang under the bullet's TEXT, not under the bullet.
3900        assert!(
3901            out.lines()
3902                .any(|l| l.starts_with("    ") && !l.trim().is_empty()),
3903            "a 300-char advisory must wrap onto continuation lines, got:\n{out}"
3904        );
3905    }
3906}
3907
3908#[cfg(test)]
3909mod events_list_render_tests {
3910    use super::*;
3911    use time::macros::date;
3912
3913    fn row(reff: &str, kind: &'static str, decision_ref: Option<&str>) -> EventRow {
3914        EventRow {
3915            reff: reff.to_owned(),
3916            kind,
3917            date: date!(2025 - 03 - 01),
3918            sat: Some(5_000_000),
3919            usd: Some(rust_decimal_macros::dec!(4271.78)),
3920            decision_ref: decision_ref.map(str::to_owned),
3921        }
3922    }
3923
3924    /// Empty → an explicit "none" line (never a blank rendering).
3925    #[test]
3926    fn empty_renders_a_none_line() {
3927        assert_eq!(render_events_list(&[]), "No decidable events.\n");
3928    }
3929
3930    /// Each row is ref-FIRST (trivially copyable), carries kind/date/BTC(+USD), and a bracketed status:
3931    /// `[decidable]` when open, `[decided: decision|N]` when a decision targets it.
3932    #[test]
3933    fn rows_are_ref_first_with_bracketed_status() {
3934        let out = render_events_list(&[
3935            row("import|coinbase|in|cb-recv", "transfer-in", None),
3936            row(
3937                "import|coinbase|out|cb-send",
3938                "transfer-out",
3939                Some("decision|1"),
3940            ),
3941        ]);
3942        let lines: Vec<&str> = out.lines().collect();
3943        assert!(
3944            lines[0].contains("2 (1 decided, 1 open)"),
3945            "header: {}",
3946            lines[0]
3947        );
3948        // ref is the first whitespace token on each row (the paste contract).
3949        assert_eq!(
3950            lines[1].split_whitespace().next(),
3951            Some("import|coinbase|in|cb-recv")
3952        );
3953        assert!(lines[1].contains("[decidable]"), "open row: {}", lines[1]);
3954        assert!(
3955            lines[1].contains("0.05000000 BTC") && lines[1].contains("4271.78"),
3956            "amount: {}",
3957            lines[1]
3958        );
3959        assert!(
3960            lines[2].contains("[decided: decision|1]"),
3961            "decided row: {}",
3962            lines[2]
3963        );
3964    }
3965}
3966
3967#[cfg(test)]
3968mod holdings_pending_tests {
3969    //! UX-P4-6 — the holdings view shows a BTC-unit pending line when sats sit unreconciled, and
3970    //! hides it on a reconciled ledger. `report` otherwise never mentioned pending (only `verify` did).
3971    use super::*;
3972    use btctax_core::state::PendingTransfer;
3973    use btctax_core::EventId;
3974
3975    #[test]
3976    fn holdings_pending_line_shows_in_btc_and_hides_when_reconciled() {
3977        let mut pending = LedgerState::default();
3978        pending.stats.sigma_pending = 3_000_000; // 0.03 BTC unreconciled
3979        pending.pending_reconciliation = vec![PendingTransfer {
3980            event: EventId::decision(1),
3981            principal_sat: 3_000_000,
3982            fee_sat: None,
3983            legs: vec![],
3984        }];
3985        let shown = render_report(&pending, None);
3986        assert!(shown.contains("Pending:"), "pending line present: {shown}");
3987        assert!(shown.contains("0.03000000 BTC"), "BTC unit: {shown}");
3988        assert!(
3989            shown.contains("1 unreconciled transfer"),
3990            "names the count (singular): {shown}"
3991        );
3992        assert!(shown.contains("verify"), "points at `verify`: {shown}");
3993
3994        // Reconciled ledger: no pending sats → no pending line at all.
3995        let reconciled = LedgerState::default();
3996        let hidden = render_report(&reconciled, None);
3997        assert!(
3998            !hidden.contains("Pending:"),
3999            "no pending line when reconciled: {hidden}"
4000        );
4001    }
4002
4003    #[test]
4004    fn holdings_pending_line_pluralizes_multiple_transfers() {
4005        let mut pending = LedgerState::default();
4006        pending.stats.sigma_pending = 150_000_000; // 1.5 BTC
4007        pending.pending_reconciliation = vec![
4008            PendingTransfer {
4009                event: EventId::decision(1),
4010                principal_sat: 100_000_000,
4011                fee_sat: None,
4012                legs: vec![],
4013            },
4014            PendingTransfer {
4015                event: EventId::decision(2),
4016                principal_sat: 50_000_000,
4017                fee_sat: None,
4018                legs: vec![],
4019            },
4020        ];
4021        let shown = render_report(&pending, None);
4022        assert!(
4023            shown.contains("2 unreconciled transfers"),
4024            "plural: {shown}"
4025        );
4026        assert!(shown.contains("1.50000000 BTC"), "{shown}");
4027    }
4028}
4029
4030#[cfg(test)]
4031mod decision_class_tests {
4032    //! UX-P4-7 — the shared SCREEN-ONLY human formatter for decision-payload class fields, replacing
4033    //! the raw `{:?}` Debug dumps (`SelfTransferMine { basis: Some(19000.00), acquired_at:
4034    //! Some(2026-01-01) }`) that the CLI bulk-void preview + TUI void list truncated mid-field.
4035    use super::*;
4036    use btctax_core::{DisposeKind, InboundClass, IncomeKind, OutflowClass};
4037    use rust_decimal_macros::dec;
4038    use time::macros::date;
4039
4040    #[test]
4041    fn inbound_self_transfer_mine_is_human_no_debug_struct() {
4042        let s = describe_inbound_class(&InboundClass::SelfTransferMine {
4043            basis: Some(dec!(19000)),
4044            acquired_at: Some(date!(2026 - 01 - 01)),
4045        });
4046        assert!(s.contains("self-transfer"), "{s}");
4047        assert!(s.contains("$19000.00"), "names the basis in $: {s}");
4048        assert!(s.contains("2026-01-01"), "names the acquired date: {s}");
4049        assert!(!s.contains('{'), "no Debug struct braces: {s}");
4050        assert!(!s.contains("Some("), "no Debug Option wrapper: {s}");
4051    }
4052
4053    #[test]
4054    fn inbound_self_transfer_mine_defaults_are_named_not_none() {
4055        let s = describe_inbound_class(&InboundClass::SelfTransferMine {
4056            basis: None,
4057            acquired_at: None,
4058        });
4059        assert!(s.contains("self-transfer"), "{s}");
4060        assert!(
4061            s.matches("default").count() >= 2,
4062            "None basis AND date read as 'default': {s}"
4063        );
4064        assert!(!s.contains("None"), "no Debug None: {s}");
4065    }
4066
4067    #[test]
4068    fn inbound_income_names_kind_fmv_business() {
4069        let s = describe_inbound_class(&InboundClass::Income {
4070            kind: IncomeKind::Mining,
4071            fmv: Some(dec!(500)),
4072            business: true,
4073        });
4074        assert!(s.contains("income"), "{s}");
4075        assert!(s.contains("mining"), "names the income kind: {s}");
4076        assert!(s.contains("$500.00"), "names the fmv: {s}");
4077        assert!(s.contains("business"), "flags business: {s}");
4078        assert!(!s.contains('{'), "{s}");
4079    }
4080
4081    #[test]
4082    fn inbound_gift_received_names_fmv() {
4083        let s = describe_inbound_class(&InboundClass::GiftReceived {
4084            donor_basis: Some(dec!(1000)),
4085            donor_acquired_at: Some(date!(2024 - 05 - 05)),
4086            fmv_at_gift: dec!(30000),
4087        });
4088        assert!(s.contains("gift"), "{s}");
4089        assert!(s.contains("$30000.00"), "names the FMV at gift: {s}");
4090        assert!(!s.contains('{'), "{s}");
4091    }
4092
4093    #[test]
4094    fn outflow_classes_are_human() {
4095        assert_eq!(
4096            describe_outflow_class(&OutflowClass::Dispose {
4097                kind: DisposeKind::Sell
4098            }),
4099            "sell"
4100        );
4101        assert_eq!(
4102            describe_outflow_class(&OutflowClass::Dispose {
4103                kind: DisposeKind::Spend
4104            }),
4105            "spend"
4106        );
4107        assert_eq!(describe_outflow_class(&OutflowClass::GiftOut), "gift");
4108        let donate = describe_outflow_class(&OutflowClass::Donate {
4109            appraisal_required: true,
4110        });
4111        assert!(
4112            donate.contains("donate") && donate.contains("appraisal"),
4113            "{donate}"
4114        );
4115        assert_eq!(
4116            describe_outflow_class(&OutflowClass::Donate {
4117                appraisal_required: false
4118            }),
4119            "donate"
4120        );
4121    }
4122}
4123
4124// ── Defensive Filing status (Approach-B) — `btctax defensive status` ──────────────────────────────
4125//
4126// Plain-text render of `btctax_core::defensive::DefensiveFilingView` — the SAME pure read the
4127// (now-retired) TUI wizard dashboard rendered. Every figure/advisory here is `journey_view`'s own
4128// output; this function derives no tax logic, only text. Each section names the exact verb to run
4129// next, so a filer can act without a second reference (mirrors `render_events_list`'s own convention
4130// of a copy-pasteable `ref`).
4131
4132fn render_defensive_candidate(s: &Shortfall) -> String {
4133    let wallet = s
4134        .wallet
4135        .as_ref()
4136        .map(wallet_label)
4137        .unwrap_or_else(|| "(no wallet on this shortfall)".to_string());
4138    let mut out = format!(
4139        "  {}  {wallet}  {}  short {} BTC (fee {} BTC)\n",
4140        s.event.canonical(),
4141        s.date,
4142        fmt_btc(s.short_sat),
4143        fmt_btc(s.fee_sat)
4144    );
4145    match &s.wallet {
4146        Some(w) => {
4147            let _ = writeln!(
4148                out,
4149                "    -> btctax declare-tranche --amount {} --wallet {} --window-start <earliest \
4150                 plausible date> --window-end {}",
4151                fmt_btc(s.short_sat),
4152                wallet_label(w),
4153                s.date
4154            );
4155        }
4156        None => {
4157            let _ = writeln!(
4158                out,
4159                "    -> btctax declare-tranche --amount {} --wallet <pick one — this shortfall \
4160                 carries none> --window-start <earliest plausible date> --window-end {}",
4161                fmt_btc(s.short_sat),
4162                s.date
4163            );
4164        }
4165    }
4166    out
4167}
4168
4169fn render_defensive_resolve_first(shortfall: &Shortfall, blocker: BlockerKind) -> String {
4170    let wallet = shortfall
4171        .wallet
4172        .as_ref()
4173        .map(wallet_label)
4174        .unwrap_or_else(|| "(no wallet on this shortfall)".to_string());
4175    format!(
4176        "  {}  {wallet}  {}  short {} BTC — blocked by {blocker:?}\n    -> resolve the blocker \
4177         first (see `btctax events list` / `btctax verify`), then re-run `btctax defensive status`\n",
4178        shortfall.event.canonical(),
4179        shortfall.date,
4180        fmt_btc(shortfall.short_sat)
4181    )
4182}
4183
4184fn render_defensive_advisory(a: &Advisory) -> String {
4185    match a {
4186        Advisory::OverCovered { by_sat } => format!(
4187            "      [advisory] this tranche is larger than the shortfall it covers by {} BTC — if a \
4188             later import supplied those coins, promoting files an estimated basis on documented \
4189             coins\n",
4190            fmt_btc(*by_sat)
4191        ),
4192        Advisory::NowDisplacing => "      [advisory] this promoted floor now displaces documented \
4193             basis on a real disposal\n"
4194            .to_string(),
4195        Advisory::MethodInversion(msg) => format!("      [advisory] {msg}\n"),
4196        Advisory::TrancheDip(msg) => format!("      [advisory] {msg}\n"),
4197        Advisory::FeeOnlyPromoteNoop => {
4198            "      [advisory] the shortfall(s) this tranche covers are \
4199             all fee-component — promoting would only ever substantiate fee-sat basis, never \
4200             principal\n"
4201                .to_string()
4202        }
4203        Advisory::WouldDisplaceIfPromoted => {
4204            "      [advisory] promoting this tranche would displace \
4205             documented basis on a real disposal\n"
4206                .to_string()
4207        }
4208    }
4209}
4210
4211fn render_defensive_saving(s: &SavingFlavor) -> String {
4212    match s {
4213        SavingFlavor::ComputedTax { year, delta } => format!(
4214            "      [assess] promoting this tranche would save an estimated ${} in federal tax for \
4215             {year} (clamped, never negative)\n",
4216            fmt_money(*delta)
4217        ),
4218        SavingFlavor::Uncomputable { year, gain_delta } => format!(
4219            "      [assess] {year} cannot price a tax dollar figure yet (no bundled table / no \
4220             stored tax profile / a Hard blocker) — the realized-gain delta alone is ${}\n",
4221            fmt_money(*gain_delta)
4222        ),
4223        SavingFlavor::Named(msg) => format!("      [assess] {msg}\n"),
4224    }
4225}
4226
4227fn render_defensive_tranche(row: &TrancheRow) -> String {
4228    let mut out = format!(
4229        "  {}  {} BTC  {}\n",
4230        row.target.canonical(),
4231        fmt_btc(row.sat),
4232        match row.status {
4233            TrancheStatus::DeclaredZero => "declared ($0, revocable)",
4234            TrancheStatus::Promoted => "promoted (filed, not revocable)",
4235        }
4236    );
4237    if row.status == TrancheStatus::DeclaredZero {
4238        let _ = writeln!(
4239            out,
4240            "    -> btctax promote-tranche {} --provenance <kind> --part-ii-file <path> \
4241             [--i-acknowledge <phrase>]",
4242            row.target.canonical()
4243        );
4244    }
4245    for s in &row.clamped_saving {
4246        out.push_str(&render_defensive_saving(s));
4247    }
4248    for a in &row.advisories {
4249        out.push_str(&render_defensive_advisory(a));
4250    }
4251    out
4252}
4253
4254fn render_defensive_pool_short(ps: &PoolShort) -> String {
4255    format!(
4256        "  {:?}: short {} BTC ({} BTC live in tranche(s) here) — don't declare again; review the \
4257         window/wallet on the existing tranche(s) instead\n",
4258        ps.pool,
4259        fmt_btc(ps.short_sat),
4260        fmt_btc(ps.live_tranche_sat)
4261    )
4262}
4263
4264/// `btctax defensive status`: the SAME `btctax_core::defensive::journey_view` the (now-retired) TUI
4265/// wizard dashboard rendered, as plain text. No new computation — every figure/advisory here is
4266/// `view`'s own output; this function derives no tax logic. Each section names the exact verb to run
4267/// next.
4268pub fn render_defensive_status(view: &DefensiveFilingView) -> String {
4269    let mut out = String::new();
4270
4271    let nothing_outstanding = view.candidates.is_empty()
4272        && view.resolve_first.is_empty()
4273        && view.tranches.is_empty()
4274        && view.still_short.is_empty()
4275        && view.flagged_years.is_empty();
4276
4277    if nothing_outstanding {
4278        let _ = writeln!(
4279            out,
4280            "Nothing outstanding right now — no declare candidate, no blocked resolve-first \
4281             shortfall, no live tranche, and no flagged export year."
4282        );
4283        return out;
4284    }
4285
4286    if !view.candidates.is_empty() {
4287        let _ = writeln!(
4288            out,
4289            "Declare candidates ({}) — shortfalls a new $0 tranche could cover now:",
4290            view.candidates.len()
4291        );
4292        for s in &view.candidates {
4293            out.push_str(&render_defensive_candidate(s));
4294        }
4295        out.push('\n');
4296    }
4297
4298    if !view.resolve_first.is_empty() {
4299        let _ = writeln!(
4300            out,
4301            "Resolve first ({}) — an open blocker on the same pool/timeframe must clear before \
4302             these can be declared:",
4303            view.resolve_first.len()
4304        );
4305        for t in &view.resolve_first {
4306            if let Triage::ResolveFirst { shortfall, blocker } = t {
4307                out.push_str(&render_defensive_resolve_first(shortfall, *blocker));
4308            }
4309        }
4310        out.push('\n');
4311    }
4312
4313    if !view.tranches.is_empty() {
4314        let _ = writeln!(out, "Live tranches ({}):", view.tranches.len());
4315        for row in &view.tranches {
4316            out.push_str(&render_defensive_tranche(row));
4317        }
4318        out.push('\n');
4319    }
4320
4321    if !view.still_short.is_empty() {
4322        let _ = writeln!(out, "Pools still short ({}):", view.still_short.len());
4323        for ps in &view.still_short {
4324            out.push_str(&render_defensive_pool_short(ps));
4325        }
4326        out.push('\n');
4327    }
4328
4329    if !view.flagged_years.is_empty() {
4330        let years: Vec<String> = view.flagged_years.iter().map(|y| y.to_string()).collect();
4331        let _ = writeln!(
4332            out,
4333            "Flagged years needing (re-)export attention ({}): {}",
4334            years.len(),
4335            years.join(", ")
4336        );
4337        let _ = writeln!(
4338            out,
4339            "    -> btctax export-irs-pdf --tax-year <year>  (or `btctax export-snapshot` for the \
4340             CSV projection)"
4341        );
4342        out.push('\n');
4343    }
4344
4345    if view.safe_harbor_blocked {
4346        let _ = writeln!(
4347            out,
4348            "Safe-harbor allocation: BLOCKED — v1 keeps a pre-2025 conservative-filing tranche and \
4349             a safe-harbor allocation mutually exclusive."
4350        );
4351    }
4352
4353    // Trim the single trailing blank line a section may have left.
4354    if out.ends_with("\n\n") {
4355        out.pop();
4356    }
4357    out
4358}
4359
4360#[cfg(test)]
4361mod defensive_status_tests {
4362    use super::*;
4363    use btctax_core::identity::EventId;
4364    use std::collections::BTreeSet;
4365    use time::macros::date;
4366
4367    fn empty_view() -> DefensiveFilingView {
4368        DefensiveFilingView {
4369            candidates: vec![],
4370            resolve_first: vec![],
4371            tranches: vec![],
4372            still_short: vec![],
4373            flagged_years: BTreeSet::new(),
4374            safe_harbor_blocked: false,
4375        }
4376    }
4377
4378    #[test]
4379    fn nothing_outstanding_is_a_single_line_all_clear() {
4380        let out = render_defensive_status(&empty_view());
4381        assert!(out.contains("Nothing outstanding right now"), "{out}");
4382    }
4383
4384    #[test]
4385    fn candidate_names_the_declare_tranche_verb_with_its_own_amount_wallet_and_window_end() {
4386        let mut view = empty_view();
4387        view.candidates.push(Shortfall {
4388            event: EventId::decision(7),
4389            wallet: Some(WalletId::Exchange {
4390                provider: "coinbase".into(),
4391                account: "default".into(),
4392            }),
4393            date: date!(2019 - 03 - 14),
4394            short_sat: 5_000_000,
4395            fee_sat: 0,
4396        });
4397        let out = render_defensive_status(&view);
4398        assert!(out.contains("Declare candidates (1)"), "{out}");
4399        assert!(out.contains("decision|7"), "{out}");
4400        assert!(
4401            out.contains(
4402                "btctax declare-tranche --amount 0.05000000 \
4403                 --wallet exchange:coinbase:default"
4404            ),
4405            "{out}"
4406        );
4407        assert!(out.contains("--window-end 2019-03-14"), "{out}");
4408    }
4409
4410    #[test]
4411    fn resolve_first_names_the_blocker_and_the_re_run_remedy_not_declare() {
4412        let mut view = empty_view();
4413        view.resolve_first.push(Triage::ResolveFirst {
4414            shortfall: Shortfall {
4415                event: EventId::decision(9),
4416                wallet: Some(WalletId::SelfCustody {
4417                    label: "cold".into(),
4418                }),
4419                date: date!(2020 - 01 - 01),
4420                short_sat: 1_000_000,
4421                fee_sat: 0,
4422            },
4423            blocker: BlockerKind::UnknownBasisInbound,
4424        });
4425        let out = render_defensive_status(&view);
4426        assert!(out.contains("Resolve first (1)"), "{out}");
4427        assert!(out.contains("blocked by UnknownBasisInbound"), "{out}");
4428        assert!(
4429            !out.contains("declare-tranche"),
4430            "a resolve-first row must not offer the declare verb: {out}"
4431        );
4432    }
4433
4434    #[test]
4435    fn declared_zero_tranche_offers_promote_promoted_tranche_does_not() {
4436        let mut view = empty_view();
4437        view.tranches.push(TrancheRow {
4438            target: EventId::decision(3),
4439            sat: 5_000_000,
4440            status: TrancheStatus::DeclaredZero,
4441            clamped_saving: vec![],
4442            advisories: vec![],
4443        });
4444        view.tranches.push(TrancheRow {
4445            target: EventId::decision(5),
4446            sat: 2_000_000,
4447            status: TrancheStatus::Promoted,
4448            clamped_saving: vec![],
4449            advisories: vec![],
4450        });
4451        let out = render_defensive_status(&view);
4452        assert!(
4453            out.contains("btctax promote-tranche decision|3"),
4454            "declared ($0) row must offer promote-tranche: {out}"
4455        );
4456        assert!(
4457            !out.contains("promote-tranche decision|5"),
4458            "an already-promoted row must not offer promote-tranche again: {out}"
4459        );
4460        assert!(out.contains("declared ($0, revocable)"), "{out}");
4461        assert!(out.contains("promoted (filed, not revocable)"), "{out}");
4462    }
4463
4464    #[test]
4465    fn flagged_years_name_the_export_verb() {
4466        let mut view = empty_view();
4467        view.flagged_years.insert(2024);
4468        view.flagged_years.insert(2025);
4469        let out = render_defensive_status(&view);
4470        assert!(out.contains("2024, 2025"), "{out}");
4471        assert!(out.contains("btctax export-irs-pdf --tax-year"), "{out}");
4472    }
4473
4474    #[test]
4475    fn safe_harbor_blocked_is_named() {
4476        let mut view = empty_view();
4477        view.safe_harbor_blocked = true;
4478        // Needs a non-empty section too, else the all-clear branch short-circuits before this line.
4479        view.flagged_years.insert(2024);
4480        let out = render_defensive_status(&view);
4481        assert!(out.contains("Safe-harbor allocation: BLOCKED"), "{out}");
4482    }
4483}