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