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