1use crate::config::CliConfig;
4use btctax_adapters::FileReport;
5use btctax_core::conventions::{tax_date, Sat, Usd, TRANSITION_DATE};
6use btctax_core::defensive::discovery::{Shortfall, Triage};
7use btctax_core::defensive::{
8 Advisory, DefensiveFilingView, PoolShort, SavingFlavor, TrancheRow, TrancheStatus,
9};
10use btctax_core::persistence::ImportReport;
11use btctax_core::DonationDetails;
12use btctax_core::{
13 conservation_report, disposal_compliance, form_8283, form_8949, schedule_d,
14 year_donation_deduction, BasisSource, Blocker, BlockerKind, ComplianceStatus,
15 ConservationReport, DisposalCompliance, DisposalLeg, DisposeKind, EventId, EventPayload,
16 Form8283HowAcquired, Form8283Section, Form8949Box, Form8949Part, GiftZone, HarvestReport,
17 HarvestStatus, HarvestTarget, InboundClass, IncomeKind, LedgerEvent, LedgerState, LotMethod,
18 LtcgBracket, OutflowClass, RemovalKind, RemovalLeg, ScheduleDTotals, SeTaxResult, SellReport,
19 SellStatus, Severity, TaxDate, Term, WalletId,
20};
21use btctax_store::fsperms;
22use csv::Writer;
23use std::collections::{BTreeMap, BTreeSet};
24use std::fmt::Write as _;
25use std::path::Path;
26
27fn fmt_money(d: btctax_core::conventions::Usd) -> String {
40 format!("{d:.2}")
41}
42
43fn basis_source_tag(bs: BasisSource) -> &'static str {
48 match bs {
49 BasisSource::ExchangeProvided => "exchange",
50 BasisSource::ComputedFromCost => "cost",
51 BasisSource::FmvAtIncome => "income_fmv",
52 BasisSource::CarriedFromTransfer => "transferred",
53 BasisSource::GiftCarryover => "gift_carryover",
54 BasisSource::GiftFmvFallback => "gift_fmv_fallback",
55 BasisSource::SafeHarborAllocated => "safe_harbor",
56 BasisSource::ReconstructedPerWallet => "reconstructed",
57 BasisSource::SelfTransferInbound => "self_transfer_in",
58 BasisSource::EstimatedConservative => "estimated_conservative",
59 }
60}
61
62fn pseudo_tag(pseudo: bool) -> &'static str {
68 if pseudo {
69 " [PSEUDO]"
70 } else {
71 ""
72 }
73}
74
75fn dispose_kind_tag(dk: DisposeKind) -> &'static str {
76 match dk {
77 DisposeKind::Sell => "sell",
78 DisposeKind::Spend => "spend",
79 }
80}
81
82fn income_kind_tag(ik: IncomeKind) -> &'static str {
83 match ik {
84 IncomeKind::Mining => "mining",
85 IncomeKind::Staking => "staking",
86 IncomeKind::Interest => "interest",
87 IncomeKind::Airdrop => "airdrop",
88 IncomeKind::Reward => "reward",
89 }
90}
91
92pub fn describe_inbound_class(c: &InboundClass) -> String {
99 match c {
100 InboundClass::Income {
101 kind,
102 fmv,
103 business,
104 } => {
105 let mut s = format!("income {}", income_kind_tag(*kind));
106 if let Some(v) = fmv {
107 let _ = write!(s, ", fmv ${}", fmt_money(*v));
108 }
109 if *business {
110 s.push_str(", business");
111 }
112 s
113 }
114 InboundClass::GiftReceived {
115 donor_basis,
116 donor_acquired_at,
117 fmv_at_gift,
118 } => {
119 let mut s = format!("gift received, fmv ${}", fmt_money(*fmv_at_gift));
120 if let Some(b) = donor_basis {
121 let _ = write!(s, ", donor-basis ${}", fmt_money(*b));
122 }
123 if let Some(d) = donor_acquired_at {
124 let _ = write!(s, ", donor-acquired {d}");
125 }
126 s
127 }
128 InboundClass::SelfTransferMine { basis, acquired_at } => {
129 let basis_s = match basis {
132 Some(v) => format!("${}", fmt_money(*v)),
133 None => "default".to_string(),
134 };
135 let acq_s = match acquired_at {
136 Some(d) => d.to_string(),
137 None => "default".to_string(),
138 };
139 format!("self-transfer (mine), basis {basis_s}, acquired {acq_s}")
140 }
141 }
142}
143
144pub fn describe_outflow_class(c: &OutflowClass) -> String {
147 match c {
148 OutflowClass::Dispose { kind } => dispose_kind_tag(*kind).to_string(),
149 OutflowClass::GiftOut => "gift".to_string(),
150 OutflowClass::Donate { appraisal_required } => {
151 if *appraisal_required {
152 "donate (appraisal required)".to_string()
153 } else {
154 "donate".to_string()
155 }
156 }
157 }
158}
159
160fn gift_zone_tag(gz: GiftZone) -> &'static str {
161 match gz {
162 GiftZone::Gain => "gain",
163 GiftZone::Loss => "loss",
164 GiftZone::NoGainNoLoss => "no_gain_no_loss",
165 }
166}
167
168fn removal_kind_tag(rk: RemovalKind) -> &'static str {
169 match rk {
170 RemovalKind::Gift => "gift",
171 RemovalKind::Donation => "donation",
172 }
173}
174
175fn term_tag(t: Term) -> &'static str {
177 match t {
178 Term::ShortTerm => "short",
179 Term::LongTerm => "long",
180 }
181}
182
183fn form8949_part_tag(p: Form8949Part) -> &'static str {
185 match p {
186 Form8949Part::ShortTerm => "ST",
187 Form8949Part::LongTerm => "LT",
188 }
189}
190
191fn form8949_box_tag(b: Form8949Box) -> &'static str {
196 match b {
197 Form8949Box::C => "C",
198 Form8949Box::F => "F",
199 Form8949Box::I => "I",
200 Form8949Box::L => "L",
201 }
202}
203
204fn form8283_section_tag(s: Form8283Section) -> &'static str {
207 match s {
208 Form8283Section::A => "A",
209 Form8283Section::B => "B",
210 }
211}
212
213fn form8283_how_acquired_tag(h: Form8283HowAcquired) -> &'static str {
216 match h {
217 Form8283HowAcquired::Purchased => "Purchased",
218 Form8283HowAcquired::Gift => "Gift",
219 Form8283HowAcquired::Other => "Other",
220 Form8283HowAcquired::Review => "Review",
221 }
222}
223
224fn compliance_status_tag(cs: &ComplianceStatus) -> String {
233 match cs {
234 ComplianceStatus::StandingOrder { effective_from } => {
235 format!("standing_order:{effective_from}")
236 }
237 ComplianceStatus::Contemporaneous => "contemporaneous".into(),
238 ComplianceStatus::AttestedRecording => "attested_recording".into(),
239 ComplianceStatus::NonCompliant => "non_compliant".into(),
240 }
241}
242
243pub fn render_file_reports(reports: &[FileReport], import: &ImportReport) -> String {
245 let mut out = String::new();
246 let _ = writeln!(out, "Import:");
247 for r in reports {
248 let _ = writeln!(
249 out,
250 " {} [{}]: parsed {} rows -> {} BTC events ({} dropped no-BTC, {} unclassified)",
251 r.source.tag(),
252 r.label,
253 r.parsed_rows,
254 r.btc_events,
255 r.dropped_no_btc,
256 r.unclassified
257 );
258 }
259 let _ = writeln!(
260 out,
261 " appended {} | duplicates {} | NEW import-conflicts {}",
262 import.appended, import.duplicates, import.conflicts
263 );
264 if import.conflicts > 0 {
265 let _ = writeln!(
266 out,
267 " ! resolve conflicts with `reconcile accept-conflict <id>` or `reject-conflict <id>` (see `verify`)"
268 );
269 }
270 out
271}
272
273pub fn wallet_label(w: &WalletId) -> String {
275 match w {
276 WalletId::Exchange { provider, account } => format!("exchange:{provider}:{account}"),
277 WalletId::SelfCustody { label } => format!("self:{label}"),
278 }
279}
280
281pub fn no_lots_message(wallet: &WalletId, at: TaxDate, available: Sat, requested: Sat) -> String {
288 if available == 0 {
289 format!("no BTC available in {} as of {}", wallet_label(wallet), at)
290 } else {
291 format!(
292 "only {} BTC available in {} as of {} (requested {} BTC)",
293 fmt_btc(available),
294 wallet_label(wallet),
295 at,
296 fmt_btc(requested),
297 )
298 }
299}
300
301fn disposal_year(d: &btctax_core::Disposal) -> i32 {
302 d.disposed_at.year()
303}
304
305pub fn render_report(state: &LedgerState, year: Option<i32>) -> String {
307 let mut out = String::new();
308 let yr = |y: i32| year.is_none_or(|f| f == y); let _ = writeln!(out, "Holdings (per wallet):");
311 if state.holdings_by_wallet.is_empty() {
312 let _ = writeln!(out, " none");
313 }
314 for (w, sat) in &state.holdings_by_wallet {
315 let _ = writeln!(out, " {}: {} sat", wallet_label(w), sat);
316 }
317 if state.stats.sigma_pending > 0 {
320 let n = state.pending_reconciliation.len();
321 let plural = if n == 1 { "transfer" } else { "transfers" };
322 let _ = writeln!(
323 out,
324 " Pending: {} BTC ({n} unreconciled {plural} — see `btctax verify`)",
325 fmt_btc(state.stats.sigma_pending),
326 );
327 }
328
329 let _ = writeln!(out, "Lots:");
330 if state.lots.is_empty() {
331 let _ = writeln!(out, " none");
332 }
333 for l in &state.lots {
334 let _ = writeln!(
335 out,
336 " {}#{} {} remaining {} sat | basis {} ({}){}{}",
337 l.lot_id.origin_event_id.canonical(),
338 l.lot_id.split_sequence,
339 wallet_label(&l.wallet),
340 l.remaining_sat,
341 l.usd_basis,
342 basis_source_tag(l.basis_source),
343 if l.basis_pending {
344 " [basis pending]"
345 } else {
346 ""
347 },
348 pseudo_tag(l.pseudo),
349 );
350 }
351
352 let label = match year {
353 Some(y) => format!("(year {y})"),
354 None => "(all years)".to_string(),
355 };
356
357 let disposals: Vec<_> = state
358 .disposals
359 .iter()
360 .filter(|d| yr(disposal_year(d)))
361 .collect();
362 if disposals.is_empty() {
363 let _ = writeln!(out, "Disposals {}: none", label);
364 } else {
365 let _ = writeln!(out, "Disposals {}:", label);
366 for d in disposals {
367 let _ = writeln!(
368 out,
369 " {} @ {} ({})",
370 dispose_kind_tag(d.kind),
371 d.disposed_at,
372 d.event.canonical()
373 );
374 for leg in &d.legs {
375 render_disposal_leg(&mut out, leg);
376 }
377 }
378 }
379
380 let removals: Vec<_> = state
381 .removals
382 .iter()
383 .filter(|r| yr(r.removed_at.year()))
384 .collect();
385 if removals.is_empty() {
386 let _ = writeln!(out, "Removals {}: none", label);
387 } else {
388 let _ = writeln!(out, "Removals {}:", label);
389 for r in removals {
390 let deduction_tag = match r.claimed_deduction {
391 Some(d) => format!(" [claimed deduction {}]", fmt_money(d)),
392 None => String::new(),
393 };
394 let _ = writeln!(
395 out,
396 " {} @ {} ({}){}",
397 removal_kind_tag(r.kind),
398 r.removed_at,
399 r.event.canonical(),
400 deduction_tag
401 );
402 for leg in &r.legs {
403 render_removal_leg(&mut out, leg);
404 }
405 }
406 }
407
408 let income: Vec<_> = state
409 .income_recognized
410 .iter()
411 .filter(|i| yr(i.recognized_at.year()))
412 .collect();
413 if income.is_empty() {
414 let _ = writeln!(out, "Income {}: none", label);
415 } else {
416 let _ = writeln!(out, "Income {}:", label);
417 for i in income {
418 let _ = writeln!(
419 out,
420 " {} @ {} {} sat = {} USD{}{}",
421 income_kind_tag(i.kind),
422 i.recognized_at,
423 i.sat,
424 i.usd_fmv,
425 if i.business { " [business]" } else { "" },
426 pseudo_tag(i.pseudo), );
428 }
429 }
430
431 let charitable_total: btctax_core::conventions::Usd = state
434 .removals
435 .iter()
436 .filter(|r| yr(r.removed_at.year()) && r.kind == RemovalKind::Donation)
437 .filter_map(|r| r.claimed_deduction)
438 .sum();
439 let _ = writeln!(
440 out,
441 "Charitable deduction {} (Schedule A itemized) — BEFORE §170(b) AGI limits / carryover: {}",
442 label,
443 fmt_money(charitable_total)
444 );
445
446 out
447}
448
449fn render_disposal_leg(out: &mut String, leg: &DisposalLeg) {
450 let zone = leg
451 .gift_zone
452 .map(|z| format!(" gift-zone {}", gift_zone_tag(z)))
453 .unwrap_or_default();
454 let _ = writeln!(
455 out,
456 " {} sat: proceeds {} basis {} gain {} {}{}{}",
457 leg.sat,
458 leg.proceeds,
459 leg.basis,
460 leg.gain,
461 term_tag(leg.term),
462 zone,
463 pseudo_tag(leg.pseudo),
464 );
465}
466
467fn render_removal_leg(out: &mut String, leg: &RemovalLeg) {
468 let _ = writeln!(
469 out,
470 " {} sat: basis {} fmv {} {} (zero gain){}",
471 leg.sat,
472 leg.basis,
473 leg.fmv_at_transfer,
474 term_tag(leg.term),
475 pseudo_tag(leg.pseudo),
476 );
477}
478
479pub fn filing_status_tag(fs: btctax_core::FilingStatus) -> &'static str {
487 use btctax_core::FilingStatus::*;
488 match fs {
489 Single => "single",
490 Mfj => "mfj",
491 Mfs => "mfs",
492 HoH => "hoh",
493 Qss => "qss",
494 }
495}
496
497pub fn fee_treatment_display(t: btctax_core::FeeTreatment) -> &'static str {
501 match t {
502 btctax_core::FeeTreatment::TreatmentC => "non-taxable, basis carries (TP8 c)",
503 btctax_core::FeeTreatment::TreatmentB => "taxable mini-disposition (TP8 b)",
504 }
505}
506
507pub fn lot_method_display(m: LotMethod) -> &'static str {
508 match m {
509 LotMethod::Fifo => "FIFO",
510 LotMethod::Lifo => "LIFO",
511 LotMethod::Hifo => "HIFO",
512 }
513}
514
515#[derive(Debug, Clone)]
517pub struct ElectionLine {
518 pub recorded: TaxDate,
519 pub effective_from: TaxDate,
520 pub method: LotMethod,
521 pub wallet: Option<WalletId>,
523 pub note: &'static str,
525}
526
527pub fn voided_targets(events: &[LedgerEvent]) -> BTreeSet<EventId> {
530 events
531 .iter()
532 .filter_map(|e| match &e.payload {
533 EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
534 _ => None,
535 })
536 .collect()
537}
538
539pub fn method_election_lines(
543 events: &[LedgerEvent],
544 voided: &BTreeSet<EventId>,
545) -> Vec<ElectionLine> {
546 let mut election_events: Vec<(u64, &LedgerEvent)> = events
547 .iter()
548 .filter_map(|e| {
549 if let EventId::Decision { seq } = e.id {
550 if matches!(e.payload, EventPayload::MethodElection(_)) {
551 return Some((seq, e));
552 }
553 }
554 None
555 })
556 .collect();
557 election_events.sort_by_key(|(s, _)| *s);
558
559 election_events
560 .iter()
561 .map(|(_, e)| {
562 let EventPayload::MethodElection(me) = &e.payload else {
563 unreachable!("filtered to MethodElection above")
564 };
565 let recorded = tax_date(e.utc_timestamp, e.original_tz);
566 let note = if voided.contains(&e.id) {
567 "voided"
568 } else if me.effective_from < TRANSITION_DATE || me.effective_from < recorded {
569 "backdated/ignored"
570 } else {
571 "in force"
572 };
573 ElectionLine {
574 recorded,
575 effective_from: me.effective_from,
576 method: me.method,
577 wallet: me.wallet.clone(),
578 note,
579 }
580 })
581 .collect()
582}
583
584#[derive(Debug, Clone)]
586pub struct VerifyReport {
587 pub conservation: ConservationReport,
588 pub hard: Vec<Blocker>,
589 pub advisory: Vec<Blocker>,
590 pub pending: usize,
591 pub unknown_basis_inbounds: usize,
592 pub safe_harbor: String,
593 pub declared_pre2025_method: LotMethod,
595 pub pre2025_method_attested: bool,
596 pub elections: Vec<ElectionLine>,
598 pub selection_count: usize,
600 pub compliance: Vec<DisposalCompliance>,
602 pub drift: Vec<String>,
607}
608
609impl VerifyReport {
610 pub fn has_hard_blockers(&self) -> bool {
613 !self.hard.is_empty()
614 }
615}
616
617fn safe_harbor_status(state: &LedgerState, _events: &[LedgerEvent]) -> String {
628 let effective_path_b = state
631 .lots
632 .iter()
633 .any(|l| l.basis_source == BasisSource::SafeHarborAllocated)
634 || state.disposals.iter().any(|d| {
635 d.legs
636 .iter()
637 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
638 })
639 || state.removals.iter().any(|r| {
640 r.legs
641 .iter()
642 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
643 });
644 let unconservable = state
645 .blockers
646 .iter()
647 .any(|b| b.kind == BlockerKind::SafeHarborUnconservable);
648 let timebar = state
649 .blockers
650 .iter()
651 .any(|b| b.kind == BlockerKind::SafeHarborTimebar);
652 if unconservable {
656 "Path B allocation FAILS conservation/eligibility (hard, §7.4) — fix the allocation"
657 .to_string()
658 } else if effective_path_b {
659 "Path B safe-harbor allocation is effective (§7.4)".to_string()
660 } else if timebar {
661 "Path B time-barred -> using Path A (advisory); `reconcile safe-harbor attest` if timely in your books".to_string()
662 } else {
663 "Path A (actual per-wallet reconstruction; default, no election)".to_string()
664 }
665}
666
667pub fn build_verify(
668 state: &LedgerState,
669 events: &[LedgerEvent],
670 prices: &dyn btctax_core::price::PriceProvider,
671 cli: &CliConfig,
672) -> VerifyReport {
673 let conservation = conservation_report(state);
674 let mut hard = Vec::new();
675 let mut advisory = Vec::new();
676 for b in &state.blockers {
677 match b.kind.severity() {
678 Severity::Hard => hard.push(b.clone()),
679 Severity::Advisory => advisory.push(b.clone()),
680 }
681 }
682 let unknown_basis_inbounds = state
683 .blockers
684 .iter()
685 .filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
686 .count();
687
688 let voided = voided_targets(events);
690
691 let elections = method_election_lines(events, &voided);
693
694 let selection_count = events
699 .iter()
700 .filter(|e| matches!(e.payload, EventPayload::LotSelection(_)) && !voided.contains(&e.id))
701 .count();
702
703 let compliance = disposal_compliance(events, state);
705
706 let drift = btctax_core::conservative_promote::promote_drift_advisory(events, prices);
709
710 VerifyReport {
711 conservation,
712 hard,
713 advisory,
714 pending: state.pending_reconciliation.len(),
715 unknown_basis_inbounds,
716 safe_harbor: safe_harbor_status(state, events),
717 declared_pre2025_method: cli.pre2025_method,
718 pre2025_method_attested: cli.pre2025_method_attested,
719 elections,
720 selection_count,
721 compliance,
722 drift,
723 }
724}
725
726pub fn write_csv_exports(
738 out_dir: &Path,
739 state: &LedgerState,
740 tax_year: Option<i32>,
741 se_result: Option<&SeTaxResult>,
742 donation_details: &BTreeMap<EventId, DonationDetails>,
743) -> Result<(), crate::CliError> {
744 fsperms::mkdir_owner_only(out_dir)?;
745
746 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("lots.csv"))?);
747 w.write_record([
748 "origin_event",
749 "split",
750 "wallet",
751 "acquired_at",
752 "remaining_sat",
753 "usd_basis",
754 "basis_source",
755 "basis_pending",
756 ])?;
757 for l in &state.lots {
758 w.write_record([
759 l.lot_id.origin_event_id.canonical(),
760 l.lot_id.split_sequence.to_string(),
761 wallet_label(&l.wallet),
762 l.acquired_at.to_string(),
763 l.remaining_sat.to_string(),
764 l.usd_basis.to_string(),
765 basis_source_tag(l.basis_source).to_string(),
766 l.basis_pending.to_string(),
767 ])?;
768 }
769 w.flush()?;
770
771 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("disposals.csv"))?);
772 w.write_record([
773 "event",
774 "kind",
775 "disposed_at",
776 "lot",
777 "sat",
778 "proceeds",
779 "basis",
780 "gain",
781 "term",
782 "gift_zone",
783 "acquired_at",
784 "wallet",
785 ])?;
786 for d in &state.disposals {
787 for leg in &d.legs {
788 w.write_record([
789 d.event.canonical(),
790 dispose_kind_tag(d.kind).to_string(),
791 d.disposed_at.to_string(),
792 format!(
793 "{}#{}",
794 leg.lot_id.origin_event_id.canonical(),
795 leg.lot_id.split_sequence
796 ),
797 leg.sat.to_string(),
798 leg.proceeds.to_string(),
799 leg.basis.to_string(),
800 leg.gain.to_string(),
801 term_tag(leg.term).to_string(),
802 leg.gift_zone
803 .map(|z| gift_zone_tag(z).to_string())
804 .unwrap_or_default(),
805 leg.acquired_at.to_string(),
806 wallet_label(&leg.wallet),
807 ])?;
808 }
809 }
810 w.flush()?;
811
812 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("removals.csv"))?);
813 w.write_record([
820 "event",
821 "kind",
822 "removed_at",
823 "lot",
824 "sat",
825 "basis",
826 "fmv_at_transfer",
827 "term",
828 "acquired_at",
829 "claimed_deduction",
830 "donee",
831 ])?;
832 for r in &state.removals {
833 let deduction_first = r
834 .claimed_deduction
835 .map(|d| d.to_string())
836 .unwrap_or_default();
837 let donee_cell = r.donee.clone().unwrap_or_default();
838 for (leg_idx, leg) in r.legs.iter().enumerate() {
839 let deduction_cell: &str = if leg_idx == 0 { &deduction_first } else { "" };
842 w.write_record([
843 r.event.canonical(),
844 removal_kind_tag(r.kind).to_string(),
845 r.removed_at.to_string(),
846 format!(
847 "{}#{}",
848 leg.lot_id.origin_event_id.canonical(),
849 leg.lot_id.split_sequence
850 ),
851 leg.sat.to_string(),
852 leg.basis.to_string(),
853 leg.fmv_at_transfer.to_string(),
854 term_tag(leg.term).to_string(),
855 leg.acquired_at.to_string(),
856 deduction_cell.to_string(),
857 donee_cell.clone(),
858 ])?;
859 }
860 }
861 w.flush()?;
862
863 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("income.csv"))?);
864 w.write_record([
865 "event",
866 "kind",
867 "recognized_at",
868 "sat",
869 "usd_fmv",
870 "business",
871 ])?;
872 for i in &state.income_recognized {
873 w.write_record([
874 i.event.canonical(),
875 income_kind_tag(i.kind).to_string(),
876 i.recognized_at.to_string(),
877 i.sat.to_string(),
878 i.usd_fmv.to_string(),
879 i.business.to_string(),
880 ])?;
881 }
882 w.flush()?;
883
884 if let Some(year) = tax_year {
887 write_form8949_csv(out_dir, state, year)?;
888 write_schedule_d_csv(out_dir, state, year)?;
889 write_form8283_csv(out_dir, state, year, donation_details)?;
890 write_basis_methodology_txt(out_dir, state, year)?; if let Some(se) = se_result {
898 write_schedule_se_csv(out_dir, se)?;
899 }
900 }
901 Ok(())
902}
903
904pub fn write_form_csvs(
920 out_dir: &Path,
921 state: &LedgerState,
922 year: i32,
923 se_result: Option<&SeTaxResult>,
924 donation_details: &BTreeMap<EventId, DonationDetails>,
925) -> Result<(), crate::CliError> {
926 fsperms::mkdir_owner_only(out_dir)?;
927 write_form8949_csv(out_dir, state, year)?;
928 write_schedule_d_csv(out_dir, state, year)?;
929 write_form8283_csv(out_dir, state, year, donation_details)?;
930 write_basis_methodology_txt(out_dir, state, year)?; if let Some(se) = se_result {
932 write_schedule_se_csv(out_dir, se)?;
933 }
934 Ok(())
935}
936
937pub fn write_form_8275_txt(
954 out_dir: &Path,
955 state: &LedgerState,
956 events: &[LedgerEvent],
957 year: i32,
958) -> Result<(), crate::CliError> {
959 write_form_8275_txt_named(out_dir, state, events, year, "form_8275.txt")
960}
961
962pub(crate) fn write_form_8275_txt_named(
968 out_dir: &Path,
969 state: &LedgerState,
970 events: &[LedgerEvent],
971 year: i32,
972 filename: &str,
973) -> Result<(), crate::CliError> {
974 use std::io::Write as _;
975 if let Some(disc) = btctax_core::tax::form8275::disclosure_8275(events, state, year) {
976 let mut file = fsperms::open_owner_only(&out_dir.join(filename))?;
977 write!(file, "{}", disc.render())?;
979 }
980 Ok(())
981}
982
983pub(crate) fn write_basis_methodology_txt(
988 out_dir: &Path,
989 state: &LedgerState,
990 year: i32,
991) -> Result<(), crate::CliError> {
992 use std::io::Write as _;
993 if let Some(text) = btctax_core::conservative::basis_methodology(state, year) {
994 let mut file = fsperms::open_owner_only(&out_dir.join("basis_methodology.txt"))?;
995 writeln!(file, "{text}")?;
996 }
997 Ok(())
998}
999
1000fn write_schedule_se_csv(out_dir: &Path, se: &SeTaxResult) -> Result<(), crate::CliError> {
1004 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_se.csv"))?);
1005 w.write_record([
1006 "net_se_earnings",
1007 "se_base_9235",
1008 "ss_component",
1009 "medicare_component",
1010 "additional_medicare_component",
1011 "total_se_tax",
1012 "deductible_half",
1013 ])?;
1014 w.write_record([
1015 se.net_se.to_string(),
1016 se.base.to_string(),
1017 se.ss.to_string(),
1018 se.medicare.to_string(),
1019 se.addl.to_string(),
1020 se.total.to_string(),
1021 se.deductible_half.to_string(),
1022 ])?;
1023 w.flush()?;
1024 Ok(())
1025}
1026
1027pub const FORM_8283_AGGREGATION_CAVEAT: &str =
1033 "Section A/B reflects the \u{00a7}170(f)(11)(F) year-aggregate for similar property: all BTC \
1034 donations in the year are summed (all BTC is 'similar property'); if the year-total claimed \
1035 deduction exceeds $5,000 every row is Section B (qualified appraisal required), otherwise \
1036 Section A. CCA 202302012: the readily-valued exception does not apply to crypto.";
1037
1038pub fn render_donation_appraisal_advisory(state: &LedgerState, year: i32) -> Option<String> {
1056 use btctax_core::QUALIFIED_APPRAISAL_THRESHOLD;
1057 let agg = year_donation_deduction(state, year);
1058 if agg <= QUALIFIED_APPRAISAL_THRESHOLD {
1059 return None;
1060 }
1061 let threshold = fmt_money(QUALIFIED_APPRAISAL_THRESHOLD);
1064 Some(format!(
1065 "\u{00a7}170(f)(11)(F): your {year} BTC donations aggregate ${} of claimed deduction \
1066 (> ${threshold}) \u{2014} a qualified appraisal is required for the donated BTC even if \
1067 no single donation exceeds ${threshold} (all BTC is 'similar property'; CCA 202302012 \
1068 \u{2014} no readily-valued exception for crypto).",
1069 fmt_money(agg)
1070 ))
1071}
1072
1073fn write_form8283_csv(
1080 out_dir: &Path,
1081 state: &LedgerState,
1082 year: i32,
1083 details: &BTreeMap<EventId, DonationDetails>,
1084) -> Result<(), crate::CliError> {
1085 use std::io::Write as _;
1086 let mut file = fsperms::open_owner_only(&out_dir.join("form8283.csv"))?;
1087
1088 writeln!(file, "# {FORM_8283_AGGREGATION_CAVEAT}")?;
1090
1091 let total_deduction = year_donation_deduction(state, year);
1097 if total_deduction <= rust_decimal::Decimal::from(500) {
1098 writeln!(
1099 file,
1100 "# [R0-M1] The year's total noncash charitable deduction ({}) is <= $500; Form 8283 is \
1101 NOT required at that level (rows below are informational only).",
1102 fmt_money(total_deduction)
1103 )?;
1104 }
1105
1106 let mut w = Writer::from_writer(file);
1107 w.write_record([
1108 "section",
1109 "description",
1110 "how_acquired",
1111 "date_acquired",
1112 "date_contributed",
1113 "cost_basis",
1114 "fmv",
1115 "claimed_deduction",
1116 "fmv_method",
1117 "donee",
1118 "appraiser",
1119 "needs_review",
1120 "donee_ein",
1122 "donee_address",
1123 "appraiser_tin",
1124 "appraiser_ptin",
1125 "appraiser_qualifications",
1126 "appraisal_date",
1127 ])?;
1128 for row in form_8283(state, year, details) {
1129 let d = row.details.as_ref();
1130 w.write_record([
1131 row.section
1132 .map(form8283_section_tag)
1133 .unwrap_or("")
1134 .to_string(),
1135 row.description,
1136 form8283_how_acquired_tag(row.how_acquired).to_string(),
1137 row.date_acquired.to_string(),
1138 row.date_contributed.to_string(),
1139 row.cost_basis.to_string(),
1140 row.fmv.to_string(),
1141 row.claimed_deduction
1142 .map(|d| d.to_string())
1143 .unwrap_or_default(),
1144 row.fmv_method,
1145 row.donee,
1146 row.appraiser,
1147 row.needs_review.to_string(),
1148 d.and_then(|d| d.donee_ein.clone()).unwrap_or_default(),
1150 d.and_then(|d| d.donee_address.clone()).unwrap_or_default(),
1151 d.and_then(|d| d.appraiser_tin.clone()).unwrap_or_default(),
1152 d.and_then(|d| d.appraiser_ptin.clone()).unwrap_or_default(),
1153 d.and_then(|d| d.appraiser_qualifications.clone())
1154 .unwrap_or_default(),
1155 d.and_then(|d| d.appraisal_date.map(|dt| dt.to_string()))
1156 .unwrap_or_default(),
1157 ])?;
1158 }
1159 w.flush()?;
1160 Ok(())
1161}
1162
1163fn write_form8949_csv(
1166 out_dir: &Path,
1167 state: &LedgerState,
1168 year: i32,
1169) -> Result<(), crate::CliError> {
1170 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("form8949.csv"))?);
1171 w.write_record([
1172 "part",
1173 "box",
1174 "box_needs_review",
1175 "description",
1176 "date_acquired",
1177 "date_sold",
1178 "proceeds",
1179 "cost_basis",
1180 "adjustment_code",
1181 "adjustment_amount",
1182 "gain",
1183 "wallet",
1184 "disposition_kind",
1185 ])?;
1186 for r in form_8949(state, year) {
1187 w.write_record([
1188 form8949_part_tag(r.part).to_string(),
1189 form8949_box_tag(r.box_).to_string(),
1190 r.box_needs_review.to_string(),
1191 r.description,
1192 r.date_acquired.to_string(),
1193 r.date_sold.to_string(),
1194 r.proceeds.to_string(),
1195 r.cost_basis.to_string(),
1196 r.adjustment_code,
1197 r.adjustment_amount.to_string(),
1198 r.gain.to_string(),
1199 wallet_label(&r.wallet),
1200 dispose_kind_tag(r.disposition_kind).to_string(),
1201 ])?;
1202 }
1203 w.flush()?;
1204 Ok(())
1205}
1206
1207fn write_schedule_d_csv(
1210 out_dir: &Path,
1211 state: &LedgerState,
1212 year: i32,
1213) -> Result<(), crate::CliError> {
1214 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_d.csv"))?);
1215 w.write_record(["part", "proceeds", "cost_basis", "gain"])?;
1216 let totals = schedule_d(state, year);
1217 for (part, p) in [("ST", &totals.st), ("LT", &totals.lt)] {
1218 w.write_record([
1219 part.to_string(),
1220 p.proceeds.to_string(),
1221 p.cost_basis.to_string(),
1222 p.gain.to_string(),
1223 ])?;
1224 }
1225 w.flush()?;
1226 Ok(())
1227}
1228
1229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1236pub enum PseudoDisclosure {
1237 None,
1239 Synthetic,
1241 Placeholder,
1243}
1244
1245impl PseudoDisclosure {
1246 pub fn contributed(self) -> bool {
1248 self != PseudoDisclosure::None
1249 }
1250 pub fn suffix(self) -> &'static str {
1253 if self.contributed() {
1254 " [PSEUDO]"
1255 } else {
1256 ""
1257 }
1258 }
1259 pub fn banner(self) -> &'static str {
1262 match self {
1263 PseudoDisclosure::None => "",
1264 PseudoDisclosure::Synthetic => {
1265 "⚠ [PSEUDO] This vault has pseudo-reconciled (deliberately-synthetic) entries; figures shown \
1266 are an ESTIMATE, not filing-ready. See '[PSEUDO]' rows in 'btctax report' and the \
1267 [PseudoReconcileActive] advisory in 'btctax verify'; resolve them before filing.\n"
1268 }
1269 PseudoDisclosure::Placeholder => {
1270 "⚠ [PSEUDO] These figures are estimated on a synthetic $0 placeholder profile — no tax \
1271 profile or full-return inputs are stored for this year. This is an ESTIMATE, not \
1272 filing-ready. Set a tax profile ('btctax tax-profile --year <Y> …' — setting is the \
1273 default; '--show' inverts), import inputs ('btctax income import'), or turn pseudo mode off \
1274 ('btctax reconcile pseudo off').\n"
1275 }
1276 }
1277 }
1278}
1279
1280pub fn render_tax_outcome(
1291 year: i32,
1292 out: &btctax_core::TaxOutcome,
1293 advisory: Option<&str>,
1294 pseudo: PseudoDisclosure,
1295) -> String {
1296 use btctax_core::TaxOutcome::*;
1297 let mut s = String::new();
1298 s.push_str(pseudo.banner());
1299 let _ = writeln!(s, "Federal tax attributable to crypto — tax year {year}");
1300 match out {
1301 NotComputable(b) => {
1302 let _ = writeln!(s, " NOT COMPUTABLE [{:?}]: {}", b.kind, b.detail);
1303 }
1304 Computed(r) => {
1305 let _ = writeln!(
1306 s,
1307 " net short-term: {} net long-term: {}",
1308 fmt_money(r.st_net),
1309 fmt_money(r.lt_net)
1310 );
1311 let _ = writeln!(
1312 s,
1313 " crypto ordinary income (level): {}",
1314 fmt_money(r.ordinary_from_crypto)
1315 );
1316 let ordinary_rate_attributable = r.total_federal_tax_attributable - r.ltcg_tax - r.niit;
1319 let _ = writeln!(
1320 s,
1321 " ordinary-rate tax (attributable): {}",
1322 fmt_money(ordinary_rate_attributable)
1323 );
1324 let _ = writeln!(
1325 s,
1326 " LTCG tax (attributable): {} NIIT (attributable): {}",
1327 fmt_money(r.ltcg_tax),
1328 fmt_money(r.niit)
1329 );
1330 let _ = writeln!(
1331 s,
1332 " TOTAL federal tax attributable to crypto (delta): {}{} \
1333 (= ordinary-rate + LTCG + NIIT attributable)",
1334 fmt_money(r.total_federal_tax_attributable),
1335 pseudo.suffix()
1336 );
1337 let _ = writeln!(
1338 s,
1339 " §1211 loss deduction (level): {} carryforward out: short {} / long {}",
1340 fmt_money(r.loss_deduction),
1341 fmt_money(r.carryforward_out.short),
1342 fmt_money(r.carryforward_out.long)
1343 );
1344 let _ = writeln!(
1345 s,
1346 " marginal rates: ordinary {} / LTCG {} / NIIT increased by crypto: {}",
1347 r.marginal_rates.ordinary,
1348 r.marginal_rates.ltcg,
1349 if r.marginal_rates.niit_applies {
1350 "yes"
1351 } else {
1352 "no"
1353 }
1354 );
1355 let _ = writeln!(
1356 s,
1357 " (incremental ceteris-paribus delta on the minimal profile; \
1358 excludes AGI-driven SS/IRMAA/AMT/QBI/phaseout effects — I5. §1411 NIIT reduces NII by the \
1359 §1211(b)-allowed net capital loss (≤ $3,000 / $1,500 MFS — Form 8960 line 5a / §1.1411-4(d)) \
1360 and is floored at $0; crypto ordinary income (mining/staking/airdrops/rewards) is correctly \
1361 excluded from NII; crypto-lending interest income (§1411(c)(1)(A)(i)) is INCLUDED in NII; \
1362 mining/staking/airdrops/rewards remain excluded (SE income per §1411(c)(6) or non-NII other income).)"
1363 );
1364 }
1365 }
1366 if let Some(msg) = advisory {
1369 let _ = writeln!(s, " ADVISORY (M4): {msg}");
1370 }
1371 s
1372}
1373
1374pub(crate) const ADVISORY_WRAP_COLS: usize = 92;
1376
1377pub(crate) fn wrap_bulleted(text: &str) -> String {
1382 const BULLET: &str = " \u{2022} ";
1383 const HANG: &str = " ";
1384 let mut out = String::new();
1385 let mut line = String::from(BULLET);
1386 let mut have_word = false;
1387
1388 for word in text.split_whitespace() {
1389 let prospective = line.chars().count() + usize::from(have_word) + word.chars().count();
1390 if have_word && prospective > ADVISORY_WRAP_COLS {
1391 out.push_str(line.trim_end());
1392 out.push('\n');
1393 line = String::from(HANG);
1394 have_word = false;
1395 }
1396 if have_word {
1397 line.push(' ');
1398 }
1399 line.push_str(word);
1400 have_word = true;
1401 }
1402 out.push_str(line.trim_end());
1403 out
1404}
1405
1406pub fn render_advisories(advisories: &[btctax_core::tax::advisories::Advisory]) -> String {
1410 let mut s = String::new();
1411 if advisories.is_empty() {
1412 return s;
1413 }
1414 let _ = writeln!(s, "\n ── ADVISORIES ({}) ──", advisories.len());
1415 for a in advisories {
1416 let _ = writeln!(s, "{}", wrap_bulleted(&a.message()));
1420 }
1421 let _ = writeln!(
1422 s,
1423 " (Advisories never change a number and never fail the command. See `btctax limitations`.)"
1424 );
1425 s
1426}
1427
1428pub fn provenance_label(p: crate::resolve::Provenance) -> &'static str {
1431 use crate::resolve::Provenance::*;
1432 match p {
1433 ReturnInputs => "ReturnInputs (derived from line items)",
1434 StoredProfile => "stored TaxProfile (raw override)",
1435 PseudoPlaceholder => "pseudo-reconcile placeholder ($0)",
1436 Missing => "none (TaxProfileMissing)",
1437 }
1438}
1439
1440pub fn render_dual_report(
1455 year: i32,
1456 ar: &btctax_core::AbsoluteReturn,
1457 printed: &btctax_core::tax::packet::PrintedForms,
1458 delta: &btctax_core::TaxOutcome,
1459 provenance: crate::resolve::Provenance,
1460 pseudo: PseudoDisclosure,
1461) -> String {
1462 let f = &printed.f1040;
1463 let mut s = String::new();
1464 let _ = writeln!(
1465 s,
1466 "\n═══ Absolute filed return (Form 1040) — tax year {year} ═══"
1467 );
1468 let _ = writeln!(s, " Profile source: {}", provenance_label(provenance));
1469 let _ = writeln!(s, " Total income (1040 L9): {}", fmt_money(f.line9));
1470 let _ = writeln!(s, " Adjustments (L10): {}", fmt_money(f.line10));
1471 let _ = writeln!(s, " AGI (L11): {}", fmt_money(f.line11));
1472 let ded_kind = if ar.deduction_is_itemized {
1473 "itemized"
1474 } else {
1475 "standard"
1476 };
1477 let _ = writeln!(s, " Deduction (L12, {ded_kind}): {}", fmt_money(f.line12));
1478 if ar.qbi_deduction > Usd::ZERO {
1479 let _ = writeln!(s, " QBI deduction (L13): {}", fmt_money(f.line13));
1480 }
1481 let _ = writeln!(s, " Taxable income (L15): {}", fmt_money(f.line15));
1482 let _ = writeln!(s, " Tax (L16): {}", fmt_money(f.line16));
1483 if ar.foreign_tax_credit > Usd::ZERO {
1484 let _ = writeln!(
1485 s,
1486 " Foreign tax credit (Sch 3 L1): {}",
1487 fmt_money(printed.sch_3.map_or(Usd::ZERO, |s3| s3.line1))
1488 );
1489 }
1490 if ar.se_tax_sch2_l4 > Usd::ZERO {
1491 let _ = writeln!(
1492 s,
1493 " Self-employment tax (Sch 2 L4): {}",
1494 fmt_money(printed.sch_2.map_or(Usd::ZERO, |s2| s2.line4))
1495 );
1496 }
1497 if ar.additional_medicare.additional_medicare_tax > Usd::ZERO {
1498 let _ = writeln!(
1499 s,
1500 " Additional Medicare (Form 8959 → Sch 2 L11): {}",
1501 fmt_money(printed.f8959.line18)
1502 );
1503 }
1504 if ar.niit.tax > Usd::ZERO {
1505 let _ = writeln!(
1506 s,
1507 " Net Investment Income Tax (Form 8960 → Sch 2 L12): {}",
1508 fmt_money(printed.f8960.map_or(Usd::ZERO, |f| f.line17))
1509 );
1510 }
1511 if ar.amt.line6 > Usd::ZERO {
1529 let _ = writeln!(
1530 s,
1531 " Alternative Minimum Tax (Form 6251 → Sch 2 L2): {}\n \
1532 AMTI (L4) {} · exemption (L5) {} · tentative minimum tax (L9) {} · regular tax (L10) {}\n \
1533 {}",
1534 fmt_money(ar.amt.line11),
1535 fmt_money(ar.amt.line4),
1536 fmt_money(ar.amt.line5),
1537 fmt_money(ar.amt.line9),
1538 fmt_money(ar.amt.line10),
1539 if ar.amt.must_attach() {
1540 "Form 6251 line 7 exceeds line 10 → the form MUST be attached (i6251, Who Must File, \
1541 condition 1)."
1542 } else {
1543 "Line 7 does not exceed line 10 → no Form 6251 attachment is required."
1544 }
1545 );
1546 }
1547 let _ = writeln!(
1548 s,
1549 " TOTAL TAX (L24): {}{}",
1550 fmt_money(f.line24),
1551 pseudo.suffix()
1552 );
1553 let _ = writeln!(s, " Total payments (L33): {}", fmt_money(f.line33));
1554 if f.line34 > Usd::ZERO {
1555 let _ = writeln!(s, " → REFUND (L35a): {}", fmt_money(f.line34));
1556 } else {
1557 let _ = writeln!(s, " → AMOUNT OWED (L37): {}", fmt_money(f.line37));
1558 }
1559 let delta_str = match delta {
1561 btctax_core::TaxOutcome::Computed(r) => fmt_money(r.total_federal_tax_attributable),
1562 btctax_core::TaxOutcome::NotComputable(_) => "not computable".to_string(),
1563 };
1564 let _ = writeln!(
1565 s,
1566 "\n ── Two DIFFERENT questions — NOT reconciled (SPEC §6) ──"
1567 );
1568 let _ = writeln!(
1569 s,
1570 " • Absolute TOTAL TAX (this filed return, WITH crypto): {}{}",
1571 fmt_money(f.line24),
1572 pseudo.suffix()
1573 );
1574 let _ = writeln!(
1575 s,
1576 " • Crypto-attributable tax (DELTA, shown above): {delta_str}"
1577 );
1578 let _ = writeln!(
1579 s,
1580 " The delta's implied deduction is fixed at derivation time (non-crypto AGI), so it is \
1581 APPROXIMATE where a\n deduction is AGI-sensitive (e.g. the 7.5% medical floor); the two do NOT \
1582 reconcile to the dollar."
1583 );
1584 s
1585}
1586
1587pub fn render_schedule_d(
1597 year: i32,
1598 totals: &ScheduleDTotals,
1599 outcome: &btctax_core::TaxOutcome,
1600) -> String {
1601 let mut s = String::new();
1602 let _ = writeln!(
1603 s,
1604 "Schedule D (raw pre-netting part totals) — tax year {year}"
1605 );
1606 let _ = writeln!(
1607 s,
1608 " Part I (short-term): proceeds {} cost basis {} gain {}",
1609 fmt_money(totals.st.proceeds),
1610 fmt_money(totals.st.cost_basis),
1611 fmt_money(totals.st.gain)
1612 );
1613 let _ = writeln!(
1614 s,
1615 " Part II (long-term): proceeds {} cost basis {} gain {}",
1616 fmt_money(totals.lt.proceeds),
1617 fmt_money(totals.lt.cost_basis),
1618 fmt_money(totals.lt.gain)
1619 );
1620 match outcome {
1621 btctax_core::TaxOutcome::NotComputable(_) => {
1622 let _ = writeln!(
1623 s,
1624 " (raw disposition totals shown above; the year's tax is NOT COMPUTABLE until \
1625 the blocker is resolved — these Form 8949/Schedule D part totals are \
1626 informational and are not netted/carried until the tax computes)."
1627 );
1628 }
1629 btctax_core::TaxOutcome::Computed(_) => {
1630 let _ = writeln!(
1631 s,
1632 " Note: §1222/§1211/§1212 netting + carryforward are applied in the tax \
1633 computation (report --tax-year); these are the raw pre-netting Form \
1634 8949/Schedule D part totals."
1635 );
1636 }
1637 }
1638 s
1639}
1640
1641pub fn render_schedule_se(
1663 year: i32,
1664 result: Option<&SeTaxResult>,
1665 gross_se: Usd,
1666 table_present: bool,
1667 schedule_c_expenses: Usd,
1668 w2_ss_wages: Usd,
1669 w2_medicare_wages: Usd,
1670) -> Option<String> {
1671 match result {
1672 Some(r) => {
1673 let mut s = String::new();
1674 let _ = writeln!(
1675 s,
1676 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1677 );
1678 if schedule_c_expenses > Usd::ZERO {
1680 let gross_display = r.net_se + schedule_c_expenses;
1683 let _ = writeln!(
1684 s,
1685 " gross business income {} \u{2212} Schedule C expenses {} = net SE earnings {}",
1686 fmt_money(gross_display),
1687 fmt_money(schedule_c_expenses),
1688 fmt_money(r.net_se)
1689 );
1690 let _ = writeln!(
1692 s,
1693 " (Schedule C advisory) Schedule C expenses also reduce your ORDINARY taxable \
1694 income, but the income-tax total above uses GROSS crypto income \u{2014} to first \
1695 order it OVERSTATES your tax by your marginal ordinary rate applied to {}. The tax \
1696 profile cannot express this (an `ordinary_taxable_income` edit would shift both \
1697 legs of the crypto-attributable delta); the engine-side coordination is deferred \
1698 \u{2014} coordinate it on your actual return.",
1699 fmt_money(schedule_c_expenses)
1700 );
1701 } else {
1702 let _ = writeln!(
1703 s,
1704 " net self-employment income (business crypto, Interest excluded): {}",
1705 fmt_money(r.net_se)
1706 );
1707 let _ = writeln!(
1708 s,
1709 " (Schedule C) no Schedule C expenses supplied (--schedule-c-expenses)"
1710 );
1711 }
1712 let _ = writeln!(
1713 s,
1714 " \u{00d7} 92.35% net-earnings factor (\u{00a7}1402(a)) = net SE earnings: {}",
1715 fmt_money(r.base)
1716 );
1717 let _ = writeln!(
1718 s,
1719 " Social Security component (12.4%, §1401(a); capped at the SS wage base): {}",
1720 fmt_money(r.ss)
1721 );
1722 let _ = writeln!(
1723 s,
1724 " Medicare component (2.9%, §1401(b); uncapped): {}",
1725 fmt_money(r.medicare)
1726 );
1727 let _ = writeln!(
1728 s,
1729 " Additional Medicare component (0.9%, §1401(b)(2)): {}",
1730 fmt_money(r.addl)
1731 );
1732 let _ = writeln!(
1733 s,
1734 " TOTAL self-employment tax (§1401): {}",
1735 fmt_money(r.total)
1736 );
1737 let _ = writeln!(
1738 s,
1739 " §164(f) one-half-SE-tax deduction (above-the-line; EXCLUDES Additional Medicare per \
1740 §164(f)(1)): {}",
1741 fmt_money(r.deductible_half)
1742 );
1743 let _ = writeln!(
1746 s,
1747 " (§164(f) advisory) The §164(f) deduction ({}) is NOT auto-coordinated into the \
1748 income-tax total above — to first order, that total overstates your combined tax by \
1749 your marginal ordinary rate applied to {}. The tax profile cannot express this deduction \
1750 directly (reducing `ordinary_taxable_income` would shift BOTH legs of the \
1751 crypto-attributable delta and only correct the bracket differential, not the level) — \
1752 coordinate it on your actual return.",
1753 fmt_money(r.deductible_half),
1754 fmt_money(r.deductible_half)
1755 );
1756 if w2_ss_wages > Usd::ZERO || w2_medicare_wages > Usd::ZERO {
1759 let _ = writeln!(
1760 s,
1761 " (W-2 coordination applied) SS cap = max(0, wage base \u{2212} {}) (Box 3+7); \
1762 Additional-Medicare threshold reduced (not below 0) by {} (Box 5, \
1763 §1401(b)(2)(B)/Form 8959 Part II).",
1764 fmt_money(w2_ss_wages),
1765 fmt_money(w2_medicare_wages)
1766 );
1767 } else {
1768 let _ = writeln!(
1769 s,
1770 " (W-2) assumes $0 W-2 wages (set --w2-ss-wages/--w2-medicare-wages on the tax \
1771 profile if you had a wage job)."
1772 );
1773 }
1774 if r.base < rust_decimal::Decimal::from(400) {
1777 let _ = writeln!(
1778 s,
1779 " (§6017 filing floor) Net earnings from self-employment ({base}) are below $400: \
1780 a Schedule SE filing is required on account of this income only when net earnings \
1781 from self-employment (the ×92.35% base, §1402(a)) are $400 or more (§6017), and \
1782 below that floor no §1401 SE tax is imposed (§1402(b)(2); church employee income \
1783 excepted — §1402(j)(2), not modeled) — the figures above are shown for \
1784 transparency (other self-employment activities, if any, combine on your actual \
1785 Schedule SE).",
1786 base = fmt_money(r.base)
1787 );
1788 }
1789 let _ = writeln!(
1791 s,
1792 " (standalone) This §1401 SE tax is a SEPARATE federal liability, NOT included in the \
1793 income-tax + NIIT total above; the §164(f) one-half-SE-tax deduction is not \
1794 auto-coordinated into that total."
1795 );
1796 Some(s)
1797 }
1798 None => {
1799 if gross_se.is_zero() {
1800 None } else if !table_present {
1802 let mut s = String::new();
1804 let _ = writeln!(
1805 s,
1806 "Schedule SE (§1401 self-employment tax) — tax year {year}"
1807 );
1808 let _ = writeln!(
1809 s,
1810 " SS wage base unavailable for {year}: business self-employment income is present \
1811 but no bundled tax table (ss_wage_base) exists for {year}; the §1401 SE tax was \
1812 NOT computed (no silent drop)."
1813 );
1814 Some(s)
1815 } else {
1816 let mut s = String::new();
1819 let _ = writeln!(
1820 s,
1821 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1822 );
1823 let _ = writeln!(
1824 s,
1825 " fully expensed: gross {} \u{2212} Schedule C expenses {} \u{2264} $0 \
1826 \u{2192} no \u{00a7}1401 SE tax for {year}.",
1827 fmt_money(gross_se),
1828 fmt_money(schedule_c_expenses)
1829 );
1830 Some(s)
1831 }
1832 }
1833 }
1834}
1835
1836pub fn render_gift_advisory(
1859 state: &LedgerState,
1860 year: i32,
1861 prior_taxable_gifts: btctax_core::conventions::Usd,
1862 tables: &impl btctax_core::TaxTables,
1863) -> Option<String> {
1864 use std::collections::BTreeMap;
1865
1866 let any_gift = state
1868 .removals
1869 .iter()
1870 .any(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year);
1871 if !any_gift {
1872 return None; }
1874
1875 let t = match tables.table_for(year) {
1877 None => {
1878 let total: btctax_core::conventions::Usd = state
1879 .removals
1880 .iter()
1881 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1882 .flat_map(|r| r.legs.iter())
1883 .map(|leg| leg.fmv_at_transfer)
1884 .sum();
1885 return Some(format!(
1886 "Form 709 gift advisory ({year}): gift annual-exclusion table unavailable for \
1887 {year}; Form 709 exposure not evaluated — ${} in gifts recorded.",
1888 fmt_money(total)
1889 ));
1890 }
1891 Some(t) => t,
1892 };
1893 let excl = t.gift_annual_exclusion;
1894
1895 let mut labeled: BTreeMap<String, btctax_core::conventions::Usd> = BTreeMap::new();
1897 let mut unlabeled_count: usize = 0;
1898 let mut unlabeled_total: btctax_core::conventions::Usd = Default::default();
1899
1900 for r in state
1901 .removals
1902 .iter()
1903 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1904 {
1905 let fmv: btctax_core::conventions::Usd = r.legs.iter().map(|l| l.fmv_at_transfer).sum();
1906 match &r.donee {
1907 Some(label) => {
1908 *labeled.entry(label.clone()).or_default() += fmv;
1909 }
1910 None => {
1911 unlabeled_count += 1;
1912 unlabeled_total += fmv;
1913 }
1914 }
1915 }
1916
1917 let mut filing_required_donees: Vec<String> = Vec::new();
1919 let mut total_taxable: btctax_core::conventions::Usd = Default::default();
1920 let mut s = format!("Form 709 gift advisory ({year}):");
1921
1922 if !labeled.is_empty() {
1923 s.push_str(&format!(
1924 "\n§2503(b) per-donee annual exclusion analysis (TY{year}, exclusion ${}):",
1925 fmt_money(excl)
1926 ));
1927 for (donee, &total) in &labeled {
1928 let applied = if total < excl { total } else { excl };
1929 let taxable: btctax_core::conventions::Usd = if total > excl {
1930 total - excl
1931 } else {
1932 Default::default()
1933 };
1934 s.push_str(&format!(
1935 "\n {donee}: total ${}, exclusion applied ${}, taxable ${}",
1936 fmt_money(total),
1937 fmt_money(applied),
1938 fmt_money(taxable)
1939 ));
1940 if total > excl {
1941 filing_required_donees.push(donee.clone());
1942 total_taxable += taxable;
1943 }
1944 }
1945 if !filing_required_donees.is_empty() {
1946 s.push_str(&format!(
1947 "\nForm 709 filing required (donee(s): {}). Total taxable gifts: ${}.",
1948 filing_required_donees.join(", "),
1949 fmt_money(total_taxable)
1950 ));
1951 } else {
1952 s.push_str(&format!(
1953 "\nNo Form 709 filing required based on per-donee totals \
1954 (each ≤ ${} exclusion). Total taxable gifts: $0.00.",
1955 fmt_money(excl)
1956 ));
1957 }
1958 }
1959
1960 if unlabeled_count > 0 {
1961 s.push_str(&format!(
1962 "\nNOTE: {unlabeled_count} gift(s) totalling ${} have no donee label — the §2503(b) \
1963 annual exclusion is PER DONEE and cannot be applied without one; label them via \
1964 `reconcile reclassify-outflow --donee`. Shown as a single conservative aggregate.",
1965 fmt_money(unlabeled_total)
1966 ));
1967 if unlabeled_total > excl {
1970 s.push_str(&format!(
1971 "\n Conservative aggregate ${} > ${} (one exclusion); \
1972 verify per-donee totals after labelling.",
1973 fmt_money(unlabeled_total),
1974 fmt_money(excl)
1975 ));
1976 } else {
1977 s.push_str(&format!(
1978 "\n Conservative aggregate ${} \u{2264} ${} (one exclusion); \
1979 per-donee exposure unverifiable without labels.",
1980 fmt_money(unlabeled_total),
1981 fmt_money(excl)
1982 ));
1983 }
1984 }
1985
1986 let lifetime_excl = t.gift_lifetime_exclusion;
1990 let cumulative_taxable = prior_taxable_gifts + total_taxable;
1991
1992 if cumulative_taxable > btctax_core::conventions::Usd::ZERO {
1994 let remaining = if cumulative_taxable >= lifetime_excl {
1995 btctax_core::conventions::Usd::ZERO
1996 } else {
1997 lifetime_excl - cumulative_taxable
1998 };
1999 s.push_str(&format!(
2000 "\n§2505 lifetime (basic) exclusion: you have used ${} of your ${} ({year}) lifetime \
2001 exclusion (${} remaining). No gift tax is DUE until cumulative taxable gifts exceed \
2002 the lifetime exclusion.",
2003 fmt_money(cumulative_taxable),
2004 fmt_money(lifetime_excl),
2005 fmt_money(remaining),
2006 ));
2007 if cumulative_taxable > lifetime_excl {
2009 let excess = cumulative_taxable - lifetime_excl;
2010 s.push_str(&format!(
2011 "\nlifetime exclusion EXCEEDED — gift tax may be due on ${} \
2012 (the excess base past the unified credit, not a computed tax); \
2013 consult a professional.",
2014 fmt_money(excess),
2015 ));
2016 }
2017 if unlabeled_count > 0 {
2020 s.push_str(&format!(
2021 "\n§2505 consumption reflects LABELED-donee taxable gifts only; \
2022 {unlabeled_count} unlabeled gift(s) totalling ${} are NOT included — \
2023 label them via `--donee` for a complete figure; consumption may be \
2024 understated / remaining overstated.",
2025 fmt_money(unlabeled_total),
2026 ));
2027 }
2028 }
2029
2030 s.push_str(
2033 "\nCaveats: §2513 gift-splitting (MFJ) not modeled (single-filer advisory only); \
2034 future-interest gifts (which require Form 709 filing regardless of amount) not \
2035 detectable; §2505 figures are advisory only — no portability/DSUE (§2010(c)(4)) \
2036 applied; prior cumulative taxable gifts are user-supplied (default $0 if \
2037 --prior-taxable-gifts not given).",
2038 );
2039
2040 Some(s)
2041}
2042
2043fn picks_str(picks: &[btctax_core::LotPick]) -> String {
2049 if picks.is_empty() {
2050 return "(none)".to_string();
2051 }
2052 picks
2053 .iter()
2054 .map(|p| {
2055 format!(
2056 "{}#{}:{}",
2057 p.lot.origin_event_id.canonical(),
2058 p.lot.split_sequence,
2059 p.sat
2060 )
2061 })
2062 .collect::<Vec<_>>()
2063 .join(", ")
2064}
2065
2066pub fn render_optimize_proposal(p: &btctax_core::OptimizeProposal) -> String {
2079 use btctax_core::{ApproxReason, Persistability};
2080 let mut s = String::new();
2081 let _ = writeln!(
2082 s,
2083 "Optimize (what-if) — tax year {} — NOTHING is filed or bound by running this.",
2084 p.year
2085 );
2086 if p.approximate {
2088 let why = match p.approx_reason {
2089 Some(ApproxReason::ComboCapExceeded { combos, cap }) => format!(
2090 "input exceeded the exhaustive bound ({combos} combos > {cap}); \
2091 a coordinate-descent fallback ran"
2092 ),
2093 Some(ApproxReason::ContentionUnenumerated { contended, .. }) => format!(
2094 "{contended} contended same-wallet disposal(s) could not be fully joint-enumerated"
2095 ),
2096 Some(ApproxReason::PoolHeuristic { lots, bound }) => format!(
2097 "a pool of {lots} lots exceeds the {bound}-lot exhaustive-enumeration bound; \
2098 only a deterministic heuristic SUBSET of that pool's identifications was searched"
2099 ),
2100 None => "approximate".to_string(),
2101 };
2102 let _ = writeln!(
2103 s,
2104 " \u{26a0} APPROXIMATE \u{2014} NOT a guaranteed global minimum: {why}."
2105 );
2106 let _ = writeln!(
2107 s,
2108 " The true least-tax assignment may be lower; this is a disclosed improvement over your"
2109 );
2110 let _ = writeln!(
2111 s,
2112 " current filing position (delta \u{2264} 0), NOT \u{2018}the least tax.\u{2019}"
2113 );
2114 }
2115 let _ = writeln!(
2116 s,
2117 " current federal tax (attributable): {}",
2118 p.baseline_tax
2119 );
2120 let _ = writeln!(
2121 s,
2122 " optimized federal tax (attributable): {}",
2123 p.optimized_tax
2124 );
2125 let _ = writeln!(
2126 s,
2127 " delta (optimized \u{2212} current): {} (negative = saving; always \u{2264} 0)",
2128 p.delta
2129 );
2130 for d in &p.per_disposal {
2131 let _ = writeln!(
2132 s,
2133 " {} @ {} [{}] :: {}",
2134 d.disposal.canonical(),
2135 d.date,
2136 wallet_label(&d.wallet),
2137 compliance_status_tag(&d.status)
2138 );
2139 if d.proposed_selection == d.current_selection {
2144 let _ = writeln!(
2145 s,
2146 " proposed: {} [no change \u{2014} already optimal under current identification]",
2147 picks_str(&d.proposed_selection)
2148 );
2149 continue;
2150 }
2151 let persist = match d.persistable {
2152 Persistability::ContemporaneousNow => {
2153 "persistable now (made \u{2264} sale \u{2192} Contemporaneous)"
2154 }
2155 Persistability::NeedsAttestation => {
2156 "already executed \u{2014} needs `optimize accept --disposal <ref> \
2157 --attest \"\u{2026}\"` (genuine contemporaneous ID only)"
2158 }
2159 Persistability::ForbiddenBroker2027 => {
2160 "2027+ broker-held \u{2014} CANNOT be persisted (own-books insufficient); \
2161 FIFO is the defensible position"
2162 }
2163 };
2164 let _ = writeln!(
2165 s,
2166 " proposed: {} [{}]",
2167 picks_str(&d.proposed_selection),
2168 persist
2169 );
2170 }
2171 let _ = writeln!(
2173 s,
2174 " (vertex-granularity identification: a multi-partial split landing exactly on a \
2175 tax-bracket kink is out of scope.)"
2176 );
2177 let _ = writeln!(
2178 s,
2179 " (this is the tax IF you had identified thus; adequate ID must exist by the time \
2180 of sale \u{2014} \u{a7}1.1012-1(j))"
2181 );
2182 let _ = writeln!(
2184 s,
2185 " (scope: global over taxable-disposal lot selections; self-transfer lot routing is \
2186 held at its baseline position and is not re-optimized.)"
2187 );
2188 s
2189}
2190
2191pub fn render_accept_outcome(o: &crate::cmd::optimize::AcceptOutcome) -> String {
2196 let mut s = String::new();
2197 let _ = writeln!(
2198 s,
2199 "Optimize accept \u{2014} {} persisted, {} skipped.",
2200 o.persisted.len(),
2201 o.skipped.len()
2202 );
2203 for (disposal, decision, basis) in &o.persisted {
2204 let _ = writeln!(
2205 s,
2206 " PERSISTED {} \u{2192} LotSelection {} [{}]{}",
2207 disposal.canonical(),
2208 decision.canonical(),
2209 basis,
2210 if *basis == "AttestedRecording" {
2211 " (+ attestation recorded; revoke with `reconcile void`)"
2212 } else {
2213 " (revoke with `reconcile void`)"
2214 }
2215 );
2216 }
2217 for (disposal, reason) in &o.skipped {
2218 let _ = writeln!(s, " skipped {}: {}", disposal.canonical(), reason);
2219 }
2220 if o.persisted.is_empty() && o.skipped.is_empty() {
2221 let _ = writeln!(s, " (no disposals matched)");
2222 }
2223 s
2224}
2225
2226pub fn render_consult(r: &btctax_core::ConsultReport) -> String {
2236 let mut s = String::new();
2237 let _ = writeln!(
2238 s,
2239 "Consult (read-only what-if): sell {} sat from {} on {}",
2240 r.req.sell_sat,
2241 wallet_label(&r.req.wallet),
2242 r.req.at
2243 );
2244 if r.approximate {
2246 let _ = writeln!(
2247 s,
2248 " \u{26a0} heuristic \u{2014} searched a subset of a large (>12-lot) pool; \
2249 the proposed selection may not be the exact minimum."
2250 );
2251 }
2252 let _ = writeln!(
2253 s,
2254 " proposed selection: {}",
2255 picks_str(&r.proposed_selection)
2256 );
2257 let _ = writeln!(
2258 s,
2259 " short-term gain: {} long-term gain: {}",
2260 r.st_gain, r.lt_gain
2261 );
2262 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
2265 let _ = writeln!(
2266 s,
2267 " whole-year federal tax attributable (with this sale): {}",
2268 r.total_federal_tax_attributable
2269 );
2270 if let Some(t) = &r.timing {
2271 let _ = writeln!(
2272 s,
2273 " timing: {} sat of the best selection is short-term until {}; \
2274 selling on/after then would be taxed long-term, a \u{2248} {} difference.",
2275 t.st_sat_in_selection, t.latest_crossover, t.saving_if_waited
2276 );
2277 }
2278 let _ = writeln!(
2279 s,
2280 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
2281 not investment advice (no buy/sell/hold recommendation)."
2282 );
2283 s
2284}
2285
2286fn ltcg_bracket_label(b: LtcgBracket) -> &'static str {
2288 match b {
2289 LtcgBracket::Zero => "0%",
2290 LtcgBracket::Fifteen => "15%",
2291 LtcgBracket::Twenty => "20%",
2292 }
2293}
2294
2295pub fn render_whatif_sell(r: &SellReport, magi_caveat: bool) -> String {
2301 let mut s = String::new();
2302 let _ = writeln!(
2303 s,
2304 "What-if (read-only): sell {} sat from {} on {}",
2305 r.req.sell_sat,
2306 wallet_label(&r.req.wallet),
2307 r.req.at
2308 );
2309 if magi_caveat {
2310 let _ = writeln!(
2311 s,
2312 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
2313 );
2314 }
2315 let _ = writeln!(s, " proceeds: {}", r.proceeds);
2316 let _ = writeln!(s, " lots consumed:");
2317 for leg in &r.lots {
2318 let term = match leg.term {
2319 Term::ShortTerm => "ST",
2320 Term::LongTerm => "LT",
2321 };
2322 let _ = writeln!(
2323 s,
2324 " {}#{} {} sat basis {} {} \u{2192} {} {} gain {}",
2325 leg.lot_id.origin_event_id.canonical(),
2326 leg.lot_id.split_sequence,
2327 leg.sat,
2328 leg.basis,
2329 leg.acquired_at,
2330 leg.sold_at,
2331 term,
2332 leg.gain
2333 );
2334 }
2335 let _ = writeln!(
2336 s,
2337 " short-term gain: {} long-term gain: {}",
2338 r.st_gain, r.lt_gain
2339 );
2340 match r.bracket_room {
2342 Some(room) => {
2343 let _ = writeln!(
2344 s,
2345 " \u{00a7}1(h) LTCG bracket: {} (room {} before the next breakpoint)",
2346 ltcg_bracket_label(r.bracket),
2347 room
2348 );
2349 }
2350 None => {
2351 let _ = writeln!(
2352 s,
2353 " \u{00a7}1(h) LTCG bracket: {} (top bracket \u{2014} no headroom)",
2354 ltcg_bracket_label(r.bracket)
2355 );
2356 }
2357 }
2358 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
2360 match r.effective_rate {
2361 Some(rate) => {
2362 let _ = writeln!(s, " effective rate: {rate}");
2363 }
2364 None => {
2365 let _ = writeln!(
2366 s,
2367 " effective rate: n/a (a loss/zero-gain sale \u{2014} its value is the carryforward, \
2368 not this-year tax)"
2369 );
2370 }
2371 }
2372 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2375 if carried != Usd::ZERO || r.ordinary_offset_delta != Usd::ZERO {
2376 let _ = writeln!(
2377 s,
2378 " \u{00a7}1212: {} offsets ordinary income this year, {} carried to next year \
2379 (short {} / long {})",
2380 r.ordinary_offset_delta, carried, r.carryforward_delta.short, r.carryforward_delta.long
2381 );
2382 }
2383 if r.niit_applies {
2385 let dir = if r.niit_incremental < Usd::ZERO {
2386 "decrease"
2387 } else {
2388 "increase"
2389 };
2390 let _ = writeln!(
2391 s,
2392 " \u{00a7}1411 NIIT: {} ({dir}) attributable to this sale",
2393 r.niit_incremental
2394 );
2395 }
2396 let status = match r.status {
2397 SellStatus::Gain => "net gain",
2398 SellStatus::Loss => "net loss (the carryforward is the value \u{2014} not this-year tax)",
2399 };
2400 let _ = writeln!(s, " status: {status}");
2401 let _ = writeln!(
2402 s,
2403 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
2404 not investment advice (no buy/sell/hold recommendation)."
2405 );
2406 s
2407}
2408
2409fn harvest_target_label(t: &HarvestTarget) -> String {
2411 match t {
2412 HarvestTarget::ZeroLtcg => {
2413 "zero-ltcg (all gain in the \u{00a7}1(h) 0% bracket)".to_string()
2414 }
2415 HarvestTarget::FifteenLtcg => "fifteen-ltcg (stay at/under 15%)".to_string(),
2416 HarvestTarget::Gain(x) => format!("gain \u{2264} {x}"),
2417 HarvestTarget::Tax(x) => format!("marginal tax \u{2264} {x}"),
2418 }
2419}
2420
2421pub fn render_whatif_harvest(r: &HarvestReport, magi_caveat: bool) -> String {
2427 let mut s = String::new();
2428 let _ = writeln!(
2429 s,
2430 "What-if HARVEST (read-only): {} from {} on {}",
2431 harvest_target_label(&r.req.target),
2432 wallet_label(&r.req.wallet),
2433 r.req.at
2434 );
2435 if magi_caveat {
2436 let _ = writeln!(
2437 s,
2438 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
2439 );
2440 }
2441 let status = match &r.status {
2442 HarvestStatus::Found => "FOUND (the target binds)",
2443 HarvestStatus::NotBinding => "NOT BINDING (the whole position fits)",
2444 HarvestStatus::AlreadyBreached => "ALREADY BREACHED at N=0 (nothing can be harvested)",
2445 HarvestStatus::NoLots => "NO LOTS (nothing to harvest from that wallet)",
2446 HarvestStatus::ProceedsRequired => "PROCEEDS REQUIRED",
2447 HarvestStatus::PreTransitionYear => "PRE-2025 (a closed year, not a plan)",
2448 HarvestStatus::YearNotComputable(_) => "YEAR NOT COMPUTABLE",
2449 };
2450 let _ = writeln!(s, " status: {status}");
2451 let _ = writeln!(s, " \u{2192} sell up to {} BTC ({} sat)", r.n_btc, r.n_sat);
2452 let _ = writeln!(s, " bound by: {}", r.binding_constraint);
2453 let _ = writeln!(
2454 s,
2455 " realized gain at N*: short-term {} long-term {}",
2456 r.st_gain, r.lt_gain
2457 );
2458 let ps = &r.with_result.pref_split;
2460 let bracket = if ps.at_20 > Usd::ZERO {
2461 "20%"
2462 } else if ps.at_15 > Usd::ZERO {
2463 "15%"
2464 } else {
2465 "0%"
2466 };
2467 let _ = writeln!(
2468 s,
2469 " \u{00a7}1(h) preferential dollars at N*: {} in 0% / {} in 15% / {} in 20% (top bracket: {})",
2470 ps.at_0, ps.at_15, ps.at_20, bracket
2471 );
2472 let _ = writeln!(s, " marginal federal tax at N*: {}", r.marginal_tax);
2473 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2475 if carried != Usd::ZERO {
2476 let dir = if carried < Usd::ZERO {
2477 "burned (spent)"
2478 } else {
2479 "carried to next year"
2480 };
2481 let _ = writeln!(
2482 s,
2483 " \u{00a7}1212 carryforward {}: {} (short {} / long {})",
2484 dir, carried, r.carryforward_delta.short, r.carryforward_delta.long
2485 );
2486 }
2487 if r.niit_applies {
2489 let dir = if r.niit_incremental < Usd::ZERO {
2490 "decrease"
2491 } else {
2492 "increase"
2493 };
2494 let _ = writeln!(
2495 s,
2496 " \u{00a7}1411 NIIT: {} ({dir}) at N* \u{2014} the +3.8% kink applies even inside a 0%/15% bracket answer",
2497 r.niit_incremental
2498 );
2499 }
2500 if let Some(note) = &r.plateau_note {
2501 let _ = writeln!(s, " \u{2139} {note}");
2502 }
2503 let _ = writeln!(
2504 s,
2505 "Tax decision-support only \u{2014} the engine-verified consequences of a contemplated harvest; \
2506 not investment advice (no buy/sell/hold recommendation)."
2507 );
2508 s
2509}
2510
2511pub fn render_verify(r: &VerifyReport) -> String {
2512 let mut out = String::new();
2513 let c = &r.conservation;
2514 let _ = writeln!(
2515 out,
2516 "Conservation (FR9): {}",
2517 if c.balanced { "BALANCED" } else { "DRIFT" }
2518 );
2519 let _ = writeln!(
2520 out,
2521 " in {} = disposed {} + removed {} + held {} + fee-sats {} + pending {}{}",
2522 c.sigma_in,
2523 c.sigma_disposed,
2524 c.sigma_removed,
2525 c.sigma_held,
2526 c.sigma_fee_sats,
2527 c.sigma_pending,
2528 if c.has_uncovered {
2529 " [identity undefined: uncovered disposal open]"
2530 } else {
2531 ""
2532 }
2533 );
2534 let _ = writeln!(out, "2025 transition: {}", r.safe_harbor);
2535 let _ = writeln!(
2536 out,
2537 "Pending reconciliation: {} transfer(s); unknown-basis inbounds: {}",
2538 r.pending, r.unknown_basis_inbounds
2539 );
2540
2541 let _ = writeln!(
2542 out,
2543 "Hard blockers (gate tax computation): {}",
2544 r.hard.len()
2545 );
2546 for b in &r.hard {
2547 let evt = b
2548 .event
2549 .as_ref()
2550 .map(|e| e.canonical())
2551 .unwrap_or_else(|| "-".to_string());
2552 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2553 if b.kind == BlockerKind::FmvMissing {
2556 let _ = writeln!(out, " ↳ {}", crate::price_cache::UPDATE_PRICES_HINT);
2557 }
2558 }
2559 let _ = writeln!(out, "Advisory blockers: {}", r.advisory.len());
2560 for b in &r.advisory {
2561 let evt = b
2562 .event
2563 .as_ref()
2564 .map(|e| e.canonical())
2565 .unwrap_or_else(|| "-".to_string());
2566 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2567 }
2568 let _ = writeln!(
2569 out,
2570 "Pre-2025 method (attested historical fact): {} (attested: {})",
2571 lot_method_display(r.declared_pre2025_method),
2572 r.pre2025_method_attested
2573 );
2574 let _ = writeln!(
2575 out,
2576 "Standing orders (MethodElection): {}",
2577 r.elections.len()
2578 );
2579 for e in &r.elections {
2580 let _ = writeln!(
2581 out,
2582 " recorded {} effective {} -> {} [{}]",
2583 e.recorded,
2584 e.effective_from,
2585 lot_method_display(e.method),
2586 e.note
2587 );
2588 }
2589 let _ = writeln!(out, "Lot selections recorded: {}", r.selection_count);
2590 let _ = writeln!(
2591 out,
2592 "Per-disposal compliance (post-2025): {}",
2593 r.compliance.len()
2594 );
2595 for c in &r.compliance {
2596 let _ = writeln!(
2597 out,
2598 " {} @ {} :: {}",
2599 c.disposal.canonical(),
2600 c.date,
2601 compliance_status_tag(&c.status)
2602 );
2603 }
2604 let _ = writeln!(out, "Promote-basis drift advisories: {}", r.drift.len());
2607 for d in &r.drift {
2608 let _ = writeln!(out, " {d}");
2609 }
2610 out
2611}
2612
2613#[cfg(test)]
2614mod gift_advisory_tests {
2615 use super::*;
2621 use btctax_core::conventions::Usd;
2622 use btctax_core::{EventId, LotId, Removal, RemovalLeg, TaxTable};
2623 use rust_decimal_macros::dec;
2624 use std::collections::BTreeMap;
2625 use time::macros::date;
2626
2627 fn gift_removal(seq: u64, removed_at: TaxDate, fmv: Usd) -> Removal {
2629 Removal {
2630 event: EventId::decision(seq),
2631 kind: RemovalKind::Gift,
2632 removed_at,
2633 legs: vec![RemovalLeg {
2634 lot_id: LotId {
2635 origin_event_id: EventId::decision(seq),
2636 split_sequence: 0,
2637 },
2638 sat: 100,
2639 basis: dec!(0),
2640 fmv_at_transfer: fmv,
2641 term: Term::LongTerm,
2642 basis_source: BasisSource::ComputedFromCost,
2643 acquired_at: date!(2024 - 01 - 01),
2644 pseudo: false,
2645 }],
2646 appraisal_required: false,
2647 donor_acquired_at: None,
2648 claimed_deduction: None,
2649 donee: None,
2650 }
2651 }
2652 fn gift_removal_labeled(seq: u64, removed_at: TaxDate, fmv: Usd, label: &str) -> Removal {
2654 Removal {
2655 donee: Some(label.to_string()),
2656 ..gift_removal(seq, removed_at, fmv)
2657 }
2658 }
2659 fn state_with(removals: Vec<Removal>) -> LedgerState {
2660 LedgerState {
2661 removals,
2662 ..Default::default()
2663 }
2664 }
2665 fn tables_with(year: i32, excl: Usd) -> BTreeMap<i32, TaxTable> {
2669 tables_with_lifetime(year, excl, dec!(13_990_000))
2670 }
2671
2672 fn tables_with_lifetime(year: i32, excl: Usd, lifetime_excl: Usd) -> BTreeMap<i32, TaxTable> {
2674 let mut m = BTreeMap::new();
2675 m.insert(
2676 year,
2677 TaxTable {
2678 year,
2679 source: "TEST",
2680 ordinary: BTreeMap::new(),
2681 ltcg: BTreeMap::new(),
2682 gift_annual_exclusion: excl,
2683 ss_wage_base: dec!(176100),
2684 gift_lifetime_exclusion: lifetime_excl,
2685 },
2686 );
2687 m
2688 }
2689
2690 #[test]
2694 fn no_gifts_is_none() {
2695 let st = state_with(vec![]);
2696 let tables = tables_with(2025, dec!(19000));
2697 assert!(render_gift_advisory(&st, 2025, dec!(0), &tables).is_none());
2698 }
2699
2700 #[test]
2703 fn gifts_present_but_no_table_emits_note_not_none() {
2704 let st = state_with(vec![gift_removal(1, date!(2026 - 06 - 01), dec!(50000))]);
2706 let tables = tables_with(2025, dec!(19000));
2708 let msg =
2709 render_gift_advisory(&st, 2026, dec!(0), &tables).expect("note expected, not None");
2710 assert!(msg.contains("unavailable"), "{msg}");
2711 assert!(msg.contains("Form 709 exposure not evaluated"), "{msg}");
2712 assert!(
2713 msg.contains("50000.00"),
2714 "must record the gift total: {msg}"
2715 );
2716 }
2717
2718 #[test]
2724 fn labeled_donee_over_exclusion_emits_advisory() {
2725 let st = state_with(vec![gift_removal_labeled(
2726 1,
2727 date!(2025 - 06 - 01),
2728 dec!(20000),
2729 "Alice",
2730 )]);
2731 let tables = tables_with(2025, dec!(19000));
2732 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2733 assert!(msg.contains("20000.00"), "must show Alice's total: {msg}");
2734 assert!(msg.contains("19000.00"), "must show the exclusion: {msg}");
2735 assert!(
2736 msg.contains("Form 709 filing required"),
2737 "must flag filing required: {msg}"
2738 );
2739 assert!(msg.contains("Alice"), "must name Alice: {msg}");
2740 assert!(msg.contains("1000.00"), "taxable must be $1000.00: {msg}");
2742 assert!(
2744 !msg.contains("donee identity is not modeled"),
2745 "stale aggregate caveat must not appear: {msg}"
2746 );
2747 }
2748
2749 #[test]
2752 fn labeled_donee_under_exclusion_no_filing_required() {
2753 let st = state_with(vec![gift_removal_labeled(
2754 1,
2755 date!(2025 - 06 - 01),
2756 dec!(10000),
2757 "Alice",
2758 )]);
2759 let tables = tables_with(2025, dec!(19000));
2760 let msg =
2762 render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected, not None");
2763 assert!(
2764 msg.contains("No Form 709 filing required"),
2765 "must say no filing required: {msg}"
2766 );
2767 assert!(msg.contains("Alice"), "must mention Alice: {msg}");
2768 assert!(msg.contains("10000.00"), "must show Alice's total: {msg}");
2769 }
2770
2771 #[test]
2777 fn per_donee_under_exclusion_two_donees_no_filing_required() {
2778 let st = state_with(vec![
2779 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(15000), "Alice"),
2780 gift_removal_labeled(2, date!(2025 - 06 - 01), dec!(15000), "Bob"),
2781 ]);
2782 let tables = tables_with(2025, dec!(19000));
2783 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2784 assert!(
2786 msg.contains("No Form 709 filing required"),
2787 "neither donee exceeds exclusion → no filing required: {msg}"
2788 );
2789 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2791 assert!(msg.contains("Bob"), "Bob must appear: {msg}");
2792 assert!(msg.contains("15000.00"), "donee total must appear: {msg}");
2794 assert!(
2796 !msg.contains("Form 709 filing required (donee(s):"),
2797 "filing trigger must NOT fire: {msg}"
2798 );
2799 assert!(
2801 msg.contains("Total taxable gifts: $0.00"),
2802 "total taxable must be $0.00: {msg}"
2803 );
2804 }
2805
2806 #[test]
2809 fn one_donee_over_exclusion_filing_required() {
2810 let st = state_with(vec![gift_removal_labeled(
2811 1,
2812 date!(2025 - 06 - 01),
2813 dec!(25000),
2814 "Alice",
2815 )]);
2816 let tables = tables_with(2025, dec!(19000));
2817 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2818 assert!(
2819 msg.contains("Form 709 filing required (donee(s): Alice)"),
2820 "must trigger filing required for Alice: {msg}"
2821 );
2822 assert!(msg.contains("25000.00"), "Alice total must appear: {msg}");
2823 assert!(
2824 msg.contains("19000.00"),
2825 "exclusion applied must appear: {msg}"
2826 );
2827 assert!(
2829 msg.contains("6000.00"),
2830 "taxable $6,000.00 must appear: {msg}"
2831 );
2832 }
2833
2834 #[test]
2837 fn unlabeled_bucket_caveat_with_conservative_aggregate() {
2838 let st = state_with(vec![gift_removal(1, date!(2025 - 06 - 01), dec!(30000))]);
2839 let tables = tables_with(2025, dec!(19000));
2840 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2841 assert!(
2843 msg.contains("no donee label"),
2844 "unlabeled caveat must appear: {msg}"
2845 );
2846 assert!(
2847 msg.contains("30000.00"),
2848 "unlabeled total must appear: {msg}"
2849 );
2850 assert!(
2852 msg.contains("Conservative aggregate"),
2853 "conservative aggregate signal must appear: {msg}"
2854 );
2855 assert!(
2856 msg.contains("19000.00"),
2857 "one-exclusion comparison must appear: {msg}"
2858 );
2859 assert!(
2861 !msg.contains("Form 709 filing required (donee(s):"),
2862 "labeled filing trigger must NOT fire for unlabeled gifts: {msg}"
2863 );
2864 }
2865
2866 #[test]
2869 fn mixed_labeled_over_and_unlabeled_shows_both() {
2870 let st = state_with(vec![
2871 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(25000), "Alice"),
2872 gift_removal(2, date!(2025 - 06 - 01), dec!(5000)), ]);
2874 let tables = tables_with(2025, dec!(19000));
2875 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2876 assert!(
2878 msg.contains("Form 709 filing required"),
2879 "filing required for Alice: {msg}"
2880 );
2881 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2882 assert!(
2884 msg.contains("no donee label"),
2885 "unlabeled caveat must appear: {msg}"
2886 );
2887 assert!(
2888 msg.contains("5000.00"),
2889 "unlabeled total must appear: {msg}"
2890 );
2891 }
2892
2893 #[test]
2896 fn donations_excluded_from_form709_advisory() {
2897 let donation_removal = Removal {
2898 event: EventId::decision(1),
2899 kind: RemovalKind::Donation,
2900 removed_at: date!(2025 - 06 - 01),
2901 legs: vec![RemovalLeg {
2902 lot_id: LotId {
2903 origin_event_id: EventId::decision(1),
2904 split_sequence: 0,
2905 },
2906 sat: 100,
2907 basis: dec!(0),
2908 fmv_at_transfer: dec!(50000), term: Term::LongTerm,
2910 basis_source: BasisSource::ComputedFromCost,
2911 acquired_at: date!(2024 - 01 - 01),
2912 pseudo: false,
2913 }],
2914 appraisal_required: false,
2915 donor_acquired_at: None,
2916 claimed_deduction: Some(dec!(50000)),
2917 donee: Some("Charity X".to_string()),
2918 };
2919 let st = state_with(vec![donation_removal]);
2920 let tables = tables_with(2025, dec!(19000));
2921 assert!(
2923 render_gift_advisory(&st, 2025, dec!(0), &tables).is_none(),
2924 "Donation must be excluded from the Form 709 advisory"
2925 );
2926 }
2927
2928 #[test]
2934 fn section_2505_under_lifetime_shows_used_and_remaining() {
2935 let st = state_with(vec![gift_removal_labeled(
2936 1,
2937 date!(2025 - 06 - 01),
2938 dec!(100000),
2939 "Alice",
2940 )]);
2941 let tables = tables_with(2025, dec!(19000)); let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2943 assert!(
2945 msg.contains("81000.00"),
2946 "taxable $81,000 must appear: {msg}"
2947 );
2948 assert!(
2950 msg.contains("§2505 lifetime (basic) exclusion"),
2951 "§2505 block must appear: {msg}"
2952 );
2953 assert!(
2954 msg.contains("13990000.00"),
2955 "lifetime exclusion $13,990,000 must appear: {msg}"
2956 );
2957 assert!(
2959 msg.contains("13909000.00"),
2960 "remaining $13,909,000 must appear: {msg}"
2961 );
2962 assert!(
2964 !msg.contains("EXCEEDED"),
2965 "must NOT say EXCEEDED when under limit: {msg}"
2966 );
2967 assert!(
2969 !msg.contains("later chunk (Chunk 3)"),
2970 "stale Chunk-3 caveat must be absent: {msg}"
2971 );
2972 }
2973
2974 #[test]
2977 fn section_2505_prior_gifts_accumulate() {
2978 let st = state_with(vec![gift_removal_labeled(
2979 1,
2980 date!(2025 - 06 - 01),
2981 dec!(100000),
2982 "Alice",
2983 )]);
2984 let tables = tables_with(2025, dec!(19000));
2985 let msg =
2986 render_gift_advisory(&st, 2025, dec!(13_900_000), &tables).expect("advisory expected");
2987 assert!(
2989 msg.contains("13981000.00"),
2990 "cumulative $13,981,000 must appear: {msg}"
2991 );
2992 assert!(
2994 msg.contains("9000.00"),
2995 "remaining $9,000 must appear: {msg}"
2996 );
2997 assert!(
2998 !msg.contains("EXCEEDED"),
2999 "must NOT say EXCEEDED when under limit: {msg}"
3000 );
3001 }
3002
3003 #[test]
3007 fn section_2505_exceeds_lifetime_shows_exceeded_and_excess() {
3008 let st = state_with(vec![gift_removal_labeled(
3009 1,
3010 date!(2025 - 06 - 01),
3011 dec!(100000),
3012 "Alice",
3013 )]);
3014 let tables = tables_with(2025, dec!(19000));
3015 let msg =
3016 render_gift_advisory(&st, 2025, dec!(13_950_000), &tables).expect("advisory expected");
3017 assert!(
3018 msg.contains("14031000.00"),
3019 "cumulative $14,031,000 must appear: {msg}"
3020 );
3021 assert!(
3022 msg.contains("EXCEEDED"),
3023 "must say EXCEEDED when over lifetime limit: {msg}"
3024 );
3025 assert!(
3027 msg.contains("41000.00"),
3028 "excess $41,000 must appear: {msg}"
3029 );
3030 }
3031
3032 #[test]
3036 fn section_2505_exact_boundary_remaining_zero_not_exceeded() {
3037 let st = state_with(vec![gift_removal_labeled(
3038 1,
3039 date!(2025 - 06 - 01),
3040 dec!(100000),
3041 "Alice",
3042 )]);
3043 let tables = tables_with(2025, dec!(19000));
3044 let msg =
3046 render_gift_advisory(&st, 2025, dec!(13_909_000), &tables).expect("advisory expected");
3047 assert!(
3048 msg.contains("13990000.00"),
3049 "cumulative $13,990,000 must appear: {msg}"
3050 );
3051 assert!(
3053 msg.contains("($0.00 remaining)"),
3054 "remaining $0.00 in exact phrasing '($0.00 remaining)' must appear: {msg}"
3055 );
3056 assert!(
3058 !msg.contains("EXCEEDED"),
3059 "must NOT say EXCEEDED at exactly the limit: {msg}"
3060 );
3061 }
3062
3063 #[test]
3067 fn section_2505_prior_only_block_shows_even_when_current_taxable_zero() {
3068 let st = state_with(vec![gift_removal_labeled(
3069 1,
3070 date!(2025 - 06 - 01),
3071 dec!(10000), "Alice",
3073 )]);
3074 let tables = tables_with(2025, dec!(19000));
3075 let msg =
3076 render_gift_advisory(&st, 2025, dec!(5_000_000), &tables).expect("advisory expected");
3077 assert!(
3079 msg.contains("5000000.00"),
3080 "cumulative $5,000,000 must appear: {msg}"
3081 );
3082 assert!(
3083 msg.contains("§2505 lifetime (basic) exclusion"),
3084 "§2505 block must appear for prior-only case: {msg}"
3085 );
3086 assert!(!msg.contains("EXCEEDED"), "must NOT say EXCEEDED: {msg}");
3087 }
3088
3089 #[test]
3092 fn section_2505_no_block_when_cumulative_zero() {
3093 let st = state_with(vec![gift_removal_labeled(
3094 1,
3095 date!(2025 - 06 - 01),
3096 dec!(10000), "Alice",
3098 )]);
3099 let tables = tables_with(2025, dec!(19000));
3100 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3101 assert!(
3102 !msg.contains("§2505 lifetime"),
3103 "§2505 block must NOT appear when cumulative = 0: {msg}"
3104 );
3105 }
3106
3107 #[test]
3109 fn section_2505_default_zero_prior_shows_caveats() {
3110 let st = state_with(vec![gift_removal_labeled(
3111 1,
3112 date!(2025 - 06 - 01),
3113 dec!(100000), "Alice",
3115 )]);
3116 let tables = tables_with(2025, dec!(19000));
3117 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3118 assert!(
3120 !msg.contains("later chunk (Chunk 3)"),
3121 "stale 'later chunk (Chunk 3)' must be absent: {msg}"
3122 );
3123 assert!(
3125 msg.contains("§2513 gift-splitting"),
3126 "§2513 caveat must be present: {msg}"
3127 );
3128 assert!(
3129 msg.contains("portability/DSUE"),
3130 "portability/DSUE caveat must be present: {msg}"
3131 );
3132 assert!(
3133 msg.contains("prior cumulative taxable gifts are user-supplied"),
3134 "prior-cumulative disclosure caveat must be present: {msg}"
3135 );
3136 }
3137
3138 #[test]
3141 fn section_2505_mixed_shows_omission_disclosure_for_unlabeled() {
3142 let st = state_with(vec![
3143 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(100000), "Alice"),
3144 gift_removal(2, date!(2025 - 06 - 01), dec!(50000)), ]);
3146 let tables = tables_with(2025, dec!(19000));
3147 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3148 assert!(
3150 msg.contains("§2505 lifetime (basic) exclusion"),
3151 "§2505 block must appear: {msg}"
3152 );
3153 assert!(
3154 msg.contains("81000.00"),
3155 "used $81,000 (labeled only) must appear: {msg}"
3156 );
3157 assert!(
3159 msg.contains("§2505 consumption reflects LABELED-donee taxable gifts only"),
3160 "omission disclosure must appear: {msg}"
3161 );
3162 assert!(
3163 msg.contains("50000.00"),
3164 "unlabeled total $50,000 must appear in omission disclosure: {msg}"
3165 );
3166 assert!(
3167 msg.contains("consumption may be understated"),
3168 "under-stated warning must appear: {msg}"
3169 );
3170 }
3171
3172 #[test]
3174 fn section_2505_stale_chunk3_caveat_is_absent() {
3175 let st = state_with(vec![gift_removal_labeled(
3176 1,
3177 date!(2025 - 06 - 01),
3178 dec!(20000),
3179 "Alice",
3180 )]);
3181 let tables = tables_with(2025, dec!(19000));
3182 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3183 assert!(
3184 !msg.contains("later chunk (Chunk 3)"),
3185 "stale Chunk-3 caveat must be absent: {msg}"
3186 );
3187 assert!(
3188 !msg.contains("§2505 lifetime exemption is a later chunk"),
3189 "stale §2505 future-chunk phrase must be absent: {msg}"
3190 );
3191 }
3192}
3193
3194#[cfg(test)]
3195mod schedule_se_tests {
3196 use super::*;
3200 use rust_decimal_macros::dec;
3201
3202 fn golden1() -> SeTaxResult {
3204 SeTaxResult {
3205 net_se: dec!(100000),
3206 base: dec!(92350.00),
3207 ss: dec!(11451.40),
3208 medicare: dec!(2678.15),
3209 addl: dec!(0.00),
3210 total: dec!(14129.55),
3211 deductible_half: dec!(7064.78),
3212 }
3213 }
3214
3215 fn w2_headline() -> SeTaxResult {
3217 SeTaxResult {
3218 net_se: dec!(100000),
3219 base: dec!(92350.00),
3220 ss: dec!(3236.40),
3221 medicare: dec!(2678.15),
3222 addl: dec!(381.15),
3223 total: dec!(6295.70),
3224 deductible_half: dec!(2957.28),
3225 }
3226 }
3227
3228 fn w2_asymmetric() -> SeTaxResult {
3230 SeTaxResult {
3231 net_se: dec!(100000),
3232 base: dec!(92350.00),
3233 ss: dec!(3236.40),
3234 medicare: dec!(2678.15),
3235 addl: dec!(0.00),
3236 total: dec!(5914.55),
3237 deductible_half: dec!(2957.28),
3238 }
3239 }
3240
3241 fn expenses_headline() -> SeTaxResult {
3246 SeTaxResult {
3247 net_se: dec!(80000),
3248 base: dec!(73880.00),
3249 ss: dec!(9161.12),
3250 medicare: dec!(2142.52),
3251 addl: dec!(0.00),
3252 total: dec!(11303.64),
3253 deductible_half: dec!(5651.82),
3254 }
3255 }
3256
3257 fn expenses_w2_combined() -> SeTaxResult {
3267 SeTaxResult {
3268 net_se: dec!(80000),
3269 base: dec!(73880.00),
3270 ss: dec!(3236.40),
3271 medicare: dec!(2142.52),
3272 addl: dec!(214.92),
3273 total: dec!(5593.84),
3274 deductible_half: dec!(2689.46),
3275 }
3276 }
3277
3278 #[test]
3282 fn business_mining_year_renders_full_section() {
3283 let r = golden1();
3284 let s = render_schedule_se(
3285 2025,
3286 Some(&r),
3287 dec!(100000),
3288 true,
3289 Usd::ZERO,
3290 Usd::ZERO,
3291 Usd::ZERO,
3292 )
3293 .expect("SE section expected");
3294 assert!(s.contains("92350.00"), "net SE earnings base: {s}");
3296 assert!(s.contains("11451.40"), "SS component: {s}");
3297 assert!(s.contains("2678.15"), "Medicare component: {s}");
3298 assert!(s.contains("14129.55"), "total SE tax: {s}");
3299 assert!(s.contains("7064.78"), "§164(f) deductible half: {s}");
3300 assert!(
3301 s.contains("Additional Medicare"),
3302 "addl component labeled: {s}"
3303 );
3304 assert!(
3306 s.contains("$0 W-2 wages"),
3307 "short $0-W-2 note must appear (both W-2 = 0): {s}"
3308 );
3309 assert!(
3310 s.contains("--w2-ss-wages"),
3311 "$0 note must mention --w2-ss-wages flag: {s}"
3312 );
3313 assert!(
3314 !s.contains("OVERSTATED"),
3315 "old OVERSTATED text must be absent (Chunk A regression): {s}"
3316 );
3317 assert!(
3318 !s.contains("UNDERSTATED"),
3319 "old UNDERSTATED text must be absent (Chunk A regression): {s}"
3320 );
3321 assert!(
3323 s.contains("NOT auto-coordinated"),
3324 "§164(f) advisory must appear: {s}"
3325 );
3326 assert!(
3327 s.contains("coordinate it on your actual return"),
3328 "§164(f) advisory must include coordination instruction: {s}"
3329 );
3330 assert!(
3332 s.contains("SEPARATE federal liability"),
3333 "standalone note: {s}"
3334 );
3335 assert!(
3336 s.contains("not") && s.contains("§164(f)"),
3337 "notes §164(f) not auto-coordinated: {s}"
3338 );
3339 assert!(
3341 s.contains("no Schedule C expenses supplied"),
3342 "Chunk B $0-expenses note must appear: {s}"
3343 );
3344 assert!(
3345 s.contains("--schedule-c-expenses"),
3346 "$0 note must mention --schedule-c-expenses flag: {s}"
3347 );
3348 assert!(
3349 !s.contains("not modeled"),
3350 "old 'not modeled' caveat must be absent (replaced by Chunk B): {s}"
3351 );
3352 }
3353
3354 #[test]
3356 fn w2_set_renders_coordinated_disclosure() {
3357 let r = w2_headline();
3358 let s = render_schedule_se(
3359 2025,
3360 Some(&r),
3361 dec!(100000),
3362 true,
3363 Usd::ZERO,
3364 dec!(150000),
3365 dec!(150000),
3366 )
3367 .expect("SE section expected");
3368 assert!(
3370 s.contains("W-2 coordination applied"),
3371 "coordinated disclosure must appear: {s}"
3372 );
3373 assert!(
3374 s.contains("§1401(b)(2)(B)"),
3375 "must cite §1401(b)(2)(B): {s}"
3376 );
3377 assert!(
3378 s.contains("Form 8959 Part II"),
3379 "must cite Form 8959 Part II: {s}"
3380 );
3381 assert!(s.contains("150000"), "w2_ss_wages amount must appear: {s}");
3383 assert!(!s.contains("OVERSTATED"), "OVERSTATED must be absent: {s}");
3385 assert!(
3386 !s.contains("UNDERSTATED"),
3387 "UNDERSTATED must be absent: {s}"
3388 );
3389 assert!(s.contains("3236.40"), "reduced SS component: {s}");
3391 assert!(s.contains("381.15"), "non-zero addl: {s}");
3392 assert!(s.contains("6295.70"), "reduced total: {s}");
3393 assert!(s.contains("2957.28"), "deductible_half: {s}");
3394 }
3395
3396 #[test]
3400 fn w2_asymmetric_render_transposition_guard() {
3401 let r = w2_asymmetric();
3402 let s = render_schedule_se(
3403 2025,
3404 Some(&r),
3405 dec!(100000),
3406 true,
3407 Usd::ZERO,
3408 dec!(150000),
3409 Usd::ZERO,
3410 )
3411 .expect("SE section expected");
3412 assert!(
3414 s.contains("W-2 coordination applied"),
3415 "coordinated disclosure must appear: {s}"
3416 );
3417 assert!(s.contains("3236.40"), "ss must be 3236.40 (reduced): {s}");
3419 assert!(
3420 s.contains("0.00"),
3421 "addl must be 0.00 (threshold un-reduced): {s}"
3422 );
3423 assert!(!s.contains("OVERSTATED"), "{s}");
3425 assert!(!s.contains("UNDERSTATED"), "{s}");
3426 }
3427
3428 #[test]
3430 fn no_business_income_no_section() {
3431 assert!(
3432 render_schedule_se(2025, None, Usd::ZERO, true, Usd::ZERO, Usd::ZERO, Usd::ZERO)
3433 .is_none()
3434 );
3435 }
3436
3437 #[test]
3440 fn business_income_but_no_table_emits_note() {
3441 let s = render_schedule_se(
3442 2099,
3443 None,
3444 dec!(100000),
3445 false,
3446 Usd::ZERO,
3447 Usd::ZERO,
3448 Usd::ZERO,
3449 )
3450 .expect("wage-base-unavailable note expected");
3451 assert!(s.contains("SS wage base unavailable"), "{s}");
3452 assert!(s.contains("2099"), "names the year: {s}");
3453 assert!(s.contains("no silent drop"), "{s}");
3454 }
3455
3456 #[test]
3461 fn expenses_20k_no_w2_renders_breakout_and_advisory() {
3462 let r = expenses_headline(); let s = render_schedule_se(
3464 2025,
3465 Some(&r),
3466 dec!(100000), true,
3468 dec!(20000), Usd::ZERO,
3470 Usd::ZERO,
3471 )
3472 .expect("SE section expected");
3473 assert!(
3475 s.contains("gross business income"),
3476 "breakout line must appear: {s}"
3477 );
3478 assert!(
3479 s.contains("100000.00"),
3480 "gross ($100k) must appear in breakout: {s}"
3481 );
3482 assert!(
3483 s.contains("20000.00"),
3484 "expenses ($20k) must appear in breakout: {s}"
3485 );
3486 assert!(
3487 s.contains("80000.00"),
3488 "net_se ($80k) must appear in breakout: {s}"
3489 );
3490 assert!(
3492 s.contains("OVERSTATES"),
3493 "Schedule C advisory OVERSTATES text: {s}"
3494 );
3495 assert!(
3496 s.contains("ORDINARY taxable income"),
3497 "advisory must mention ORDINARY taxable income: {s}"
3498 );
3499 assert!(
3500 s.contains("engine-side coordination is deferred"),
3501 "advisory must mention deferred coordination: {s}"
3502 );
3503 assert!(
3505 !s.contains("reduce your ordinary_taxable_income"),
3506 "NO OTI-edit prescription allowed (spec D3): {s}"
3507 );
3508 assert!(
3509 !s.contains("set --ordinary-taxable-income"),
3510 "NO OTI-edit prescription allowed (spec D3): {s}"
3511 );
3512 assert!(s.contains("73880.00"), "base $73,880: {s}");
3514 assert!(s.contains("9161.12"), "ss $9,161.12: {s}");
3515 assert!(s.contains("2142.52"), "medicare $2,142.52: {s}");
3516 assert!(s.contains("11303.64"), "total $11,303.64: {s}");
3517 assert!(s.contains("5651.82"), "deductible_half $5,651.82: {s}");
3518 assert!(
3520 !s.contains("not modeled"),
3521 "old 'not modeled' caveat must be absent: {s}"
3522 );
3523 }
3524
3525 #[test]
3528 fn fully_expensed_shows_new_line_not_wage_base_note() {
3529 let s = render_schedule_se(
3532 2025,
3533 None,
3534 dec!(10000), true, dec!(15000), Usd::ZERO,
3538 Usd::ZERO,
3539 )
3540 .expect("fully-expensed section expected (not None)");
3541 assert!(
3543 s.contains("fully expensed"),
3544 "fully-expensed line must appear: {s}"
3545 );
3546 assert!(
3547 s.contains("10000.00"),
3548 "gross $10k must appear in fully-expensed line: {s}"
3549 );
3550 assert!(
3551 s.contains("15000.00"),
3552 "expenses $15k must appear in fully-expensed line: {s}"
3553 );
3554 assert!(
3555 s.contains("no §1401 SE tax"),
3556 "must state no SE tax owed: {s}"
3557 );
3558 assert!(s.contains("2025"), "must name the year: {s}");
3559 assert!(
3561 !s.contains("SS wage base unavailable"),
3562 "wage-base-unavailable note must be ABSENT for fully-expensed case: {s}"
3563 );
3564 }
3565
3566 #[test]
3568 fn expenses_w2_combined_renders_both() {
3569 let r = expenses_w2_combined(); let s = render_schedule_se(
3571 2025,
3572 Some(&r),
3573 dec!(100000),
3574 true,
3575 dec!(20000), dec!(150000), dec!(150000), )
3579 .expect("SE section expected");
3580 assert!(s.contains("gross business income"), "breakout line: {s}");
3582 assert!(s.contains("80000.00"), "net_se in breakout: {s}");
3583 assert!(s.contains("OVERSTATES"), "Schedule C advisory: {s}");
3585 assert!(
3587 s.contains("W-2 coordination applied"),
3588 "W-2 coordination: {s}"
3589 );
3590 assert!(s.contains("73880.00"), "base: {s}");
3592 assert!(s.contains("3236.40"), "ss (reduced by W-2 cap): {s}");
3593 assert!(s.contains("2142.52"), "medicare: {s}");
3594 assert!(s.contains("214.92"), "addl: {s}");
3595 assert!(s.contains("5593.84"), "total: {s}");
3596 assert!(s.contains("2689.46"), "deductible_half: {s}");
3597 }
3598
3599 #[test]
3601 fn schedule_se_csv_columns_and_values() {
3602 let dir = tempfile::tempdir().unwrap();
3603 let out = dir.path().join("export");
3604 let st = LedgerState::default();
3605 let r = golden1();
3606 write_csv_exports(&out, &st, Some(2025), Some(&r), &BTreeMap::new()).unwrap();
3607
3608 let path = out.join("schedule_se.csv");
3609 assert!(path.exists(), "schedule_se.csv must be written");
3610 let mut rdr = csv::Reader::from_reader(std::fs::File::open(&path).unwrap());
3611 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3612 assert_eq!(
3613 headers,
3614 vec![
3615 "net_se_earnings",
3616 "se_base_9235",
3617 "ss_component",
3618 "medicare_component",
3619 "additional_medicare_component",
3620 "total_se_tax",
3621 "deductible_half",
3622 ]
3623 );
3624 let rec = rdr
3625 .records()
3626 .next()
3627 .expect("one data row")
3628 .expect("readable");
3629 assert_eq!(&rec[0], "100000"); assert_eq!(&rec[1], "92350.00"); assert_eq!(&rec[2], "11451.40"); assert_eq!(&rec[3], "2678.15"); assert_eq!(&rec[4], "0.00"); assert_eq!(&rec[5], "14129.55"); assert_eq!(&rec[6], "7064.78"); }
3637
3638 #[test]
3640 fn schedule_se_csv_omitted_when_no_se_tax() {
3641 let dir = tempfile::tempdir().unwrap();
3642 let out = dir.path().join("export");
3643 let st = LedgerState::default();
3644 write_csv_exports(&out, &st, Some(2025), None, &BTreeMap::new()).unwrap();
3645 assert!(!out.join("schedule_se.csv").exists());
3646 }
3647}
3648
3649#[cfg(test)]
3650mod form8283_csv_tests {
3651 use super::*;
3654
3655 #[test]
3657 fn form8283_csv_detail_columns_present_and_empty() {
3658 use btctax_core::{
3659 BasisSource, DonationDetails, EventId, LedgerState, Removal, RemovalKind, RemovalLeg,
3660 Term,
3661 };
3662 use time::macros::date;
3663
3664 let dir = tempfile::tempdir().unwrap();
3665 let out = dir.path().join("export");
3666
3667 let event = EventId::decision(99);
3669 let leg = RemovalLeg {
3670 lot_id: btctax_core::LotId {
3671 origin_event_id: event.clone(),
3672 split_sequence: 0,
3673 },
3674 sat: 100_000_000,
3675 basis: rust_decimal::Decimal::ZERO,
3676 fmv_at_transfer: rust_decimal::Decimal::from(52000),
3677 term: Term::LongTerm,
3678 basis_source: BasisSource::ComputedFromCost,
3679 acquired_at: date!(2025 - 01 - 01),
3680 pseudo: false,
3681 };
3682 let removal = Removal {
3683 event: event.clone(),
3684 kind: RemovalKind::Donation,
3685 removed_at: date!(2025 - 03 - 01),
3686 legs: vec![leg],
3687 appraisal_required: false,
3688 donor_acquired_at: None,
3689 claimed_deduction: Some(rust_decimal::Decimal::from(52000)),
3690 donee: Some("Test Charity Two".into()),
3691 };
3692 let event2 = EventId::decision(100);
3694 let leg2 = RemovalLeg {
3695 lot_id: btctax_core::LotId {
3696 origin_event_id: event2.clone(),
3697 split_sequence: 0,
3698 },
3699 sat: 10_000_000,
3700 basis: rust_decimal::Decimal::ZERO,
3701 fmv_at_transfer: rust_decimal::Decimal::from(8000),
3702 term: Term::LongTerm,
3703 basis_source: BasisSource::ComputedFromCost,
3704 acquired_at: date!(2025 - 01 - 15),
3705 pseudo: false,
3706 };
3707 let removal2 = Removal {
3708 event: event2.clone(),
3709 kind: RemovalKind::Donation,
3710 removed_at: date!(2025 - 05 - 01),
3711 legs: vec![leg2],
3712 appraisal_required: false,
3713 donor_acquired_at: None,
3714 claimed_deduction: Some(rust_decimal::Decimal::from(8000)),
3715 donee: Some("No Details Org".into()),
3716 };
3717
3718 let st = LedgerState {
3719 removals: vec![removal, removal2],
3720 ..Default::default()
3721 };
3722
3723 let mut dmap: BTreeMap<EventId, DonationDetails> = BTreeMap::new();
3724 dmap.insert(
3725 event,
3726 DonationDetails {
3727 donee_name: "Test Charity".into(),
3728 donee_ein: Some("12-3456789".into()),
3729 donee_address: Some("123 Main".into()),
3730 appraiser_name: "Test Appraiser".into(),
3731 appraiser_tin: Some("987654321".into()),
3732 appraiser_ptin: Some("P01234567".into()),
3733 appraiser_qualifications: Some("Certified".into()),
3734 appraisal_date: Some(date!(2025 - 06 - 01)),
3735 appraiser_address: None,
3736 fmv_method_override: None,
3737 },
3738 );
3739 write_csv_exports(&out, &st, Some(2025), None, &dmap).unwrap();
3742
3743 let path = out.join("form8283.csv");
3744 assert!(path.exists(), "form8283.csv must exist");
3745
3746 let mut rdr = csv::ReaderBuilder::new()
3747 .comment(Some(b'#'))
3748 .from_path(&path)
3749 .unwrap();
3750 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3751 let idx = |name: &str| {
3752 headers
3753 .iter()
3754 .position(|h| h == name)
3755 .unwrap_or_else(|| panic!("header {name} not found"))
3756 };
3757 let all_recs: Vec<csv::StringRecord> = rdr.records().map(|r| r.unwrap()).collect();
3761 assert_eq!(
3762 all_recs.len(),
3763 2,
3764 "must have exactly two data rows (one per removal)"
3765 );
3766 let rec = &all_recs[0];
3767 let no_details_rec = &all_recs[1];
3768
3769 assert_eq!(&rec[idx("donee")], "Test Charity");
3771 assert_eq!(&rec[idx("appraiser")], "Test Appraiser");
3772 assert_eq!(&rec[idx("donee_ein")], "12-3456789");
3773 assert_eq!(&rec[idx("donee_address")], "123 Main");
3774 assert_eq!(&rec[idx("appraiser_tin")], "987654321");
3775 assert_eq!(&rec[idx("appraiser_ptin")], "P01234567");
3776 assert_eq!(&rec[idx("appraiser_qualifications")], "Certified");
3777 assert_eq!(&rec[idx("appraisal_date")], "2025-06-01");
3778 assert_eq!(&rec[idx("needs_review")], "false");
3779
3780 assert_eq!(
3782 &no_details_rec[idx("donee_ein")],
3783 "",
3784 "no-details row: donee_ein must be empty"
3785 );
3786 assert_eq!(
3787 &no_details_rec[idx("donee_address")],
3788 "",
3789 "no-details row: donee_address must be empty"
3790 );
3791 assert_eq!(
3792 &no_details_rec[idx("appraiser_tin")],
3793 "",
3794 "no-details row: appraiser_tin must be empty"
3795 );
3796 assert_eq!(
3797 &no_details_rec[idx("appraiser_ptin")],
3798 "",
3799 "no-details row: appraiser_ptin must be empty"
3800 );
3801 assert_eq!(
3802 &no_details_rec[idx("appraiser_qualifications")],
3803 "",
3804 "no-details row: appraiser_qualifications must be empty"
3805 );
3806 assert_eq!(
3807 &no_details_rec[idx("appraisal_date")],
3808 "",
3809 "no-details row: appraisal_date must be empty"
3810 );
3811 assert_eq!(
3812 &no_details_rec[idx("needs_review")],
3813 "true",
3814 "no-details carrier row: needs_review must be true"
3815 );
3816 }
3817}
3818
3819pub struct EventRow {
3822 pub reff: String,
3824 pub kind: &'static str,
3826 pub date: TaxDate,
3828 pub sat: Option<btctax_core::Sat>,
3830 pub usd: Option<Usd>,
3832 pub decision_ref: Option<String>,
3835}
3836
3837fn fmt_btc(sat: btctax_core::Sat) -> String {
3839 let whole = sat / 100_000_000;
3840 let frac = (sat % 100_000_000).unsigned_abs();
3841 format!("{whole}.{frac:08}")
3842}
3843
3844pub fn render_events_list(rows: &[EventRow]) -> String {
3847 let mut out = String::new();
3848 if rows.is_empty() {
3849 let _ = writeln!(out, "No decidable events.");
3850 return out;
3851 }
3852 let decided = rows.iter().filter(|r| r.decision_ref.is_some()).count();
3853 let _ = writeln!(
3854 out,
3855 "Decidable events — {} ({} decided, {} open):",
3856 rows.len(),
3857 decided,
3858 rows.len() - decided
3859 );
3860 for r in rows {
3861 let amount = match (r.sat, r.usd) {
3862 (Some(s), Some(u)) => format!("{} BTC (~${})", fmt_btc(s), fmt_money(u)),
3863 (Some(s), None) => format!("{} BTC", fmt_btc(s)),
3864 (None, _) => "—".to_string(),
3865 };
3866 let status = match &r.decision_ref {
3867 Some(d) => format!("[decided: {d}]"),
3868 None => "[decidable]".to_string(),
3869 };
3870 let _ = writeln!(
3871 out,
3872 " {} {} @ {} {} {}",
3873 r.reff, r.kind, r.date, amount, status
3874 );
3875 }
3876 out
3877}
3878
3879#[cfg(test)]
3880mod advisory_wrap_tests {
3881 use super::*;
3882
3883 #[test]
3887 fn advisories_wrap_to_the_house_width_with_a_hanging_indent() {
3888 use btctax_core::tax::advisories::Advisory;
3889 let out = render_advisories(&[Advisory::CtcOdcOmitted { dependents: 2 }]);
3890
3891 for line in out.lines() {
3892 assert!(
3893 line.chars().count() <= ADVISORY_WRAP_COLS,
3894 "line is {} cols, over the {}-col house width: {line:?}",
3895 line.chars().count(),
3896 ADVISORY_WRAP_COLS
3897 );
3898 }
3899 assert!(
3901 out.lines()
3902 .any(|l| l.starts_with(" ") && !l.trim().is_empty()),
3903 "a 300-char advisory must wrap onto continuation lines, got:\n{out}"
3904 );
3905 }
3906}
3907
3908#[cfg(test)]
3909mod events_list_render_tests {
3910 use super::*;
3911 use time::macros::date;
3912
3913 fn row(reff: &str, kind: &'static str, decision_ref: Option<&str>) -> EventRow {
3914 EventRow {
3915 reff: reff.to_owned(),
3916 kind,
3917 date: date!(2025 - 03 - 01),
3918 sat: Some(5_000_000),
3919 usd: Some(rust_decimal_macros::dec!(4271.78)),
3920 decision_ref: decision_ref.map(str::to_owned),
3921 }
3922 }
3923
3924 #[test]
3926 fn empty_renders_a_none_line() {
3927 assert_eq!(render_events_list(&[]), "No decidable events.\n");
3928 }
3929
3930 #[test]
3933 fn rows_are_ref_first_with_bracketed_status() {
3934 let out = render_events_list(&[
3935 row("import|coinbase|in|cb-recv", "transfer-in", None),
3936 row(
3937 "import|coinbase|out|cb-send",
3938 "transfer-out",
3939 Some("decision|1"),
3940 ),
3941 ]);
3942 let lines: Vec<&str> = out.lines().collect();
3943 assert!(
3944 lines[0].contains("2 (1 decided, 1 open)"),
3945 "header: {}",
3946 lines[0]
3947 );
3948 assert_eq!(
3950 lines[1].split_whitespace().next(),
3951 Some("import|coinbase|in|cb-recv")
3952 );
3953 assert!(lines[1].contains("[decidable]"), "open row: {}", lines[1]);
3954 assert!(
3955 lines[1].contains("0.05000000 BTC") && lines[1].contains("4271.78"),
3956 "amount: {}",
3957 lines[1]
3958 );
3959 assert!(
3960 lines[2].contains("[decided: decision|1]"),
3961 "decided row: {}",
3962 lines[2]
3963 );
3964 }
3965}
3966
3967#[cfg(test)]
3968mod holdings_pending_tests {
3969 use super::*;
3972 use btctax_core::state::PendingTransfer;
3973 use btctax_core::EventId;
3974
3975 #[test]
3976 fn holdings_pending_line_shows_in_btc_and_hides_when_reconciled() {
3977 let mut pending = LedgerState::default();
3978 pending.stats.sigma_pending = 3_000_000; pending.pending_reconciliation = vec![PendingTransfer {
3980 event: EventId::decision(1),
3981 principal_sat: 3_000_000,
3982 fee_sat: None,
3983 legs: vec![],
3984 }];
3985 let shown = render_report(&pending, None);
3986 assert!(shown.contains("Pending:"), "pending line present: {shown}");
3987 assert!(shown.contains("0.03000000 BTC"), "BTC unit: {shown}");
3988 assert!(
3989 shown.contains("1 unreconciled transfer"),
3990 "names the count (singular): {shown}"
3991 );
3992 assert!(shown.contains("verify"), "points at `verify`: {shown}");
3993
3994 let reconciled = LedgerState::default();
3996 let hidden = render_report(&reconciled, None);
3997 assert!(
3998 !hidden.contains("Pending:"),
3999 "no pending line when reconciled: {hidden}"
4000 );
4001 }
4002
4003 #[test]
4004 fn holdings_pending_line_pluralizes_multiple_transfers() {
4005 let mut pending = LedgerState::default();
4006 pending.stats.sigma_pending = 150_000_000; pending.pending_reconciliation = vec![
4008 PendingTransfer {
4009 event: EventId::decision(1),
4010 principal_sat: 100_000_000,
4011 fee_sat: None,
4012 legs: vec![],
4013 },
4014 PendingTransfer {
4015 event: EventId::decision(2),
4016 principal_sat: 50_000_000,
4017 fee_sat: None,
4018 legs: vec![],
4019 },
4020 ];
4021 let shown = render_report(&pending, None);
4022 assert!(
4023 shown.contains("2 unreconciled transfers"),
4024 "plural: {shown}"
4025 );
4026 assert!(shown.contains("1.50000000 BTC"), "{shown}");
4027 }
4028}
4029
4030#[cfg(test)]
4031mod decision_class_tests {
4032 use super::*;
4036 use btctax_core::{DisposeKind, InboundClass, IncomeKind, OutflowClass};
4037 use rust_decimal_macros::dec;
4038 use time::macros::date;
4039
4040 #[test]
4041 fn inbound_self_transfer_mine_is_human_no_debug_struct() {
4042 let s = describe_inbound_class(&InboundClass::SelfTransferMine {
4043 basis: Some(dec!(19000)),
4044 acquired_at: Some(date!(2026 - 01 - 01)),
4045 });
4046 assert!(s.contains("self-transfer"), "{s}");
4047 assert!(s.contains("$19000.00"), "names the basis in $: {s}");
4048 assert!(s.contains("2026-01-01"), "names the acquired date: {s}");
4049 assert!(!s.contains('{'), "no Debug struct braces: {s}");
4050 assert!(!s.contains("Some("), "no Debug Option wrapper: {s}");
4051 }
4052
4053 #[test]
4054 fn inbound_self_transfer_mine_defaults_are_named_not_none() {
4055 let s = describe_inbound_class(&InboundClass::SelfTransferMine {
4056 basis: None,
4057 acquired_at: None,
4058 });
4059 assert!(s.contains("self-transfer"), "{s}");
4060 assert!(
4061 s.matches("default").count() >= 2,
4062 "None basis AND date read as 'default': {s}"
4063 );
4064 assert!(!s.contains("None"), "no Debug None: {s}");
4065 }
4066
4067 #[test]
4068 fn inbound_income_names_kind_fmv_business() {
4069 let s = describe_inbound_class(&InboundClass::Income {
4070 kind: IncomeKind::Mining,
4071 fmv: Some(dec!(500)),
4072 business: true,
4073 });
4074 assert!(s.contains("income"), "{s}");
4075 assert!(s.contains("mining"), "names the income kind: {s}");
4076 assert!(s.contains("$500.00"), "names the fmv: {s}");
4077 assert!(s.contains("business"), "flags business: {s}");
4078 assert!(!s.contains('{'), "{s}");
4079 }
4080
4081 #[test]
4082 fn inbound_gift_received_names_fmv() {
4083 let s = describe_inbound_class(&InboundClass::GiftReceived {
4084 donor_basis: Some(dec!(1000)),
4085 donor_acquired_at: Some(date!(2024 - 05 - 05)),
4086 fmv_at_gift: dec!(30000),
4087 });
4088 assert!(s.contains("gift"), "{s}");
4089 assert!(s.contains("$30000.00"), "names the FMV at gift: {s}");
4090 assert!(!s.contains('{'), "{s}");
4091 }
4092
4093 #[test]
4094 fn outflow_classes_are_human() {
4095 assert_eq!(
4096 describe_outflow_class(&OutflowClass::Dispose {
4097 kind: DisposeKind::Sell
4098 }),
4099 "sell"
4100 );
4101 assert_eq!(
4102 describe_outflow_class(&OutflowClass::Dispose {
4103 kind: DisposeKind::Spend
4104 }),
4105 "spend"
4106 );
4107 assert_eq!(describe_outflow_class(&OutflowClass::GiftOut), "gift");
4108 let donate = describe_outflow_class(&OutflowClass::Donate {
4109 appraisal_required: true,
4110 });
4111 assert!(
4112 donate.contains("donate") && donate.contains("appraisal"),
4113 "{donate}"
4114 );
4115 assert_eq!(
4116 describe_outflow_class(&OutflowClass::Donate {
4117 appraisal_required: false
4118 }),
4119 "donate"
4120 );
4121 }
4122}
4123
4124fn render_defensive_candidate(s: &Shortfall) -> String {
4133 let wallet = s
4134 .wallet
4135 .as_ref()
4136 .map(wallet_label)
4137 .unwrap_or_else(|| "(no wallet on this shortfall)".to_string());
4138 let mut out = format!(
4139 " {} {wallet} {} short {} BTC (fee {} BTC)\n",
4140 s.event.canonical(),
4141 s.date,
4142 fmt_btc(s.short_sat),
4143 fmt_btc(s.fee_sat)
4144 );
4145 match &s.wallet {
4146 Some(w) => {
4147 let _ = writeln!(
4148 out,
4149 " -> btctax declare-tranche --amount {} --wallet {} --window-start <earliest \
4150 plausible date> --window-end {}",
4151 fmt_btc(s.short_sat),
4152 wallet_label(w),
4153 s.date
4154 );
4155 }
4156 None => {
4157 let _ = writeln!(
4158 out,
4159 " -> btctax declare-tranche --amount {} --wallet <pick one — this shortfall \
4160 carries none> --window-start <earliest plausible date> --window-end {}",
4161 fmt_btc(s.short_sat),
4162 s.date
4163 );
4164 }
4165 }
4166 out
4167}
4168
4169fn render_defensive_resolve_first(shortfall: &Shortfall, blocker: BlockerKind) -> String {
4170 let wallet = shortfall
4171 .wallet
4172 .as_ref()
4173 .map(wallet_label)
4174 .unwrap_or_else(|| "(no wallet on this shortfall)".to_string());
4175 format!(
4176 " {} {wallet} {} short {} BTC — blocked by {blocker:?}\n -> resolve the blocker \
4177 first (see `btctax events list` / `btctax verify`), then re-run `btctax defensive status`\n",
4178 shortfall.event.canonical(),
4179 shortfall.date,
4180 fmt_btc(shortfall.short_sat)
4181 )
4182}
4183
4184fn render_defensive_advisory(a: &Advisory) -> String {
4185 match a {
4186 Advisory::OverCovered { by_sat } => format!(
4187 " [advisory] this tranche is larger than the shortfall it covers by {} BTC — if a \
4188 later import supplied those coins, promoting files an estimated basis on documented \
4189 coins\n",
4190 fmt_btc(*by_sat)
4191 ),
4192 Advisory::NowDisplacing => " [advisory] this promoted floor now displaces documented \
4193 basis on a real disposal\n"
4194 .to_string(),
4195 Advisory::MethodInversion(msg) => format!(" [advisory] {msg}\n"),
4196 Advisory::TrancheDip(msg) => format!(" [advisory] {msg}\n"),
4197 Advisory::FeeOnlyPromoteNoop => {
4198 " [advisory] the shortfall(s) this tranche covers are \
4199 all fee-component — promoting would only ever substantiate fee-sat basis, never \
4200 principal\n"
4201 .to_string()
4202 }
4203 Advisory::WouldDisplaceIfPromoted => {
4204 " [advisory] promoting this tranche would displace \
4205 documented basis on a real disposal\n"
4206 .to_string()
4207 }
4208 }
4209}
4210
4211fn render_defensive_saving(s: &SavingFlavor) -> String {
4212 match s {
4213 SavingFlavor::ComputedTax { year, delta } => format!(
4214 " [assess] promoting this tranche would save an estimated ${} in federal tax for \
4215 {year} (clamped, never negative)\n",
4216 fmt_money(*delta)
4217 ),
4218 SavingFlavor::Uncomputable { year, gain_delta } => format!(
4219 " [assess] {year} cannot price a tax dollar figure yet (no bundled table / no \
4220 stored tax profile / a Hard blocker) — the realized-gain delta alone is ${}\n",
4221 fmt_money(*gain_delta)
4222 ),
4223 SavingFlavor::Named(msg) => format!(" [assess] {msg}\n"),
4224 }
4225}
4226
4227fn render_defensive_tranche(row: &TrancheRow) -> String {
4228 let mut out = format!(
4229 " {} {} BTC {}\n",
4230 row.target.canonical(),
4231 fmt_btc(row.sat),
4232 match row.status {
4233 TrancheStatus::DeclaredZero => "declared ($0, revocable)",
4234 TrancheStatus::Promoted => "promoted (filed, not revocable)",
4235 }
4236 );
4237 if row.status == TrancheStatus::DeclaredZero {
4238 let _ = writeln!(
4239 out,
4240 " -> btctax promote-tranche {} --provenance <kind> --part-ii-file <path> \
4241 [--i-acknowledge <phrase>]",
4242 row.target.canonical()
4243 );
4244 }
4245 for s in &row.clamped_saving {
4246 out.push_str(&render_defensive_saving(s));
4247 }
4248 for a in &row.advisories {
4249 out.push_str(&render_defensive_advisory(a));
4250 }
4251 out
4252}
4253
4254fn render_defensive_pool_short(ps: &PoolShort) -> String {
4255 format!(
4256 " {:?}: short {} BTC ({} BTC live in tranche(s) here) — don't declare again; review the \
4257 window/wallet on the existing tranche(s) instead\n",
4258 ps.pool,
4259 fmt_btc(ps.short_sat),
4260 fmt_btc(ps.live_tranche_sat)
4261 )
4262}
4263
4264pub fn render_defensive_status(view: &DefensiveFilingView) -> String {
4269 let mut out = String::new();
4270
4271 let nothing_outstanding = view.candidates.is_empty()
4272 && view.resolve_first.is_empty()
4273 && view.tranches.is_empty()
4274 && view.still_short.is_empty()
4275 && view.flagged_years.is_empty();
4276
4277 if nothing_outstanding {
4278 let _ = writeln!(
4279 out,
4280 "Nothing outstanding right now — no declare candidate, no blocked resolve-first \
4281 shortfall, no live tranche, and no flagged export year."
4282 );
4283 return out;
4284 }
4285
4286 if !view.candidates.is_empty() {
4287 let _ = writeln!(
4288 out,
4289 "Declare candidates ({}) — shortfalls a new $0 tranche could cover now:",
4290 view.candidates.len()
4291 );
4292 for s in &view.candidates {
4293 out.push_str(&render_defensive_candidate(s));
4294 }
4295 out.push('\n');
4296 }
4297
4298 if !view.resolve_first.is_empty() {
4299 let _ = writeln!(
4300 out,
4301 "Resolve first ({}) — an open blocker on the same pool/timeframe must clear before \
4302 these can be declared:",
4303 view.resolve_first.len()
4304 );
4305 for t in &view.resolve_first {
4306 if let Triage::ResolveFirst { shortfall, blocker } = t {
4307 out.push_str(&render_defensive_resolve_first(shortfall, *blocker));
4308 }
4309 }
4310 out.push('\n');
4311 }
4312
4313 if !view.tranches.is_empty() {
4314 let _ = writeln!(out, "Live tranches ({}):", view.tranches.len());
4315 for row in &view.tranches {
4316 out.push_str(&render_defensive_tranche(row));
4317 }
4318 out.push('\n');
4319 }
4320
4321 if !view.still_short.is_empty() {
4322 let _ = writeln!(out, "Pools still short ({}):", view.still_short.len());
4323 for ps in &view.still_short {
4324 out.push_str(&render_defensive_pool_short(ps));
4325 }
4326 out.push('\n');
4327 }
4328
4329 if !view.flagged_years.is_empty() {
4330 let years: Vec<String> = view.flagged_years.iter().map(|y| y.to_string()).collect();
4331 let _ = writeln!(
4332 out,
4333 "Flagged years needing (re-)export attention ({}): {}",
4334 years.len(),
4335 years.join(", ")
4336 );
4337 let _ = writeln!(
4338 out,
4339 " -> btctax export-irs-pdf --tax-year <year> (or `btctax export-snapshot` for the \
4340 CSV projection)"
4341 );
4342 out.push('\n');
4343 }
4344
4345 if view.safe_harbor_blocked {
4346 let _ = writeln!(
4347 out,
4348 "Safe-harbor allocation: BLOCKED — v1 keeps a pre-2025 conservative-filing tranche and \
4349 a safe-harbor allocation mutually exclusive."
4350 );
4351 }
4352
4353 if out.ends_with("\n\n") {
4355 out.pop();
4356 }
4357 out
4358}
4359
4360#[cfg(test)]
4361mod defensive_status_tests {
4362 use super::*;
4363 use btctax_core::identity::EventId;
4364 use std::collections::BTreeSet;
4365 use time::macros::date;
4366
4367 fn empty_view() -> DefensiveFilingView {
4368 DefensiveFilingView {
4369 candidates: vec![],
4370 resolve_first: vec![],
4371 tranches: vec![],
4372 still_short: vec![],
4373 flagged_years: BTreeSet::new(),
4374 safe_harbor_blocked: false,
4375 }
4376 }
4377
4378 #[test]
4379 fn nothing_outstanding_is_a_single_line_all_clear() {
4380 let out = render_defensive_status(&empty_view());
4381 assert!(out.contains("Nothing outstanding right now"), "{out}");
4382 }
4383
4384 #[test]
4385 fn candidate_names_the_declare_tranche_verb_with_its_own_amount_wallet_and_window_end() {
4386 let mut view = empty_view();
4387 view.candidates.push(Shortfall {
4388 event: EventId::decision(7),
4389 wallet: Some(WalletId::Exchange {
4390 provider: "coinbase".into(),
4391 account: "default".into(),
4392 }),
4393 date: date!(2019 - 03 - 14),
4394 short_sat: 5_000_000,
4395 fee_sat: 0,
4396 });
4397 let out = render_defensive_status(&view);
4398 assert!(out.contains("Declare candidates (1)"), "{out}");
4399 assert!(out.contains("decision|7"), "{out}");
4400 assert!(
4401 out.contains(
4402 "btctax declare-tranche --amount 0.05000000 \
4403 --wallet exchange:coinbase:default"
4404 ),
4405 "{out}"
4406 );
4407 assert!(out.contains("--window-end 2019-03-14"), "{out}");
4408 }
4409
4410 #[test]
4411 fn resolve_first_names_the_blocker_and_the_re_run_remedy_not_declare() {
4412 let mut view = empty_view();
4413 view.resolve_first.push(Triage::ResolveFirst {
4414 shortfall: Shortfall {
4415 event: EventId::decision(9),
4416 wallet: Some(WalletId::SelfCustody {
4417 label: "cold".into(),
4418 }),
4419 date: date!(2020 - 01 - 01),
4420 short_sat: 1_000_000,
4421 fee_sat: 0,
4422 },
4423 blocker: BlockerKind::UnknownBasisInbound,
4424 });
4425 let out = render_defensive_status(&view);
4426 assert!(out.contains("Resolve first (1)"), "{out}");
4427 assert!(out.contains("blocked by UnknownBasisInbound"), "{out}");
4428 assert!(
4429 !out.contains("declare-tranche"),
4430 "a resolve-first row must not offer the declare verb: {out}"
4431 );
4432 }
4433
4434 #[test]
4435 fn declared_zero_tranche_offers_promote_promoted_tranche_does_not() {
4436 let mut view = empty_view();
4437 view.tranches.push(TrancheRow {
4438 target: EventId::decision(3),
4439 sat: 5_000_000,
4440 status: TrancheStatus::DeclaredZero,
4441 clamped_saving: vec![],
4442 advisories: vec![],
4443 });
4444 view.tranches.push(TrancheRow {
4445 target: EventId::decision(5),
4446 sat: 2_000_000,
4447 status: TrancheStatus::Promoted,
4448 clamped_saving: vec![],
4449 advisories: vec![],
4450 });
4451 let out = render_defensive_status(&view);
4452 assert!(
4453 out.contains("btctax promote-tranche decision|3"),
4454 "declared ($0) row must offer promote-tranche: {out}"
4455 );
4456 assert!(
4457 !out.contains("promote-tranche decision|5"),
4458 "an already-promoted row must not offer promote-tranche again: {out}"
4459 );
4460 assert!(out.contains("declared ($0, revocable)"), "{out}");
4461 assert!(out.contains("promoted (filed, not revocable)"), "{out}");
4462 }
4463
4464 #[test]
4465 fn flagged_years_name_the_export_verb() {
4466 let mut view = empty_view();
4467 view.flagged_years.insert(2024);
4468 view.flagged_years.insert(2025);
4469 let out = render_defensive_status(&view);
4470 assert!(out.contains("2024, 2025"), "{out}");
4471 assert!(out.contains("btctax export-irs-pdf --tax-year"), "{out}");
4472 }
4473
4474 #[test]
4475 fn safe_harbor_blocked_is_named() {
4476 let mut view = empty_view();
4477 view.safe_harbor_blocked = true;
4478 view.flagged_years.insert(2024);
4480 let out = render_defensive_status(&view);
4481 assert!(out.contains("Safe-harbor allocation: BLOCKED"), "{out}");
4482 }
4483}