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