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