1use crate::config::CliConfig;
4use btctax_adapters::FileReport;
5use btctax_core::conventions::{tax_date, Sat, Usd, TRANSITION_DATE};
6use btctax_core::persistence::ImportReport;
7use btctax_core::DonationDetails;
8use btctax_core::{
9 conservation_report, disposal_compliance, form_8283, form_8949, schedule_d,
10 year_donation_deduction, BasisSource, Blocker, BlockerKind, ComplianceStatus,
11 ConservationReport, DisposalCompliance, DisposalLeg, DisposeKind, EventId, EventPayload,
12 Form8283HowAcquired, Form8283Section, Form8949Box, Form8949Part, GiftZone, HarvestReport,
13 HarvestStatus, HarvestTarget, InboundClass, IncomeKind, LedgerEvent, LedgerState, LotMethod,
14 LtcgBracket, OutflowClass, RemovalKind, RemovalLeg, ScheduleDTotals, SeTaxResult, SellReport,
15 SellStatus, Severity, TaxDate, Term, WalletId,
16};
17use btctax_store::fsperms;
18use csv::Writer;
19use std::collections::{BTreeMap, BTreeSet};
20use std::fmt::Write as _;
21use std::path::Path;
22
23fn fmt_money(d: btctax_core::conventions::Usd) -> String {
36 format!("{d:.2}")
37}
38
39fn basis_source_tag(bs: BasisSource) -> &'static str {
44 match bs {
45 BasisSource::ExchangeProvided => "exchange",
46 BasisSource::ComputedFromCost => "cost",
47 BasisSource::FmvAtIncome => "income_fmv",
48 BasisSource::CarriedFromTransfer => "transferred",
49 BasisSource::GiftCarryover => "gift_carryover",
50 BasisSource::GiftFmvFallback => "gift_fmv_fallback",
51 BasisSource::SafeHarborAllocated => "safe_harbor",
52 BasisSource::ReconstructedPerWallet => "reconstructed",
53 BasisSource::SelfTransferInbound => "self_transfer_in",
54 BasisSource::EstimatedConservative => "estimated_conservative",
55 }
56}
57
58fn pseudo_tag(pseudo: bool) -> &'static str {
64 if pseudo {
65 " [PSEUDO]"
66 } else {
67 ""
68 }
69}
70
71fn dispose_kind_tag(dk: DisposeKind) -> &'static str {
72 match dk {
73 DisposeKind::Sell => "sell",
74 DisposeKind::Spend => "spend",
75 }
76}
77
78fn income_kind_tag(ik: IncomeKind) -> &'static str {
79 match ik {
80 IncomeKind::Mining => "mining",
81 IncomeKind::Staking => "staking",
82 IncomeKind::Interest => "interest",
83 IncomeKind::Airdrop => "airdrop",
84 IncomeKind::Reward => "reward",
85 }
86}
87
88pub fn describe_inbound_class(c: &InboundClass) -> String {
95 match c {
96 InboundClass::Income {
97 kind,
98 fmv,
99 business,
100 } => {
101 let mut s = format!("income {}", income_kind_tag(*kind));
102 if let Some(v) = fmv {
103 let _ = write!(s, ", fmv ${}", fmt_money(*v));
104 }
105 if *business {
106 s.push_str(", business");
107 }
108 s
109 }
110 InboundClass::GiftReceived {
111 donor_basis,
112 donor_acquired_at,
113 fmv_at_gift,
114 } => {
115 let mut s = format!("gift received, fmv ${}", fmt_money(*fmv_at_gift));
116 if let Some(b) = donor_basis {
117 let _ = write!(s, ", donor-basis ${}", fmt_money(*b));
118 }
119 if let Some(d) = donor_acquired_at {
120 let _ = write!(s, ", donor-acquired {d}");
121 }
122 s
123 }
124 InboundClass::SelfTransferMine { basis, acquired_at } => {
125 let basis_s = match basis {
128 Some(v) => format!("${}", fmt_money(*v)),
129 None => "default".to_string(),
130 };
131 let acq_s = match acquired_at {
132 Some(d) => d.to_string(),
133 None => "default".to_string(),
134 };
135 format!("self-transfer (mine), basis {basis_s}, acquired {acq_s}")
136 }
137 }
138}
139
140pub fn describe_outflow_class(c: &OutflowClass) -> String {
143 match c {
144 OutflowClass::Dispose { kind } => dispose_kind_tag(*kind).to_string(),
145 OutflowClass::GiftOut => "gift".to_string(),
146 OutflowClass::Donate { appraisal_required } => {
147 if *appraisal_required {
148 "donate (appraisal required)".to_string()
149 } else {
150 "donate".to_string()
151 }
152 }
153 }
154}
155
156fn gift_zone_tag(gz: GiftZone) -> &'static str {
157 match gz {
158 GiftZone::Gain => "gain",
159 GiftZone::Loss => "loss",
160 GiftZone::NoGainNoLoss => "no_gain_no_loss",
161 }
162}
163
164fn removal_kind_tag(rk: RemovalKind) -> &'static str {
165 match rk {
166 RemovalKind::Gift => "gift",
167 RemovalKind::Donation => "donation",
168 }
169}
170
171fn term_tag(t: Term) -> &'static str {
173 match t {
174 Term::ShortTerm => "short",
175 Term::LongTerm => "long",
176 }
177}
178
179fn form8949_part_tag(p: Form8949Part) -> &'static str {
181 match p {
182 Form8949Part::ShortTerm => "ST",
183 Form8949Part::LongTerm => "LT",
184 }
185}
186
187fn form8949_box_tag(b: Form8949Box) -> &'static str {
192 match b {
193 Form8949Box::C => "C",
194 Form8949Box::F => "F",
195 Form8949Box::I => "I",
196 Form8949Box::L => "L",
197 }
198}
199
200fn form8283_section_tag(s: Form8283Section) -> &'static str {
203 match s {
204 Form8283Section::A => "A",
205 Form8283Section::B => "B",
206 }
207}
208
209fn form8283_how_acquired_tag(h: Form8283HowAcquired) -> &'static str {
212 match h {
213 Form8283HowAcquired::Purchased => "Purchased",
214 Form8283HowAcquired::Gift => "Gift",
215 Form8283HowAcquired::Other => "Other",
216 Form8283HowAcquired::Review => "Review",
217 }
218}
219
220fn compliance_status_tag(cs: &ComplianceStatus) -> String {
229 match cs {
230 ComplianceStatus::StandingOrder { effective_from } => {
231 format!("standing_order:{effective_from}")
232 }
233 ComplianceStatus::Contemporaneous => "contemporaneous".into(),
234 ComplianceStatus::AttestedRecording => "attested_recording".into(),
235 ComplianceStatus::NonCompliant => "non_compliant".into(),
236 }
237}
238
239pub fn render_file_reports(reports: &[FileReport], import: &ImportReport) -> String {
241 let mut out = String::new();
242 let _ = writeln!(out, "Import:");
243 for r in reports {
244 let _ = writeln!(
245 out,
246 " {} [{}]: parsed {} rows -> {} BTC events ({} dropped no-BTC, {} unclassified)",
247 r.source.tag(),
248 r.label,
249 r.parsed_rows,
250 r.btc_events,
251 r.dropped_no_btc,
252 r.unclassified
253 );
254 }
255 let _ = writeln!(
256 out,
257 " appended {} | duplicates {} | NEW import-conflicts {}",
258 import.appended, import.duplicates, import.conflicts
259 );
260 if import.conflicts > 0 {
261 let _ = writeln!(
262 out,
263 " ! resolve conflicts with `reconcile accept-conflict <id>` or `reject-conflict <id>` (see `verify`)"
264 );
265 }
266 out
267}
268
269pub fn wallet_label(w: &WalletId) -> String {
271 match w {
272 WalletId::Exchange { provider, account } => format!("exchange:{provider}:{account}"),
273 WalletId::SelfCustody { label } => format!("self:{label}"),
274 }
275}
276
277pub fn no_lots_message(wallet: &WalletId, at: TaxDate, available: Sat, requested: Sat) -> String {
284 if available == 0 {
285 format!("no BTC available in {} as of {}", wallet_label(wallet), at)
286 } else {
287 format!(
288 "only {} BTC available in {} as of {} (requested {} BTC)",
289 fmt_btc(available),
290 wallet_label(wallet),
291 at,
292 fmt_btc(requested),
293 )
294 }
295}
296
297fn disposal_year(d: &btctax_core::Disposal) -> i32 {
298 d.disposed_at.year()
299}
300
301pub fn render_report(state: &LedgerState, year: Option<i32>) -> String {
303 let mut out = String::new();
304 let yr = |y: i32| year.is_none_or(|f| f == y); let _ = writeln!(out, "Holdings (per wallet):");
307 if state.holdings_by_wallet.is_empty() {
308 let _ = writeln!(out, " none");
309 }
310 for (w, sat) in &state.holdings_by_wallet {
311 let _ = writeln!(out, " {}: {} sat", wallet_label(w), sat);
312 }
313 if state.stats.sigma_pending > 0 {
316 let n = state.pending_reconciliation.len();
317 let plural = if n == 1 { "transfer" } else { "transfers" };
318 let _ = writeln!(
319 out,
320 " Pending: {} BTC ({n} unreconciled {plural} — see `btctax verify`)",
321 fmt_btc(state.stats.sigma_pending),
322 );
323 }
324
325 let _ = writeln!(out, "Lots:");
326 if state.lots.is_empty() {
327 let _ = writeln!(out, " none");
328 }
329 for l in &state.lots {
330 let _ = writeln!(
331 out,
332 " {}#{} {} remaining {} sat | basis {} ({}){}{}",
333 l.lot_id.origin_event_id.canonical(),
334 l.lot_id.split_sequence,
335 wallet_label(&l.wallet),
336 l.remaining_sat,
337 l.usd_basis,
338 basis_source_tag(l.basis_source),
339 if l.basis_pending {
340 " [basis pending]"
341 } else {
342 ""
343 },
344 pseudo_tag(l.pseudo),
345 );
346 }
347
348 let label = match year {
349 Some(y) => format!("(year {y})"),
350 None => "(all years)".to_string(),
351 };
352
353 let disposals: Vec<_> = state
354 .disposals
355 .iter()
356 .filter(|d| yr(disposal_year(d)))
357 .collect();
358 if disposals.is_empty() {
359 let _ = writeln!(out, "Disposals {}: none", label);
360 } else {
361 let _ = writeln!(out, "Disposals {}:", label);
362 for d in disposals {
363 let _ = writeln!(
364 out,
365 " {} @ {} ({})",
366 dispose_kind_tag(d.kind),
367 d.disposed_at,
368 d.event.canonical()
369 );
370 for leg in &d.legs {
371 render_disposal_leg(&mut out, leg);
372 }
373 }
374 }
375
376 let removals: Vec<_> = state
377 .removals
378 .iter()
379 .filter(|r| yr(r.removed_at.year()))
380 .collect();
381 if removals.is_empty() {
382 let _ = writeln!(out, "Removals {}: none", label);
383 } else {
384 let _ = writeln!(out, "Removals {}:", label);
385 for r in removals {
386 let deduction_tag = match r.claimed_deduction {
387 Some(d) => format!(" [claimed deduction {}]", fmt_money(d)),
388 None => String::new(),
389 };
390 let _ = writeln!(
391 out,
392 " {} @ {} ({}){}",
393 removal_kind_tag(r.kind),
394 r.removed_at,
395 r.event.canonical(),
396 deduction_tag
397 );
398 for leg in &r.legs {
399 render_removal_leg(&mut out, leg);
400 }
401 }
402 }
403
404 let income: Vec<_> = state
405 .income_recognized
406 .iter()
407 .filter(|i| yr(i.recognized_at.year()))
408 .collect();
409 if income.is_empty() {
410 let _ = writeln!(out, "Income {}: none", label);
411 } else {
412 let _ = writeln!(out, "Income {}:", label);
413 for i in income {
414 let _ = writeln!(
415 out,
416 " {} @ {} {} sat = {} USD{}{}",
417 income_kind_tag(i.kind),
418 i.recognized_at,
419 i.sat,
420 i.usd_fmv,
421 if i.business { " [business]" } else { "" },
422 pseudo_tag(i.pseudo), );
424 }
425 }
426
427 let charitable_total: btctax_core::conventions::Usd = state
430 .removals
431 .iter()
432 .filter(|r| yr(r.removed_at.year()) && r.kind == RemovalKind::Donation)
433 .filter_map(|r| r.claimed_deduction)
434 .sum();
435 let _ = writeln!(
436 out,
437 "Charitable deduction {} (Schedule A itemized) — BEFORE §170(b) AGI limits / carryover: {}",
438 label,
439 fmt_money(charitable_total)
440 );
441
442 out
443}
444
445fn render_disposal_leg(out: &mut String, leg: &DisposalLeg) {
446 let zone = leg
447 .gift_zone
448 .map(|z| format!(" gift-zone {}", gift_zone_tag(z)))
449 .unwrap_or_default();
450 let _ = writeln!(
451 out,
452 " {} sat: proceeds {} basis {} gain {} {}{}{}",
453 leg.sat,
454 leg.proceeds,
455 leg.basis,
456 leg.gain,
457 term_tag(leg.term),
458 zone,
459 pseudo_tag(leg.pseudo),
460 );
461}
462
463fn render_removal_leg(out: &mut String, leg: &RemovalLeg) {
464 let _ = writeln!(
465 out,
466 " {} sat: basis {} fmv {} {} (zero gain){}",
467 leg.sat,
468 leg.basis,
469 leg.fmv_at_transfer,
470 term_tag(leg.term),
471 pseudo_tag(leg.pseudo),
472 );
473}
474
475pub fn filing_status_tag(fs: btctax_core::FilingStatus) -> &'static str {
483 use btctax_core::FilingStatus::*;
484 match fs {
485 Single => "single",
486 Mfj => "mfj",
487 Mfs => "mfs",
488 HoH => "hoh",
489 Qss => "qss",
490 }
491}
492
493pub fn fee_treatment_display(t: btctax_core::FeeTreatment) -> &'static str {
497 match t {
498 btctax_core::FeeTreatment::TreatmentC => "non-taxable, basis carries (TP8 c)",
499 btctax_core::FeeTreatment::TreatmentB => "taxable mini-disposition (TP8 b)",
500 }
501}
502
503pub fn lot_method_display(m: LotMethod) -> &'static str {
504 match m {
505 LotMethod::Fifo => "FIFO",
506 LotMethod::Lifo => "LIFO",
507 LotMethod::Hifo => "HIFO",
508 }
509}
510
511#[derive(Debug, Clone)]
513pub struct ElectionLine {
514 pub recorded: TaxDate,
515 pub effective_from: TaxDate,
516 pub method: LotMethod,
517 pub wallet: Option<WalletId>,
519 pub note: &'static str,
521}
522
523pub fn voided_targets(events: &[LedgerEvent]) -> BTreeSet<EventId> {
526 events
527 .iter()
528 .filter_map(|e| match &e.payload {
529 EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
530 _ => None,
531 })
532 .collect()
533}
534
535pub fn method_election_lines(
539 events: &[LedgerEvent],
540 voided: &BTreeSet<EventId>,
541) -> Vec<ElectionLine> {
542 let mut election_events: Vec<(u64, &LedgerEvent)> = events
543 .iter()
544 .filter_map(|e| {
545 if let EventId::Decision { seq } = e.id {
546 if matches!(e.payload, EventPayload::MethodElection(_)) {
547 return Some((seq, e));
548 }
549 }
550 None
551 })
552 .collect();
553 election_events.sort_by_key(|(s, _)| *s);
554
555 election_events
556 .iter()
557 .map(|(_, e)| {
558 let EventPayload::MethodElection(me) = &e.payload else {
559 unreachable!("filtered to MethodElection above")
560 };
561 let recorded = tax_date(e.utc_timestamp, e.original_tz);
562 let note = if voided.contains(&e.id) {
563 "voided"
564 } else if me.effective_from < TRANSITION_DATE || me.effective_from < recorded {
565 "backdated/ignored"
566 } else {
567 "in force"
568 };
569 ElectionLine {
570 recorded,
571 effective_from: me.effective_from,
572 method: me.method,
573 wallet: me.wallet.clone(),
574 note,
575 }
576 })
577 .collect()
578}
579
580#[derive(Debug, Clone)]
582pub struct VerifyReport {
583 pub conservation: ConservationReport,
584 pub hard: Vec<Blocker>,
585 pub advisory: Vec<Blocker>,
586 pub pending: usize,
587 pub unknown_basis_inbounds: usize,
588 pub safe_harbor: String,
589 pub declared_pre2025_method: LotMethod,
591 pub pre2025_method_attested: bool,
592 pub elections: Vec<ElectionLine>,
594 pub selection_count: usize,
596 pub compliance: Vec<DisposalCompliance>,
598 pub drift: Vec<String>,
603}
604
605impl VerifyReport {
606 pub fn has_hard_blockers(&self) -> bool {
609 !self.hard.is_empty()
610 }
611}
612
613fn safe_harbor_status(state: &LedgerState, _events: &[LedgerEvent]) -> String {
624 let effective_path_b = state
627 .lots
628 .iter()
629 .any(|l| l.basis_source == BasisSource::SafeHarborAllocated)
630 || state.disposals.iter().any(|d| {
631 d.legs
632 .iter()
633 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
634 })
635 || state.removals.iter().any(|r| {
636 r.legs
637 .iter()
638 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
639 });
640 let unconservable = state
641 .blockers
642 .iter()
643 .any(|b| b.kind == BlockerKind::SafeHarborUnconservable);
644 let timebar = state
645 .blockers
646 .iter()
647 .any(|b| b.kind == BlockerKind::SafeHarborTimebar);
648 if unconservable {
652 "Path B allocation FAILS conservation/eligibility (hard, §7.4) — fix the allocation"
653 .to_string()
654 } else if effective_path_b {
655 "Path B safe-harbor allocation is effective (§7.4)".to_string()
656 } else if timebar {
657 "Path B time-barred -> using Path A (advisory); `reconcile safe-harbor attest` if timely in your books".to_string()
658 } else {
659 "Path A (actual per-wallet reconstruction; default, no election)".to_string()
660 }
661}
662
663pub fn build_verify(
664 state: &LedgerState,
665 events: &[LedgerEvent],
666 prices: &dyn btctax_core::price::PriceProvider,
667 cli: &CliConfig,
668) -> VerifyReport {
669 let conservation = conservation_report(state);
670 let mut hard = Vec::new();
671 let mut advisory = Vec::new();
672 for b in &state.blockers {
673 match b.kind.severity() {
674 Severity::Hard => hard.push(b.clone()),
675 Severity::Advisory => advisory.push(b.clone()),
676 }
677 }
678 let unknown_basis_inbounds = state
679 .blockers
680 .iter()
681 .filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
682 .count();
683
684 let voided = voided_targets(events);
686
687 let elections = method_election_lines(events, &voided);
689
690 let selection_count = events
695 .iter()
696 .filter(|e| matches!(e.payload, EventPayload::LotSelection(_)) && !voided.contains(&e.id))
697 .count();
698
699 let compliance = disposal_compliance(events, state);
701
702 let drift = btctax_core::conservative_promote::promote_drift_advisory(events, prices);
705
706 VerifyReport {
707 conservation,
708 hard,
709 advisory,
710 pending: state.pending_reconciliation.len(),
711 unknown_basis_inbounds,
712 safe_harbor: safe_harbor_status(state, events),
713 declared_pre2025_method: cli.pre2025_method,
714 pre2025_method_attested: cli.pre2025_method_attested,
715 elections,
716 selection_count,
717 compliance,
718 drift,
719 }
720}
721
722pub fn write_csv_exports(
734 out_dir: &Path,
735 state: &LedgerState,
736 tax_year: Option<i32>,
737 se_result: Option<&SeTaxResult>,
738 donation_details: &BTreeMap<EventId, DonationDetails>,
739) -> Result<(), crate::CliError> {
740 fsperms::mkdir_owner_only(out_dir)?;
741
742 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("lots.csv"))?);
743 w.write_record([
744 "origin_event",
745 "split",
746 "wallet",
747 "acquired_at",
748 "remaining_sat",
749 "usd_basis",
750 "basis_source",
751 "basis_pending",
752 ])?;
753 for l in &state.lots {
754 w.write_record([
755 l.lot_id.origin_event_id.canonical(),
756 l.lot_id.split_sequence.to_string(),
757 wallet_label(&l.wallet),
758 l.acquired_at.to_string(),
759 l.remaining_sat.to_string(),
760 l.usd_basis.to_string(),
761 basis_source_tag(l.basis_source).to_string(),
762 l.basis_pending.to_string(),
763 ])?;
764 }
765 w.flush()?;
766
767 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("disposals.csv"))?);
768 w.write_record([
769 "event",
770 "kind",
771 "disposed_at",
772 "lot",
773 "sat",
774 "proceeds",
775 "basis",
776 "gain",
777 "term",
778 "gift_zone",
779 "acquired_at",
780 "wallet",
781 ])?;
782 for d in &state.disposals {
783 for leg in &d.legs {
784 w.write_record([
785 d.event.canonical(),
786 dispose_kind_tag(d.kind).to_string(),
787 d.disposed_at.to_string(),
788 format!(
789 "{}#{}",
790 leg.lot_id.origin_event_id.canonical(),
791 leg.lot_id.split_sequence
792 ),
793 leg.sat.to_string(),
794 leg.proceeds.to_string(),
795 leg.basis.to_string(),
796 leg.gain.to_string(),
797 term_tag(leg.term).to_string(),
798 leg.gift_zone
799 .map(|z| gift_zone_tag(z).to_string())
800 .unwrap_or_default(),
801 leg.acquired_at.to_string(),
802 wallet_label(&leg.wallet),
803 ])?;
804 }
805 }
806 w.flush()?;
807
808 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("removals.csv"))?);
809 w.write_record([
816 "event",
817 "kind",
818 "removed_at",
819 "lot",
820 "sat",
821 "basis",
822 "fmv_at_transfer",
823 "term",
824 "acquired_at",
825 "claimed_deduction",
826 "donee",
827 ])?;
828 for r in &state.removals {
829 let deduction_first = r
830 .claimed_deduction
831 .map(|d| d.to_string())
832 .unwrap_or_default();
833 let donee_cell = r.donee.clone().unwrap_or_default();
834 for (leg_idx, leg) in r.legs.iter().enumerate() {
835 let deduction_cell: &str = if leg_idx == 0 { &deduction_first } else { "" };
838 w.write_record([
839 r.event.canonical(),
840 removal_kind_tag(r.kind).to_string(),
841 r.removed_at.to_string(),
842 format!(
843 "{}#{}",
844 leg.lot_id.origin_event_id.canonical(),
845 leg.lot_id.split_sequence
846 ),
847 leg.sat.to_string(),
848 leg.basis.to_string(),
849 leg.fmv_at_transfer.to_string(),
850 term_tag(leg.term).to_string(),
851 leg.acquired_at.to_string(),
852 deduction_cell.to_string(),
853 donee_cell.clone(),
854 ])?;
855 }
856 }
857 w.flush()?;
858
859 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("income.csv"))?);
860 w.write_record([
861 "event",
862 "kind",
863 "recognized_at",
864 "sat",
865 "usd_fmv",
866 "business",
867 ])?;
868 for i in &state.income_recognized {
869 w.write_record([
870 i.event.canonical(),
871 income_kind_tag(i.kind).to_string(),
872 i.recognized_at.to_string(),
873 i.sat.to_string(),
874 i.usd_fmv.to_string(),
875 i.business.to_string(),
876 ])?;
877 }
878 w.flush()?;
879
880 if let Some(year) = tax_year {
883 write_form8949_csv(out_dir, state, year)?;
884 write_schedule_d_csv(out_dir, state, year)?;
885 write_form8283_csv(out_dir, state, year, donation_details)?;
886 write_basis_methodology_txt(out_dir, state, year)?; if let Some(se) = se_result {
894 write_schedule_se_csv(out_dir, se)?;
895 }
896 }
897 Ok(())
898}
899
900pub fn write_form_csvs(
916 out_dir: &Path,
917 state: &LedgerState,
918 year: i32,
919 se_result: Option<&SeTaxResult>,
920 donation_details: &BTreeMap<EventId, DonationDetails>,
921) -> Result<(), crate::CliError> {
922 fsperms::mkdir_owner_only(out_dir)?;
923 write_form8949_csv(out_dir, state, year)?;
924 write_schedule_d_csv(out_dir, state, year)?;
925 write_form8283_csv(out_dir, state, year, donation_details)?;
926 write_basis_methodology_txt(out_dir, state, year)?; if let Some(se) = se_result {
928 write_schedule_se_csv(out_dir, se)?;
929 }
930 Ok(())
931}
932
933pub fn write_form_8275_txt(
950 out_dir: &Path,
951 state: &LedgerState,
952 events: &[LedgerEvent],
953 year: i32,
954) -> Result<(), crate::CliError> {
955 write_form_8275_txt_named(out_dir, state, events, year, "form_8275.txt")
956}
957
958pub(crate) fn write_form_8275_txt_named(
964 out_dir: &Path,
965 state: &LedgerState,
966 events: &[LedgerEvent],
967 year: i32,
968 filename: &str,
969) -> Result<(), crate::CliError> {
970 use std::io::Write as _;
971 if let Some(disc) = btctax_core::tax::form8275::disclosure_8275(events, state, year) {
972 let mut file = fsperms::open_owner_only(&out_dir.join(filename))?;
973 write!(file, "{}", disc.render())?;
975 }
976 Ok(())
977}
978
979pub(crate) fn write_basis_methodology_txt(
984 out_dir: &Path,
985 state: &LedgerState,
986 year: i32,
987) -> Result<(), crate::CliError> {
988 use std::io::Write as _;
989 if let Some(text) = btctax_core::conservative::basis_methodology(state, year) {
990 let mut file = fsperms::open_owner_only(&out_dir.join("basis_methodology.txt"))?;
991 writeln!(file, "{text}")?;
992 }
993 Ok(())
994}
995
996fn write_schedule_se_csv(out_dir: &Path, se: &SeTaxResult) -> Result<(), crate::CliError> {
1000 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_se.csv"))?);
1001 w.write_record([
1002 "net_se_earnings",
1003 "se_base_9235",
1004 "ss_component",
1005 "medicare_component",
1006 "additional_medicare_component",
1007 "total_se_tax",
1008 "deductible_half",
1009 ])?;
1010 w.write_record([
1011 se.net_se.to_string(),
1012 se.base.to_string(),
1013 se.ss.to_string(),
1014 se.medicare.to_string(),
1015 se.addl.to_string(),
1016 se.total.to_string(),
1017 se.deductible_half.to_string(),
1018 ])?;
1019 w.flush()?;
1020 Ok(())
1021}
1022
1023pub const FORM_8283_AGGREGATION_CAVEAT: &str =
1029 "Section A/B reflects the \u{00a7}170(f)(11)(F) year-aggregate for similar property: all BTC \
1030 donations in the year are summed (all BTC is 'similar property'); if the year-total claimed \
1031 deduction exceeds $5,000 every row is Section B (qualified appraisal required), otherwise \
1032 Section A. CCA 202302012: the readily-valued exception does not apply to crypto.";
1033
1034pub fn render_donation_appraisal_advisory(state: &LedgerState, year: i32) -> Option<String> {
1052 use btctax_core::QUALIFIED_APPRAISAL_THRESHOLD;
1053 let agg = year_donation_deduction(state, year);
1054 if agg <= QUALIFIED_APPRAISAL_THRESHOLD {
1055 return None;
1056 }
1057 let threshold = fmt_money(QUALIFIED_APPRAISAL_THRESHOLD);
1060 Some(format!(
1061 "\u{00a7}170(f)(11)(F): your {year} BTC donations aggregate ${} of claimed deduction \
1062 (> ${threshold}) \u{2014} a qualified appraisal is required for the donated BTC even if \
1063 no single donation exceeds ${threshold} (all BTC is 'similar property'; CCA 202302012 \
1064 \u{2014} no readily-valued exception for crypto).",
1065 fmt_money(agg)
1066 ))
1067}
1068
1069fn write_form8283_csv(
1076 out_dir: &Path,
1077 state: &LedgerState,
1078 year: i32,
1079 details: &BTreeMap<EventId, DonationDetails>,
1080) -> Result<(), crate::CliError> {
1081 use std::io::Write as _;
1082 let mut file = fsperms::open_owner_only(&out_dir.join("form8283.csv"))?;
1083
1084 writeln!(file, "# {FORM_8283_AGGREGATION_CAVEAT}")?;
1086
1087 let total_deduction = year_donation_deduction(state, year);
1093 if total_deduction <= rust_decimal::Decimal::from(500) {
1094 writeln!(
1095 file,
1096 "# [R0-M1] The year's total noncash charitable deduction ({}) is <= $500; Form 8283 is \
1097 NOT required at that level (rows below are informational only).",
1098 fmt_money(total_deduction)
1099 )?;
1100 }
1101
1102 let mut w = Writer::from_writer(file);
1103 w.write_record([
1104 "section",
1105 "description",
1106 "how_acquired",
1107 "date_acquired",
1108 "date_contributed",
1109 "cost_basis",
1110 "fmv",
1111 "claimed_deduction",
1112 "fmv_method",
1113 "donee",
1114 "appraiser",
1115 "needs_review",
1116 "donee_ein",
1118 "donee_address",
1119 "appraiser_tin",
1120 "appraiser_ptin",
1121 "appraiser_qualifications",
1122 "appraisal_date",
1123 ])?;
1124 for row in form_8283(state, year, details) {
1125 let d = row.details.as_ref();
1126 w.write_record([
1127 row.section
1128 .map(form8283_section_tag)
1129 .unwrap_or("")
1130 .to_string(),
1131 row.description,
1132 form8283_how_acquired_tag(row.how_acquired).to_string(),
1133 row.date_acquired.to_string(),
1134 row.date_contributed.to_string(),
1135 row.cost_basis.to_string(),
1136 row.fmv.to_string(),
1137 row.claimed_deduction
1138 .map(|d| d.to_string())
1139 .unwrap_or_default(),
1140 row.fmv_method,
1141 row.donee,
1142 row.appraiser,
1143 row.needs_review.to_string(),
1144 d.and_then(|d| d.donee_ein.clone()).unwrap_or_default(),
1146 d.and_then(|d| d.donee_address.clone()).unwrap_or_default(),
1147 d.and_then(|d| d.appraiser_tin.clone()).unwrap_or_default(),
1148 d.and_then(|d| d.appraiser_ptin.clone()).unwrap_or_default(),
1149 d.and_then(|d| d.appraiser_qualifications.clone())
1150 .unwrap_or_default(),
1151 d.and_then(|d| d.appraisal_date.map(|dt| dt.to_string()))
1152 .unwrap_or_default(),
1153 ])?;
1154 }
1155 w.flush()?;
1156 Ok(())
1157}
1158
1159fn write_form8949_csv(
1162 out_dir: &Path,
1163 state: &LedgerState,
1164 year: i32,
1165) -> Result<(), crate::CliError> {
1166 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("form8949.csv"))?);
1167 w.write_record([
1168 "part",
1169 "box",
1170 "box_needs_review",
1171 "description",
1172 "date_acquired",
1173 "date_sold",
1174 "proceeds",
1175 "cost_basis",
1176 "adjustment_code",
1177 "adjustment_amount",
1178 "gain",
1179 "wallet",
1180 "disposition_kind",
1181 ])?;
1182 for r in form_8949(state, year) {
1183 w.write_record([
1184 form8949_part_tag(r.part).to_string(),
1185 form8949_box_tag(r.box_).to_string(),
1186 r.box_needs_review.to_string(),
1187 r.description,
1188 r.date_acquired.to_string(),
1189 r.date_sold.to_string(),
1190 r.proceeds.to_string(),
1191 r.cost_basis.to_string(),
1192 r.adjustment_code,
1193 r.adjustment_amount.to_string(),
1194 r.gain.to_string(),
1195 wallet_label(&r.wallet),
1196 dispose_kind_tag(r.disposition_kind).to_string(),
1197 ])?;
1198 }
1199 w.flush()?;
1200 Ok(())
1201}
1202
1203fn write_schedule_d_csv(
1206 out_dir: &Path,
1207 state: &LedgerState,
1208 year: i32,
1209) -> Result<(), crate::CliError> {
1210 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_d.csv"))?);
1211 w.write_record(["part", "proceeds", "cost_basis", "gain"])?;
1212 let totals = schedule_d(state, year);
1213 for (part, p) in [("ST", &totals.st), ("LT", &totals.lt)] {
1214 w.write_record([
1215 part.to_string(),
1216 p.proceeds.to_string(),
1217 p.cost_basis.to_string(),
1218 p.gain.to_string(),
1219 ])?;
1220 }
1221 w.flush()?;
1222 Ok(())
1223}
1224
1225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1232pub enum PseudoDisclosure {
1233 None,
1235 Synthetic,
1237 Placeholder,
1239}
1240
1241impl PseudoDisclosure {
1242 pub fn contributed(self) -> bool {
1244 self != PseudoDisclosure::None
1245 }
1246 pub fn suffix(self) -> &'static str {
1249 if self.contributed() {
1250 " [PSEUDO]"
1251 } else {
1252 ""
1253 }
1254 }
1255 pub fn banner(self) -> &'static str {
1258 match self {
1259 PseudoDisclosure::None => "",
1260 PseudoDisclosure::Synthetic => {
1261 "⚠ [PSEUDO] This vault has pseudo-reconciled (deliberately-synthetic) entries; figures shown \
1262 are an ESTIMATE, not filing-ready. See '[PSEUDO]' rows in 'btctax report' and the \
1263 [PseudoReconcileActive] advisory in 'btctax verify'; resolve them before filing.\n"
1264 }
1265 PseudoDisclosure::Placeholder => {
1266 "⚠ [PSEUDO] These figures are estimated on a synthetic $0 placeholder profile — no tax \
1267 profile or full-return inputs are stored for this year. This is an ESTIMATE, not \
1268 filing-ready. Set a tax profile ('btctax tax-profile --year <Y> …' — setting is the \
1269 default; '--show' inverts), import inputs ('btctax income import'), or turn pseudo mode off \
1270 ('btctax reconcile pseudo off').\n"
1271 }
1272 }
1273 }
1274}
1275
1276pub fn render_tax_outcome(
1287 year: i32,
1288 out: &btctax_core::TaxOutcome,
1289 advisory: Option<&str>,
1290 pseudo: PseudoDisclosure,
1291) -> String {
1292 use btctax_core::TaxOutcome::*;
1293 let mut s = String::new();
1294 s.push_str(pseudo.banner());
1295 let _ = writeln!(s, "Federal tax attributable to crypto — tax year {year}");
1296 match out {
1297 NotComputable(b) => {
1298 let _ = writeln!(s, " NOT COMPUTABLE [{:?}]: {}", b.kind, b.detail);
1299 }
1300 Computed(r) => {
1301 let _ = writeln!(
1302 s,
1303 " net short-term: {} net long-term: {}",
1304 fmt_money(r.st_net),
1305 fmt_money(r.lt_net)
1306 );
1307 let _ = writeln!(
1308 s,
1309 " crypto ordinary income (level): {}",
1310 fmt_money(r.ordinary_from_crypto)
1311 );
1312 let ordinary_rate_attributable = r.total_federal_tax_attributable - r.ltcg_tax - r.niit;
1315 let _ = writeln!(
1316 s,
1317 " ordinary-rate tax (attributable): {}",
1318 fmt_money(ordinary_rate_attributable)
1319 );
1320 let _ = writeln!(
1321 s,
1322 " LTCG tax (attributable): {} NIIT (attributable): {}",
1323 fmt_money(r.ltcg_tax),
1324 fmt_money(r.niit)
1325 );
1326 let _ = writeln!(
1327 s,
1328 " TOTAL federal tax attributable to crypto (delta): {}{} \
1329 (= ordinary-rate + LTCG + NIIT attributable)",
1330 fmt_money(r.total_federal_tax_attributable),
1331 pseudo.suffix()
1332 );
1333 let _ = writeln!(
1334 s,
1335 " §1211 loss deduction (level): {} carryforward out: short {} / long {}",
1336 fmt_money(r.loss_deduction),
1337 fmt_money(r.carryforward_out.short),
1338 fmt_money(r.carryforward_out.long)
1339 );
1340 let _ = writeln!(
1341 s,
1342 " marginal rates: ordinary {} / LTCG {} / NIIT increased by crypto: {}",
1343 r.marginal_rates.ordinary,
1344 r.marginal_rates.ltcg,
1345 if r.marginal_rates.niit_applies {
1346 "yes"
1347 } else {
1348 "no"
1349 }
1350 );
1351 let _ = writeln!(
1352 s,
1353 " (incremental ceteris-paribus delta on the minimal profile; \
1354 excludes AGI-driven SS/IRMAA/AMT/QBI/phaseout effects — I5. §1411 NIIT reduces NII by the \
1355 §1211(b)-allowed net capital loss (≤ $3,000 / $1,500 MFS — Form 8960 line 5a / §1.1411-4(d)) \
1356 and is floored at $0; crypto ordinary income (mining/staking/airdrops/rewards) is correctly \
1357 excluded from NII; crypto-lending interest income (§1411(c)(1)(A)(i)) is INCLUDED in NII; \
1358 mining/staking/airdrops/rewards remain excluded (SE income per §1411(c)(6) or non-NII other income).)"
1359 );
1360 }
1361 }
1362 if let Some(msg) = advisory {
1365 let _ = writeln!(s, " ADVISORY (M4): {msg}");
1366 }
1367 s
1368}
1369
1370pub(crate) const ADVISORY_WRAP_COLS: usize = 92;
1372
1373pub(crate) fn wrap_bulleted(text: &str) -> String {
1378 const BULLET: &str = " \u{2022} ";
1379 const HANG: &str = " ";
1380 let mut out = String::new();
1381 let mut line = String::from(BULLET);
1382 let mut have_word = false;
1383
1384 for word in text.split_whitespace() {
1385 let prospective = line.chars().count() + usize::from(have_word) + word.chars().count();
1386 if have_word && prospective > ADVISORY_WRAP_COLS {
1387 out.push_str(line.trim_end());
1388 out.push('\n');
1389 line = String::from(HANG);
1390 have_word = false;
1391 }
1392 if have_word {
1393 line.push(' ');
1394 }
1395 line.push_str(word);
1396 have_word = true;
1397 }
1398 out.push_str(line.trim_end());
1399 out
1400}
1401
1402pub fn render_advisories(advisories: &[btctax_core::tax::advisories::Advisory]) -> String {
1406 let mut s = String::new();
1407 if advisories.is_empty() {
1408 return s;
1409 }
1410 let _ = writeln!(s, "\n ── ADVISORIES ({}) ──", advisories.len());
1411 for a in advisories {
1412 let _ = writeln!(s, "{}", wrap_bulleted(&a.message()));
1416 }
1417 let _ = writeln!(
1418 s,
1419 " (Advisories never change a number and never fail the command. See `btctax limitations`.)"
1420 );
1421 s
1422}
1423
1424pub fn provenance_label(p: crate::resolve::Provenance) -> &'static str {
1427 use crate::resolve::Provenance::*;
1428 match p {
1429 ReturnInputs => "ReturnInputs (derived from line items)",
1430 StoredProfile => "stored TaxProfile (raw override)",
1431 PseudoPlaceholder => "pseudo-reconcile placeholder ($0)",
1432 Missing => "none (TaxProfileMissing)",
1433 }
1434}
1435
1436pub fn render_dual_report(
1451 year: i32,
1452 ar: &btctax_core::AbsoluteReturn,
1453 printed: &btctax_core::tax::packet::PrintedForms,
1454 delta: &btctax_core::TaxOutcome,
1455 provenance: crate::resolve::Provenance,
1456 pseudo: PseudoDisclosure,
1457) -> String {
1458 let f = &printed.f1040;
1459 let mut s = String::new();
1460 let _ = writeln!(
1461 s,
1462 "\n═══ Absolute filed return (Form 1040) — tax year {year} ═══"
1463 );
1464 let _ = writeln!(s, " Profile source: {}", provenance_label(provenance));
1465 let _ = writeln!(s, " Total income (1040 L9): {}", fmt_money(f.line9));
1466 let _ = writeln!(s, " Adjustments (L10): {}", fmt_money(f.line10));
1467 let _ = writeln!(s, " AGI (L11): {}", fmt_money(f.line11));
1468 let ded_kind = if ar.deduction_is_itemized {
1469 "itemized"
1470 } else {
1471 "standard"
1472 };
1473 let _ = writeln!(s, " Deduction (L12, {ded_kind}): {}", fmt_money(f.line12));
1474 if ar.qbi_deduction > Usd::ZERO {
1475 let _ = writeln!(s, " QBI deduction (L13): {}", fmt_money(f.line13));
1476 }
1477 let _ = writeln!(s, " Taxable income (L15): {}", fmt_money(f.line15));
1478 let _ = writeln!(s, " Tax (L16): {}", fmt_money(f.line16));
1479 if ar.foreign_tax_credit > Usd::ZERO {
1480 let _ = writeln!(
1481 s,
1482 " Foreign tax credit (Sch 3 L1): {}",
1483 fmt_money(printed.sch_3.map_or(Usd::ZERO, |s3| s3.line1))
1484 );
1485 }
1486 if ar.se_tax_sch2_l4 > Usd::ZERO {
1487 let _ = writeln!(
1488 s,
1489 " Self-employment tax (Sch 2 L4): {}",
1490 fmt_money(printed.sch_2.map_or(Usd::ZERO, |s2| s2.line4))
1491 );
1492 }
1493 if ar.additional_medicare.additional_medicare_tax > Usd::ZERO {
1494 let _ = writeln!(
1495 s,
1496 " Additional Medicare (Form 8959 → Sch 2 L11): {}",
1497 fmt_money(printed.f8959.line18)
1498 );
1499 }
1500 if ar.niit.tax > Usd::ZERO {
1501 let _ = writeln!(
1502 s,
1503 " Net Investment Income Tax (Form 8960 → Sch 2 L12): {}",
1504 fmt_money(printed.f8960.map_or(Usd::ZERO, |f| f.line17))
1505 );
1506 }
1507 let _ = writeln!(
1508 s,
1509 " TOTAL TAX (L24): {}{}",
1510 fmt_money(f.line24),
1511 pseudo.suffix()
1512 );
1513 let _ = writeln!(s, " Total payments (L33): {}", fmt_money(f.line33));
1514 if f.line34 > Usd::ZERO {
1515 let _ = writeln!(s, " → REFUND (L35a): {}", fmt_money(f.line34));
1516 } else {
1517 let _ = writeln!(s, " → AMOUNT OWED (L37): {}", fmt_money(f.line37));
1518 }
1519 let delta_str = match delta {
1521 btctax_core::TaxOutcome::Computed(r) => fmt_money(r.total_federal_tax_attributable),
1522 btctax_core::TaxOutcome::NotComputable(_) => "not computable".to_string(),
1523 };
1524 let _ = writeln!(
1525 s,
1526 "\n ── Two DIFFERENT questions — NOT reconciled (SPEC §6) ──"
1527 );
1528 let _ = writeln!(
1529 s,
1530 " • Absolute TOTAL TAX (this filed return, WITH crypto): {}{}",
1531 fmt_money(f.line24),
1532 pseudo.suffix()
1533 );
1534 let _ = writeln!(
1535 s,
1536 " • Crypto-attributable tax (DELTA, shown above): {delta_str}"
1537 );
1538 let _ = writeln!(
1539 s,
1540 " The delta's implied deduction is fixed at derivation time (non-crypto AGI), so it is \
1541 APPROXIMATE where a\n deduction is AGI-sensitive (e.g. the 7.5% medical floor); the two do NOT \
1542 reconcile to the dollar."
1543 );
1544 s
1545}
1546
1547pub fn render_schedule_d(
1557 year: i32,
1558 totals: &ScheduleDTotals,
1559 outcome: &btctax_core::TaxOutcome,
1560) -> String {
1561 let mut s = String::new();
1562 let _ = writeln!(
1563 s,
1564 "Schedule D (raw pre-netting part totals) — tax year {year}"
1565 );
1566 let _ = writeln!(
1567 s,
1568 " Part I (short-term): proceeds {} cost basis {} gain {}",
1569 fmt_money(totals.st.proceeds),
1570 fmt_money(totals.st.cost_basis),
1571 fmt_money(totals.st.gain)
1572 );
1573 let _ = writeln!(
1574 s,
1575 " Part II (long-term): proceeds {} cost basis {} gain {}",
1576 fmt_money(totals.lt.proceeds),
1577 fmt_money(totals.lt.cost_basis),
1578 fmt_money(totals.lt.gain)
1579 );
1580 match outcome {
1581 btctax_core::TaxOutcome::NotComputable(_) => {
1582 let _ = writeln!(
1583 s,
1584 " (raw disposition totals shown above; the year's tax is NOT COMPUTABLE until \
1585 the blocker is resolved — these Form 8949/Schedule D part totals are \
1586 informational and are not netted/carried until the tax computes)."
1587 );
1588 }
1589 btctax_core::TaxOutcome::Computed(_) => {
1590 let _ = writeln!(
1591 s,
1592 " Note: §1222/§1211/§1212 netting + carryforward are applied in the tax \
1593 computation (report --tax-year); these are the raw pre-netting Form \
1594 8949/Schedule D part totals."
1595 );
1596 }
1597 }
1598 s
1599}
1600
1601pub fn render_schedule_se(
1623 year: i32,
1624 result: Option<&SeTaxResult>,
1625 gross_se: Usd,
1626 table_present: bool,
1627 schedule_c_expenses: Usd,
1628 w2_ss_wages: Usd,
1629 w2_medicare_wages: Usd,
1630) -> Option<String> {
1631 match result {
1632 Some(r) => {
1633 let mut s = String::new();
1634 let _ = writeln!(
1635 s,
1636 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1637 );
1638 if schedule_c_expenses > Usd::ZERO {
1640 let gross_display = r.net_se + schedule_c_expenses;
1643 let _ = writeln!(
1644 s,
1645 " gross business income {} \u{2212} Schedule C expenses {} = net SE earnings {}",
1646 fmt_money(gross_display),
1647 fmt_money(schedule_c_expenses),
1648 fmt_money(r.net_se)
1649 );
1650 let _ = writeln!(
1652 s,
1653 " (Schedule C advisory) Schedule C expenses also reduce your ORDINARY taxable \
1654 income, but the income-tax total above uses GROSS crypto income \u{2014} to first \
1655 order it OVERSTATES your tax by your marginal ordinary rate applied to {}. The tax \
1656 profile cannot express this (an `ordinary_taxable_income` edit would shift both \
1657 legs of the crypto-attributable delta); the engine-side coordination is deferred \
1658 \u{2014} coordinate it on your actual return.",
1659 fmt_money(schedule_c_expenses)
1660 );
1661 } else {
1662 let _ = writeln!(
1663 s,
1664 " net self-employment income (business crypto, Interest excluded): {}",
1665 fmt_money(r.net_se)
1666 );
1667 let _ = writeln!(
1668 s,
1669 " (Schedule C) no Schedule C expenses supplied (--schedule-c-expenses)"
1670 );
1671 }
1672 let _ = writeln!(
1673 s,
1674 " \u{00d7} 92.35% net-earnings factor (\u{00a7}1402(a)) = net SE earnings: {}",
1675 fmt_money(r.base)
1676 );
1677 let _ = writeln!(
1678 s,
1679 " Social Security component (12.4%, §1401(a); capped at the SS wage base): {}",
1680 fmt_money(r.ss)
1681 );
1682 let _ = writeln!(
1683 s,
1684 " Medicare component (2.9%, §1401(b); uncapped): {}",
1685 fmt_money(r.medicare)
1686 );
1687 let _ = writeln!(
1688 s,
1689 " Additional Medicare component (0.9%, §1401(b)(2)): {}",
1690 fmt_money(r.addl)
1691 );
1692 let _ = writeln!(
1693 s,
1694 " TOTAL self-employment tax (§1401): {}",
1695 fmt_money(r.total)
1696 );
1697 let _ = writeln!(
1698 s,
1699 " §164(f) one-half-SE-tax deduction (above-the-line; EXCLUDES Additional Medicare per \
1700 §164(f)(1)): {}",
1701 fmt_money(r.deductible_half)
1702 );
1703 let _ = writeln!(
1706 s,
1707 " (§164(f) advisory) The §164(f) deduction ({}) is NOT auto-coordinated into the \
1708 income-tax total above — to first order, that total overstates your combined tax by \
1709 your marginal ordinary rate applied to {}. The tax profile cannot express this deduction \
1710 directly (reducing `ordinary_taxable_income` would shift BOTH legs of the \
1711 crypto-attributable delta and only correct the bracket differential, not the level) — \
1712 coordinate it on your actual return.",
1713 fmt_money(r.deductible_half),
1714 fmt_money(r.deductible_half)
1715 );
1716 if w2_ss_wages > Usd::ZERO || w2_medicare_wages > Usd::ZERO {
1719 let _ = writeln!(
1720 s,
1721 " (W-2 coordination applied) SS cap = max(0, wage base \u{2212} {}) (Box 3+7); \
1722 Additional-Medicare threshold reduced (not below 0) by {} (Box 5, \
1723 §1401(b)(2)(B)/Form 8959 Part II).",
1724 fmt_money(w2_ss_wages),
1725 fmt_money(w2_medicare_wages)
1726 );
1727 } else {
1728 let _ = writeln!(
1729 s,
1730 " (W-2) assumes $0 W-2 wages (set --w2-ss-wages/--w2-medicare-wages on the tax \
1731 profile if you had a wage job)."
1732 );
1733 }
1734 if r.base < rust_decimal::Decimal::from(400) {
1737 let _ = writeln!(
1738 s,
1739 " (§6017 filing floor) Net earnings from self-employment ({base}) are below $400: \
1740 a Schedule SE filing is required on account of this income only when net earnings \
1741 from self-employment (the ×92.35% base, §1402(a)) are $400 or more (§6017), and \
1742 below that floor no §1401 SE tax is imposed (§1402(b)(2); church employee income \
1743 excepted — §1402(j)(2), not modeled) — the figures above are shown for \
1744 transparency (other self-employment activities, if any, combine on your actual \
1745 Schedule SE).",
1746 base = fmt_money(r.base)
1747 );
1748 }
1749 let _ = writeln!(
1751 s,
1752 " (standalone) This §1401 SE tax is a SEPARATE federal liability, NOT included in the \
1753 income-tax + NIIT total above; the §164(f) one-half-SE-tax deduction is not \
1754 auto-coordinated into that total."
1755 );
1756 Some(s)
1757 }
1758 None => {
1759 if gross_se.is_zero() {
1760 None } else if !table_present {
1762 let mut s = String::new();
1764 let _ = writeln!(
1765 s,
1766 "Schedule SE (§1401 self-employment tax) — tax year {year}"
1767 );
1768 let _ = writeln!(
1769 s,
1770 " SS wage base unavailable for {year}: business self-employment income is present \
1771 but no bundled tax table (ss_wage_base) exists for {year}; the §1401 SE tax was \
1772 NOT computed (no silent drop)."
1773 );
1774 Some(s)
1775 } else {
1776 let mut s = String::new();
1779 let _ = writeln!(
1780 s,
1781 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1782 );
1783 let _ = writeln!(
1784 s,
1785 " fully expensed: gross {} \u{2212} Schedule C expenses {} \u{2264} $0 \
1786 \u{2192} no \u{00a7}1401 SE tax for {year}.",
1787 fmt_money(gross_se),
1788 fmt_money(schedule_c_expenses)
1789 );
1790 Some(s)
1791 }
1792 }
1793 }
1794}
1795
1796pub fn render_gift_advisory(
1819 state: &LedgerState,
1820 year: i32,
1821 prior_taxable_gifts: btctax_core::conventions::Usd,
1822 tables: &impl btctax_core::TaxTables,
1823) -> Option<String> {
1824 use std::collections::BTreeMap;
1825
1826 let any_gift = state
1828 .removals
1829 .iter()
1830 .any(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year);
1831 if !any_gift {
1832 return None; }
1834
1835 let t = match tables.table_for(year) {
1837 None => {
1838 let total: btctax_core::conventions::Usd = state
1839 .removals
1840 .iter()
1841 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1842 .flat_map(|r| r.legs.iter())
1843 .map(|leg| leg.fmv_at_transfer)
1844 .sum();
1845 return Some(format!(
1846 "Form 709 gift advisory ({year}): gift annual-exclusion table unavailable for \
1847 {year}; Form 709 exposure not evaluated — ${} in gifts recorded.",
1848 fmt_money(total)
1849 ));
1850 }
1851 Some(t) => t,
1852 };
1853 let excl = t.gift_annual_exclusion;
1854
1855 let mut labeled: BTreeMap<String, btctax_core::conventions::Usd> = BTreeMap::new();
1857 let mut unlabeled_count: usize = 0;
1858 let mut unlabeled_total: btctax_core::conventions::Usd = Default::default();
1859
1860 for r in state
1861 .removals
1862 .iter()
1863 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1864 {
1865 let fmv: btctax_core::conventions::Usd = r.legs.iter().map(|l| l.fmv_at_transfer).sum();
1866 match &r.donee {
1867 Some(label) => {
1868 *labeled.entry(label.clone()).or_default() += fmv;
1869 }
1870 None => {
1871 unlabeled_count += 1;
1872 unlabeled_total += fmv;
1873 }
1874 }
1875 }
1876
1877 let mut filing_required_donees: Vec<String> = Vec::new();
1879 let mut total_taxable: btctax_core::conventions::Usd = Default::default();
1880 let mut s = format!("Form 709 gift advisory ({year}):");
1881
1882 if !labeled.is_empty() {
1883 s.push_str(&format!(
1884 "\n§2503(b) per-donee annual exclusion analysis (TY{year}, exclusion ${}):",
1885 fmt_money(excl)
1886 ));
1887 for (donee, &total) in &labeled {
1888 let applied = if total < excl { total } else { excl };
1889 let taxable: btctax_core::conventions::Usd = if total > excl {
1890 total - excl
1891 } else {
1892 Default::default()
1893 };
1894 s.push_str(&format!(
1895 "\n {donee}: total ${}, exclusion applied ${}, taxable ${}",
1896 fmt_money(total),
1897 fmt_money(applied),
1898 fmt_money(taxable)
1899 ));
1900 if total > excl {
1901 filing_required_donees.push(donee.clone());
1902 total_taxable += taxable;
1903 }
1904 }
1905 if !filing_required_donees.is_empty() {
1906 s.push_str(&format!(
1907 "\nForm 709 filing required (donee(s): {}). Total taxable gifts: ${}.",
1908 filing_required_donees.join(", "),
1909 fmt_money(total_taxable)
1910 ));
1911 } else {
1912 s.push_str(&format!(
1913 "\nNo Form 709 filing required based on per-donee totals \
1914 (each ≤ ${} exclusion). Total taxable gifts: $0.00.",
1915 fmt_money(excl)
1916 ));
1917 }
1918 }
1919
1920 if unlabeled_count > 0 {
1921 s.push_str(&format!(
1922 "\nNOTE: {unlabeled_count} gift(s) totalling ${} have no donee label — the §2503(b) \
1923 annual exclusion is PER DONEE and cannot be applied without one; label them via \
1924 `reconcile reclassify-outflow --donee`. Shown as a single conservative aggregate.",
1925 fmt_money(unlabeled_total)
1926 ));
1927 if unlabeled_total > excl {
1930 s.push_str(&format!(
1931 "\n Conservative aggregate ${} > ${} (one exclusion); \
1932 verify per-donee totals after labelling.",
1933 fmt_money(unlabeled_total),
1934 fmt_money(excl)
1935 ));
1936 } else {
1937 s.push_str(&format!(
1938 "\n Conservative aggregate ${} \u{2264} ${} (one exclusion); \
1939 per-donee exposure unverifiable without labels.",
1940 fmt_money(unlabeled_total),
1941 fmt_money(excl)
1942 ));
1943 }
1944 }
1945
1946 let lifetime_excl = t.gift_lifetime_exclusion;
1950 let cumulative_taxable = prior_taxable_gifts + total_taxable;
1951
1952 if cumulative_taxable > btctax_core::conventions::Usd::ZERO {
1954 let remaining = if cumulative_taxable >= lifetime_excl {
1955 btctax_core::conventions::Usd::ZERO
1956 } else {
1957 lifetime_excl - cumulative_taxable
1958 };
1959 s.push_str(&format!(
1960 "\n§2505 lifetime (basic) exclusion: you have used ${} of your ${} ({year}) lifetime \
1961 exclusion (${} remaining). No gift tax is DUE until cumulative taxable gifts exceed \
1962 the lifetime exclusion.",
1963 fmt_money(cumulative_taxable),
1964 fmt_money(lifetime_excl),
1965 fmt_money(remaining),
1966 ));
1967 if cumulative_taxable > lifetime_excl {
1969 let excess = cumulative_taxable - lifetime_excl;
1970 s.push_str(&format!(
1971 "\nlifetime exclusion EXCEEDED — gift tax may be due on ${} \
1972 (the excess base past the unified credit, not a computed tax); \
1973 consult a professional.",
1974 fmt_money(excess),
1975 ));
1976 }
1977 if unlabeled_count > 0 {
1980 s.push_str(&format!(
1981 "\n§2505 consumption reflects LABELED-donee taxable gifts only; \
1982 {unlabeled_count} unlabeled gift(s) totalling ${} are NOT included — \
1983 label them via `--donee` for a complete figure; consumption may be \
1984 understated / remaining overstated.",
1985 fmt_money(unlabeled_total),
1986 ));
1987 }
1988 }
1989
1990 s.push_str(
1993 "\nCaveats: §2513 gift-splitting (MFJ) not modeled (single-filer advisory only); \
1994 future-interest gifts (which require Form 709 filing regardless of amount) not \
1995 detectable; §2505 figures are advisory only — no portability/DSUE (§2010(c)(4)) \
1996 applied; prior cumulative taxable gifts are user-supplied (default $0 if \
1997 --prior-taxable-gifts not given).",
1998 );
1999
2000 Some(s)
2001}
2002
2003fn picks_str(picks: &[btctax_core::LotPick]) -> String {
2009 if picks.is_empty() {
2010 return "(none)".to_string();
2011 }
2012 picks
2013 .iter()
2014 .map(|p| {
2015 format!(
2016 "{}#{}:{}",
2017 p.lot.origin_event_id.canonical(),
2018 p.lot.split_sequence,
2019 p.sat
2020 )
2021 })
2022 .collect::<Vec<_>>()
2023 .join(", ")
2024}
2025
2026pub fn render_optimize_proposal(p: &btctax_core::OptimizeProposal) -> String {
2039 use btctax_core::{ApproxReason, Persistability};
2040 let mut s = String::new();
2041 let _ = writeln!(
2042 s,
2043 "Optimize (what-if) — tax year {} — NOTHING is filed or bound by running this.",
2044 p.year
2045 );
2046 if p.approximate {
2048 let why = match p.approx_reason {
2049 Some(ApproxReason::ComboCapExceeded { combos, cap }) => format!(
2050 "input exceeded the exhaustive bound ({combos} combos > {cap}); \
2051 a coordinate-descent fallback ran"
2052 ),
2053 Some(ApproxReason::ContentionUnenumerated { contended, .. }) => format!(
2054 "{contended} contended same-wallet disposal(s) could not be fully joint-enumerated"
2055 ),
2056 Some(ApproxReason::PoolHeuristic { lots, bound }) => format!(
2057 "a pool of {lots} lots exceeds the {bound}-lot exhaustive-enumeration bound; \
2058 only a deterministic heuristic SUBSET of that pool's identifications was searched"
2059 ),
2060 None => "approximate".to_string(),
2061 };
2062 let _ = writeln!(
2063 s,
2064 " \u{26a0} APPROXIMATE \u{2014} NOT a guaranteed global minimum: {why}."
2065 );
2066 let _ = writeln!(
2067 s,
2068 " The true least-tax assignment may be lower; this is a disclosed improvement over your"
2069 );
2070 let _ = writeln!(
2071 s,
2072 " current filing position (delta \u{2264} 0), NOT \u{2018}the least tax.\u{2019}"
2073 );
2074 }
2075 let _ = writeln!(
2076 s,
2077 " current federal tax (attributable): {}",
2078 p.baseline_tax
2079 );
2080 let _ = writeln!(
2081 s,
2082 " optimized federal tax (attributable): {}",
2083 p.optimized_tax
2084 );
2085 let _ = writeln!(
2086 s,
2087 " delta (optimized \u{2212} current): {} (negative = saving; always \u{2264} 0)",
2088 p.delta
2089 );
2090 for d in &p.per_disposal {
2091 let _ = writeln!(
2092 s,
2093 " {} @ {} [{}] :: {}",
2094 d.disposal.canonical(),
2095 d.date,
2096 wallet_label(&d.wallet),
2097 compliance_status_tag(&d.status)
2098 );
2099 if d.proposed_selection == d.current_selection {
2104 let _ = writeln!(
2105 s,
2106 " proposed: {} [no change \u{2014} already optimal under current identification]",
2107 picks_str(&d.proposed_selection)
2108 );
2109 continue;
2110 }
2111 let persist = match d.persistable {
2112 Persistability::ContemporaneousNow => {
2113 "persistable now (made \u{2264} sale \u{2192} Contemporaneous)"
2114 }
2115 Persistability::NeedsAttestation => {
2116 "already executed \u{2014} needs `optimize accept --disposal <ref> \
2117 --attest \"\u{2026}\"` (genuine contemporaneous ID only)"
2118 }
2119 Persistability::ForbiddenBroker2027 => {
2120 "2027+ broker-held \u{2014} CANNOT be persisted (own-books insufficient); \
2121 FIFO is the defensible position"
2122 }
2123 };
2124 let _ = writeln!(
2125 s,
2126 " proposed: {} [{}]",
2127 picks_str(&d.proposed_selection),
2128 persist
2129 );
2130 }
2131 let _ = writeln!(
2133 s,
2134 " (vertex-granularity identification: a multi-partial split landing exactly on a \
2135 tax-bracket kink is out of scope.)"
2136 );
2137 let _ = writeln!(
2138 s,
2139 " (this is the tax IF you had identified thus; adequate ID must exist by the time \
2140 of sale \u{2014} \u{a7}1.1012-1(j))"
2141 );
2142 let _ = writeln!(
2144 s,
2145 " (scope: global over taxable-disposal lot selections; self-transfer lot routing is \
2146 held at its baseline position and is not re-optimized.)"
2147 );
2148 s
2149}
2150
2151pub fn render_accept_outcome(o: &crate::cmd::optimize::AcceptOutcome) -> String {
2156 let mut s = String::new();
2157 let _ = writeln!(
2158 s,
2159 "Optimize accept \u{2014} {} persisted, {} skipped.",
2160 o.persisted.len(),
2161 o.skipped.len()
2162 );
2163 for (disposal, decision, basis) in &o.persisted {
2164 let _ = writeln!(
2165 s,
2166 " PERSISTED {} \u{2192} LotSelection {} [{}]{}",
2167 disposal.canonical(),
2168 decision.canonical(),
2169 basis,
2170 if *basis == "AttestedRecording" {
2171 " (+ attestation recorded; revoke with `reconcile void`)"
2172 } else {
2173 " (revoke with `reconcile void`)"
2174 }
2175 );
2176 }
2177 for (disposal, reason) in &o.skipped {
2178 let _ = writeln!(s, " skipped {}: {}", disposal.canonical(), reason);
2179 }
2180 if o.persisted.is_empty() && o.skipped.is_empty() {
2181 let _ = writeln!(s, " (no disposals matched)");
2182 }
2183 s
2184}
2185
2186pub fn render_consult(r: &btctax_core::ConsultReport) -> String {
2196 let mut s = String::new();
2197 let _ = writeln!(
2198 s,
2199 "Consult (read-only what-if): sell {} sat from {} on {}",
2200 r.req.sell_sat,
2201 wallet_label(&r.req.wallet),
2202 r.req.at
2203 );
2204 if r.approximate {
2206 let _ = writeln!(
2207 s,
2208 " \u{26a0} heuristic \u{2014} searched a subset of a large (>12-lot) pool; \
2209 the proposed selection may not be the exact minimum."
2210 );
2211 }
2212 let _ = writeln!(
2213 s,
2214 " proposed selection: {}",
2215 picks_str(&r.proposed_selection)
2216 );
2217 let _ = writeln!(
2218 s,
2219 " short-term gain: {} long-term gain: {}",
2220 r.st_gain, r.lt_gain
2221 );
2222 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
2225 let _ = writeln!(
2226 s,
2227 " whole-year federal tax attributable (with this sale): {}",
2228 r.total_federal_tax_attributable
2229 );
2230 if let Some(t) = &r.timing {
2231 let _ = writeln!(
2232 s,
2233 " timing: {} sat of the best selection is short-term until {}; \
2234 selling on/after then would be taxed long-term, a \u{2248} {} difference.",
2235 t.st_sat_in_selection, t.latest_crossover, t.saving_if_waited
2236 );
2237 }
2238 let _ = writeln!(
2239 s,
2240 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
2241 not investment advice (no buy/sell/hold recommendation)."
2242 );
2243 s
2244}
2245
2246fn ltcg_bracket_label(b: LtcgBracket) -> &'static str {
2248 match b {
2249 LtcgBracket::Zero => "0%",
2250 LtcgBracket::Fifteen => "15%",
2251 LtcgBracket::Twenty => "20%",
2252 }
2253}
2254
2255pub fn render_whatif_sell(r: &SellReport, magi_caveat: bool) -> String {
2261 let mut s = String::new();
2262 let _ = writeln!(
2263 s,
2264 "What-if (read-only): sell {} sat from {} on {}",
2265 r.req.sell_sat,
2266 wallet_label(&r.req.wallet),
2267 r.req.at
2268 );
2269 if magi_caveat {
2270 let _ = writeln!(
2271 s,
2272 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
2273 );
2274 }
2275 let _ = writeln!(s, " proceeds: {}", r.proceeds);
2276 let _ = writeln!(s, " lots consumed:");
2277 for leg in &r.lots {
2278 let term = match leg.term {
2279 Term::ShortTerm => "ST",
2280 Term::LongTerm => "LT",
2281 };
2282 let _ = writeln!(
2283 s,
2284 " {}#{} {} sat basis {} {} \u{2192} {} {} gain {}",
2285 leg.lot_id.origin_event_id.canonical(),
2286 leg.lot_id.split_sequence,
2287 leg.sat,
2288 leg.basis,
2289 leg.acquired_at,
2290 leg.sold_at,
2291 term,
2292 leg.gain
2293 );
2294 }
2295 let _ = writeln!(
2296 s,
2297 " short-term gain: {} long-term gain: {}",
2298 r.st_gain, r.lt_gain
2299 );
2300 match r.bracket_room {
2302 Some(room) => {
2303 let _ = writeln!(
2304 s,
2305 " \u{00a7}1(h) LTCG bracket: {} (room {} before the next breakpoint)",
2306 ltcg_bracket_label(r.bracket),
2307 room
2308 );
2309 }
2310 None => {
2311 let _ = writeln!(
2312 s,
2313 " \u{00a7}1(h) LTCG bracket: {} (top bracket \u{2014} no headroom)",
2314 ltcg_bracket_label(r.bracket)
2315 );
2316 }
2317 }
2318 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
2320 match r.effective_rate {
2321 Some(rate) => {
2322 let _ = writeln!(s, " effective rate: {rate}");
2323 }
2324 None => {
2325 let _ = writeln!(
2326 s,
2327 " effective rate: n/a (a loss/zero-gain sale \u{2014} its value is the carryforward, \
2328 not this-year tax)"
2329 );
2330 }
2331 }
2332 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2335 if carried != Usd::ZERO || r.ordinary_offset_delta != Usd::ZERO {
2336 let _ = writeln!(
2337 s,
2338 " \u{00a7}1212: {} offsets ordinary income this year, {} carried to next year \
2339 (short {} / long {})",
2340 r.ordinary_offset_delta, carried, r.carryforward_delta.short, r.carryforward_delta.long
2341 );
2342 }
2343 if r.niit_applies {
2345 let dir = if r.niit_incremental < Usd::ZERO {
2346 "decrease"
2347 } else {
2348 "increase"
2349 };
2350 let _ = writeln!(
2351 s,
2352 " \u{00a7}1411 NIIT: {} ({dir}) attributable to this sale",
2353 r.niit_incremental
2354 );
2355 }
2356 let status = match r.status {
2357 SellStatus::Gain => "net gain",
2358 SellStatus::Loss => "net loss (the carryforward is the value \u{2014} not this-year tax)",
2359 };
2360 let _ = writeln!(s, " status: {status}");
2361 let _ = writeln!(
2362 s,
2363 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
2364 not investment advice (no buy/sell/hold recommendation)."
2365 );
2366 s
2367}
2368
2369fn harvest_target_label(t: &HarvestTarget) -> String {
2371 match t {
2372 HarvestTarget::ZeroLtcg => {
2373 "zero-ltcg (all gain in the \u{00a7}1(h) 0% bracket)".to_string()
2374 }
2375 HarvestTarget::FifteenLtcg => "fifteen-ltcg (stay at/under 15%)".to_string(),
2376 HarvestTarget::Gain(x) => format!("gain \u{2264} {x}"),
2377 HarvestTarget::Tax(x) => format!("marginal tax \u{2264} {x}"),
2378 }
2379}
2380
2381pub fn render_whatif_harvest(r: &HarvestReport, magi_caveat: bool) -> String {
2387 let mut s = String::new();
2388 let _ = writeln!(
2389 s,
2390 "What-if HARVEST (read-only): {} from {} on {}",
2391 harvest_target_label(&r.req.target),
2392 wallet_label(&r.req.wallet),
2393 r.req.at
2394 );
2395 if magi_caveat {
2396 let _ = writeln!(
2397 s,
2398 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
2399 );
2400 }
2401 let status = match &r.status {
2402 HarvestStatus::Found => "FOUND (the target binds)",
2403 HarvestStatus::NotBinding => "NOT BINDING (the whole position fits)",
2404 HarvestStatus::AlreadyBreached => "ALREADY BREACHED at N=0 (nothing can be harvested)",
2405 HarvestStatus::NoLots => "NO LOTS (nothing to harvest from that wallet)",
2406 HarvestStatus::ProceedsRequired => "PROCEEDS REQUIRED",
2407 HarvestStatus::PreTransitionYear => "PRE-2025 (a closed year, not a plan)",
2408 HarvestStatus::YearNotComputable(_) => "YEAR NOT COMPUTABLE",
2409 };
2410 let _ = writeln!(s, " status: {status}");
2411 let _ = writeln!(s, " \u{2192} sell up to {} BTC ({} sat)", r.n_btc, r.n_sat);
2412 let _ = writeln!(s, " bound by: {}", r.binding_constraint);
2413 let _ = writeln!(
2414 s,
2415 " realized gain at N*: short-term {} long-term {}",
2416 r.st_gain, r.lt_gain
2417 );
2418 let ps = &r.with_result.pref_split;
2420 let bracket = if ps.at_20 > Usd::ZERO {
2421 "20%"
2422 } else if ps.at_15 > Usd::ZERO {
2423 "15%"
2424 } else {
2425 "0%"
2426 };
2427 let _ = writeln!(
2428 s,
2429 " \u{00a7}1(h) preferential dollars at N*: {} in 0% / {} in 15% / {} in 20% (top bracket: {})",
2430 ps.at_0, ps.at_15, ps.at_20, bracket
2431 );
2432 let _ = writeln!(s, " marginal federal tax at N*: {}", r.marginal_tax);
2433 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2435 if carried != Usd::ZERO {
2436 let dir = if carried < Usd::ZERO {
2437 "burned (spent)"
2438 } else {
2439 "carried to next year"
2440 };
2441 let _ = writeln!(
2442 s,
2443 " \u{00a7}1212 carryforward {}: {} (short {} / long {})",
2444 dir, carried, r.carryforward_delta.short, r.carryforward_delta.long
2445 );
2446 }
2447 if r.niit_applies {
2449 let dir = if r.niit_incremental < Usd::ZERO {
2450 "decrease"
2451 } else {
2452 "increase"
2453 };
2454 let _ = writeln!(
2455 s,
2456 " \u{00a7}1411 NIIT: {} ({dir}) at N* \u{2014} the +3.8% kink applies even inside a 0%/15% bracket answer",
2457 r.niit_incremental
2458 );
2459 }
2460 if let Some(note) = &r.plateau_note {
2461 let _ = writeln!(s, " \u{2139} {note}");
2462 }
2463 let _ = writeln!(
2464 s,
2465 "Tax decision-support only \u{2014} the engine-verified consequences of a contemplated harvest; \
2466 not investment advice (no buy/sell/hold recommendation)."
2467 );
2468 s
2469}
2470
2471pub fn render_verify(r: &VerifyReport) -> String {
2472 let mut out = String::new();
2473 let c = &r.conservation;
2474 let _ = writeln!(
2475 out,
2476 "Conservation (FR9): {}",
2477 if c.balanced { "BALANCED" } else { "DRIFT" }
2478 );
2479 let _ = writeln!(
2480 out,
2481 " in {} = disposed {} + removed {} + held {} + fee-sats {} + pending {}{}",
2482 c.sigma_in,
2483 c.sigma_disposed,
2484 c.sigma_removed,
2485 c.sigma_held,
2486 c.sigma_fee_sats,
2487 c.sigma_pending,
2488 if c.has_uncovered {
2489 " [identity undefined: uncovered disposal open]"
2490 } else {
2491 ""
2492 }
2493 );
2494 let _ = writeln!(out, "2025 transition: {}", r.safe_harbor);
2495 let _ = writeln!(
2496 out,
2497 "Pending reconciliation: {} transfer(s); unknown-basis inbounds: {}",
2498 r.pending, r.unknown_basis_inbounds
2499 );
2500
2501 let _ = writeln!(
2502 out,
2503 "Hard blockers (gate tax computation): {}",
2504 r.hard.len()
2505 );
2506 for b in &r.hard {
2507 let evt = b
2508 .event
2509 .as_ref()
2510 .map(|e| e.canonical())
2511 .unwrap_or_else(|| "-".to_string());
2512 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2513 if b.kind == BlockerKind::FmvMissing {
2516 let _ = writeln!(out, " ↳ {}", crate::price_cache::UPDATE_PRICES_HINT);
2517 }
2518 }
2519 let _ = writeln!(out, "Advisory blockers: {}", r.advisory.len());
2520 for b in &r.advisory {
2521 let evt = b
2522 .event
2523 .as_ref()
2524 .map(|e| e.canonical())
2525 .unwrap_or_else(|| "-".to_string());
2526 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2527 }
2528 let _ = writeln!(
2529 out,
2530 "Pre-2025 method (attested historical fact): {} (attested: {})",
2531 lot_method_display(r.declared_pre2025_method),
2532 r.pre2025_method_attested
2533 );
2534 let _ = writeln!(
2535 out,
2536 "Standing orders (MethodElection): {}",
2537 r.elections.len()
2538 );
2539 for e in &r.elections {
2540 let _ = writeln!(
2541 out,
2542 " recorded {} effective {} -> {} [{}]",
2543 e.recorded,
2544 e.effective_from,
2545 lot_method_display(e.method),
2546 e.note
2547 );
2548 }
2549 let _ = writeln!(out, "Lot selections recorded: {}", r.selection_count);
2550 let _ = writeln!(
2551 out,
2552 "Per-disposal compliance (post-2025): {}",
2553 r.compliance.len()
2554 );
2555 for c in &r.compliance {
2556 let _ = writeln!(
2557 out,
2558 " {} @ {} :: {}",
2559 c.disposal.canonical(),
2560 c.date,
2561 compliance_status_tag(&c.status)
2562 );
2563 }
2564 let _ = writeln!(out, "Promote-basis drift advisories: {}", r.drift.len());
2567 for d in &r.drift {
2568 let _ = writeln!(out, " {d}");
2569 }
2570 out
2571}
2572
2573#[cfg(test)]
2574mod gift_advisory_tests {
2575 use super::*;
2581 use btctax_core::conventions::Usd;
2582 use btctax_core::{EventId, LotId, Removal, RemovalLeg, TaxTable};
2583 use rust_decimal_macros::dec;
2584 use std::collections::BTreeMap;
2585 use time::macros::date;
2586
2587 fn gift_removal(seq: u64, removed_at: TaxDate, fmv: Usd) -> Removal {
2589 Removal {
2590 event: EventId::decision(seq),
2591 kind: RemovalKind::Gift,
2592 removed_at,
2593 legs: vec![RemovalLeg {
2594 lot_id: LotId {
2595 origin_event_id: EventId::decision(seq),
2596 split_sequence: 0,
2597 },
2598 sat: 100,
2599 basis: dec!(0),
2600 fmv_at_transfer: fmv,
2601 term: Term::LongTerm,
2602 basis_source: BasisSource::ComputedFromCost,
2603 acquired_at: date!(2024 - 01 - 01),
2604 pseudo: false,
2605 }],
2606 appraisal_required: false,
2607 donor_acquired_at: None,
2608 claimed_deduction: None,
2609 donee: None,
2610 }
2611 }
2612 fn gift_removal_labeled(seq: u64, removed_at: TaxDate, fmv: Usd, label: &str) -> Removal {
2614 Removal {
2615 donee: Some(label.to_string()),
2616 ..gift_removal(seq, removed_at, fmv)
2617 }
2618 }
2619 fn state_with(removals: Vec<Removal>) -> LedgerState {
2620 LedgerState {
2621 removals,
2622 ..Default::default()
2623 }
2624 }
2625 fn tables_with(year: i32, excl: Usd) -> BTreeMap<i32, TaxTable> {
2629 tables_with_lifetime(year, excl, dec!(13_990_000))
2630 }
2631
2632 fn tables_with_lifetime(year: i32, excl: Usd, lifetime_excl: Usd) -> BTreeMap<i32, TaxTable> {
2634 let mut m = BTreeMap::new();
2635 m.insert(
2636 year,
2637 TaxTable {
2638 year,
2639 source: "TEST",
2640 ordinary: BTreeMap::new(),
2641 ltcg: BTreeMap::new(),
2642 gift_annual_exclusion: excl,
2643 ss_wage_base: dec!(176100),
2644 gift_lifetime_exclusion: lifetime_excl,
2645 },
2646 );
2647 m
2648 }
2649
2650 #[test]
2654 fn no_gifts_is_none() {
2655 let st = state_with(vec![]);
2656 let tables = tables_with(2025, dec!(19000));
2657 assert!(render_gift_advisory(&st, 2025, dec!(0), &tables).is_none());
2658 }
2659
2660 #[test]
2663 fn gifts_present_but_no_table_emits_note_not_none() {
2664 let st = state_with(vec![gift_removal(1, date!(2026 - 06 - 01), dec!(50000))]);
2666 let tables = tables_with(2025, dec!(19000));
2668 let msg =
2669 render_gift_advisory(&st, 2026, dec!(0), &tables).expect("note expected, not None");
2670 assert!(msg.contains("unavailable"), "{msg}");
2671 assert!(msg.contains("Form 709 exposure not evaluated"), "{msg}");
2672 assert!(
2673 msg.contains("50000.00"),
2674 "must record the gift total: {msg}"
2675 );
2676 }
2677
2678 #[test]
2684 fn labeled_donee_over_exclusion_emits_advisory() {
2685 let st = state_with(vec![gift_removal_labeled(
2686 1,
2687 date!(2025 - 06 - 01),
2688 dec!(20000),
2689 "Alice",
2690 )]);
2691 let tables = tables_with(2025, dec!(19000));
2692 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2693 assert!(msg.contains("20000.00"), "must show Alice's total: {msg}");
2694 assert!(msg.contains("19000.00"), "must show the exclusion: {msg}");
2695 assert!(
2696 msg.contains("Form 709 filing required"),
2697 "must flag filing required: {msg}"
2698 );
2699 assert!(msg.contains("Alice"), "must name Alice: {msg}");
2700 assert!(msg.contains("1000.00"), "taxable must be $1000.00: {msg}");
2702 assert!(
2704 !msg.contains("donee identity is not modeled"),
2705 "stale aggregate caveat must not appear: {msg}"
2706 );
2707 }
2708
2709 #[test]
2712 fn labeled_donee_under_exclusion_no_filing_required() {
2713 let st = state_with(vec![gift_removal_labeled(
2714 1,
2715 date!(2025 - 06 - 01),
2716 dec!(10000),
2717 "Alice",
2718 )]);
2719 let tables = tables_with(2025, dec!(19000));
2720 let msg =
2722 render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected, not None");
2723 assert!(
2724 msg.contains("No Form 709 filing required"),
2725 "must say no filing required: {msg}"
2726 );
2727 assert!(msg.contains("Alice"), "must mention Alice: {msg}");
2728 assert!(msg.contains("10000.00"), "must show Alice's total: {msg}");
2729 }
2730
2731 #[test]
2737 fn per_donee_under_exclusion_two_donees_no_filing_required() {
2738 let st = state_with(vec![
2739 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(15000), "Alice"),
2740 gift_removal_labeled(2, date!(2025 - 06 - 01), dec!(15000), "Bob"),
2741 ]);
2742 let tables = tables_with(2025, dec!(19000));
2743 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2744 assert!(
2746 msg.contains("No Form 709 filing required"),
2747 "neither donee exceeds exclusion → no filing required: {msg}"
2748 );
2749 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2751 assert!(msg.contains("Bob"), "Bob must appear: {msg}");
2752 assert!(msg.contains("15000.00"), "donee total must appear: {msg}");
2754 assert!(
2756 !msg.contains("Form 709 filing required (donee(s):"),
2757 "filing trigger must NOT fire: {msg}"
2758 );
2759 assert!(
2761 msg.contains("Total taxable gifts: $0.00"),
2762 "total taxable must be $0.00: {msg}"
2763 );
2764 }
2765
2766 #[test]
2769 fn one_donee_over_exclusion_filing_required() {
2770 let st = state_with(vec![gift_removal_labeled(
2771 1,
2772 date!(2025 - 06 - 01),
2773 dec!(25000),
2774 "Alice",
2775 )]);
2776 let tables = tables_with(2025, dec!(19000));
2777 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2778 assert!(
2779 msg.contains("Form 709 filing required (donee(s): Alice)"),
2780 "must trigger filing required for Alice: {msg}"
2781 );
2782 assert!(msg.contains("25000.00"), "Alice total must appear: {msg}");
2783 assert!(
2784 msg.contains("19000.00"),
2785 "exclusion applied must appear: {msg}"
2786 );
2787 assert!(
2789 msg.contains("6000.00"),
2790 "taxable $6,000.00 must appear: {msg}"
2791 );
2792 }
2793
2794 #[test]
2797 fn unlabeled_bucket_caveat_with_conservative_aggregate() {
2798 let st = state_with(vec![gift_removal(1, date!(2025 - 06 - 01), dec!(30000))]);
2799 let tables = tables_with(2025, dec!(19000));
2800 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2801 assert!(
2803 msg.contains("no donee label"),
2804 "unlabeled caveat must appear: {msg}"
2805 );
2806 assert!(
2807 msg.contains("30000.00"),
2808 "unlabeled total must appear: {msg}"
2809 );
2810 assert!(
2812 msg.contains("Conservative aggregate"),
2813 "conservative aggregate signal must appear: {msg}"
2814 );
2815 assert!(
2816 msg.contains("19000.00"),
2817 "one-exclusion comparison must appear: {msg}"
2818 );
2819 assert!(
2821 !msg.contains("Form 709 filing required (donee(s):"),
2822 "labeled filing trigger must NOT fire for unlabeled gifts: {msg}"
2823 );
2824 }
2825
2826 #[test]
2829 fn mixed_labeled_over_and_unlabeled_shows_both() {
2830 let st = state_with(vec![
2831 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(25000), "Alice"),
2832 gift_removal(2, date!(2025 - 06 - 01), dec!(5000)), ]);
2834 let tables = tables_with(2025, dec!(19000));
2835 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2836 assert!(
2838 msg.contains("Form 709 filing required"),
2839 "filing required for Alice: {msg}"
2840 );
2841 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2842 assert!(
2844 msg.contains("no donee label"),
2845 "unlabeled caveat must appear: {msg}"
2846 );
2847 assert!(
2848 msg.contains("5000.00"),
2849 "unlabeled total must appear: {msg}"
2850 );
2851 }
2852
2853 #[test]
2856 fn donations_excluded_from_form709_advisory() {
2857 let donation_removal = Removal {
2858 event: EventId::decision(1),
2859 kind: RemovalKind::Donation,
2860 removed_at: date!(2025 - 06 - 01),
2861 legs: vec![RemovalLeg {
2862 lot_id: LotId {
2863 origin_event_id: EventId::decision(1),
2864 split_sequence: 0,
2865 },
2866 sat: 100,
2867 basis: dec!(0),
2868 fmv_at_transfer: dec!(50000), term: Term::LongTerm,
2870 basis_source: BasisSource::ComputedFromCost,
2871 acquired_at: date!(2024 - 01 - 01),
2872 pseudo: false,
2873 }],
2874 appraisal_required: false,
2875 donor_acquired_at: None,
2876 claimed_deduction: Some(dec!(50000)),
2877 donee: Some("Charity X".to_string()),
2878 };
2879 let st = state_with(vec![donation_removal]);
2880 let tables = tables_with(2025, dec!(19000));
2881 assert!(
2883 render_gift_advisory(&st, 2025, dec!(0), &tables).is_none(),
2884 "Donation must be excluded from the Form 709 advisory"
2885 );
2886 }
2887
2888 #[test]
2894 fn section_2505_under_lifetime_shows_used_and_remaining() {
2895 let st = state_with(vec![gift_removal_labeled(
2896 1,
2897 date!(2025 - 06 - 01),
2898 dec!(100000),
2899 "Alice",
2900 )]);
2901 let tables = tables_with(2025, dec!(19000)); let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2903 assert!(
2905 msg.contains("81000.00"),
2906 "taxable $81,000 must appear: {msg}"
2907 );
2908 assert!(
2910 msg.contains("§2505 lifetime (basic) exclusion"),
2911 "§2505 block must appear: {msg}"
2912 );
2913 assert!(
2914 msg.contains("13990000.00"),
2915 "lifetime exclusion $13,990,000 must appear: {msg}"
2916 );
2917 assert!(
2919 msg.contains("13909000.00"),
2920 "remaining $13,909,000 must appear: {msg}"
2921 );
2922 assert!(
2924 !msg.contains("EXCEEDED"),
2925 "must NOT say EXCEEDED when under limit: {msg}"
2926 );
2927 assert!(
2929 !msg.contains("later chunk (Chunk 3)"),
2930 "stale Chunk-3 caveat must be absent: {msg}"
2931 );
2932 }
2933
2934 #[test]
2937 fn section_2505_prior_gifts_accumulate() {
2938 let st = state_with(vec![gift_removal_labeled(
2939 1,
2940 date!(2025 - 06 - 01),
2941 dec!(100000),
2942 "Alice",
2943 )]);
2944 let tables = tables_with(2025, dec!(19000));
2945 let msg =
2946 render_gift_advisory(&st, 2025, dec!(13_900_000), &tables).expect("advisory expected");
2947 assert!(
2949 msg.contains("13981000.00"),
2950 "cumulative $13,981,000 must appear: {msg}"
2951 );
2952 assert!(
2954 msg.contains("9000.00"),
2955 "remaining $9,000 must appear: {msg}"
2956 );
2957 assert!(
2958 !msg.contains("EXCEEDED"),
2959 "must NOT say EXCEEDED when under limit: {msg}"
2960 );
2961 }
2962
2963 #[test]
2967 fn section_2505_exceeds_lifetime_shows_exceeded_and_excess() {
2968 let st = state_with(vec![gift_removal_labeled(
2969 1,
2970 date!(2025 - 06 - 01),
2971 dec!(100000),
2972 "Alice",
2973 )]);
2974 let tables = tables_with(2025, dec!(19000));
2975 let msg =
2976 render_gift_advisory(&st, 2025, dec!(13_950_000), &tables).expect("advisory expected");
2977 assert!(
2978 msg.contains("14031000.00"),
2979 "cumulative $14,031,000 must appear: {msg}"
2980 );
2981 assert!(
2982 msg.contains("EXCEEDED"),
2983 "must say EXCEEDED when over lifetime limit: {msg}"
2984 );
2985 assert!(
2987 msg.contains("41000.00"),
2988 "excess $41,000 must appear: {msg}"
2989 );
2990 }
2991
2992 #[test]
2996 fn section_2505_exact_boundary_remaining_zero_not_exceeded() {
2997 let st = state_with(vec![gift_removal_labeled(
2998 1,
2999 date!(2025 - 06 - 01),
3000 dec!(100000),
3001 "Alice",
3002 )]);
3003 let tables = tables_with(2025, dec!(19000));
3004 let msg =
3006 render_gift_advisory(&st, 2025, dec!(13_909_000), &tables).expect("advisory expected");
3007 assert!(
3008 msg.contains("13990000.00"),
3009 "cumulative $13,990,000 must appear: {msg}"
3010 );
3011 assert!(
3013 msg.contains("($0.00 remaining)"),
3014 "remaining $0.00 in exact phrasing '($0.00 remaining)' must appear: {msg}"
3015 );
3016 assert!(
3018 !msg.contains("EXCEEDED"),
3019 "must NOT say EXCEEDED at exactly the limit: {msg}"
3020 );
3021 }
3022
3023 #[test]
3027 fn section_2505_prior_only_block_shows_even_when_current_taxable_zero() {
3028 let st = state_with(vec![gift_removal_labeled(
3029 1,
3030 date!(2025 - 06 - 01),
3031 dec!(10000), "Alice",
3033 )]);
3034 let tables = tables_with(2025, dec!(19000));
3035 let msg =
3036 render_gift_advisory(&st, 2025, dec!(5_000_000), &tables).expect("advisory expected");
3037 assert!(
3039 msg.contains("5000000.00"),
3040 "cumulative $5,000,000 must appear: {msg}"
3041 );
3042 assert!(
3043 msg.contains("§2505 lifetime (basic) exclusion"),
3044 "§2505 block must appear for prior-only case: {msg}"
3045 );
3046 assert!(!msg.contains("EXCEEDED"), "must NOT say EXCEEDED: {msg}");
3047 }
3048
3049 #[test]
3052 fn section_2505_no_block_when_cumulative_zero() {
3053 let st = state_with(vec![gift_removal_labeled(
3054 1,
3055 date!(2025 - 06 - 01),
3056 dec!(10000), "Alice",
3058 )]);
3059 let tables = tables_with(2025, dec!(19000));
3060 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3061 assert!(
3062 !msg.contains("§2505 lifetime"),
3063 "§2505 block must NOT appear when cumulative = 0: {msg}"
3064 );
3065 }
3066
3067 #[test]
3069 fn section_2505_default_zero_prior_shows_caveats() {
3070 let st = state_with(vec![gift_removal_labeled(
3071 1,
3072 date!(2025 - 06 - 01),
3073 dec!(100000), "Alice",
3075 )]);
3076 let tables = tables_with(2025, dec!(19000));
3077 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3078 assert!(
3080 !msg.contains("later chunk (Chunk 3)"),
3081 "stale 'later chunk (Chunk 3)' must be absent: {msg}"
3082 );
3083 assert!(
3085 msg.contains("§2513 gift-splitting"),
3086 "§2513 caveat must be present: {msg}"
3087 );
3088 assert!(
3089 msg.contains("portability/DSUE"),
3090 "portability/DSUE caveat must be present: {msg}"
3091 );
3092 assert!(
3093 msg.contains("prior cumulative taxable gifts are user-supplied"),
3094 "prior-cumulative disclosure caveat must be present: {msg}"
3095 );
3096 }
3097
3098 #[test]
3101 fn section_2505_mixed_shows_omission_disclosure_for_unlabeled() {
3102 let st = state_with(vec![
3103 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(100000), "Alice"),
3104 gift_removal(2, date!(2025 - 06 - 01), dec!(50000)), ]);
3106 let tables = tables_with(2025, dec!(19000));
3107 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3108 assert!(
3110 msg.contains("§2505 lifetime (basic) exclusion"),
3111 "§2505 block must appear: {msg}"
3112 );
3113 assert!(
3114 msg.contains("81000.00"),
3115 "used $81,000 (labeled only) must appear: {msg}"
3116 );
3117 assert!(
3119 msg.contains("§2505 consumption reflects LABELED-donee taxable gifts only"),
3120 "omission disclosure must appear: {msg}"
3121 );
3122 assert!(
3123 msg.contains("50000.00"),
3124 "unlabeled total $50,000 must appear in omission disclosure: {msg}"
3125 );
3126 assert!(
3127 msg.contains("consumption may be understated"),
3128 "under-stated warning must appear: {msg}"
3129 );
3130 }
3131
3132 #[test]
3134 fn section_2505_stale_chunk3_caveat_is_absent() {
3135 let st = state_with(vec![gift_removal_labeled(
3136 1,
3137 date!(2025 - 06 - 01),
3138 dec!(20000),
3139 "Alice",
3140 )]);
3141 let tables = tables_with(2025, dec!(19000));
3142 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3143 assert!(
3144 !msg.contains("later chunk (Chunk 3)"),
3145 "stale Chunk-3 caveat must be absent: {msg}"
3146 );
3147 assert!(
3148 !msg.contains("§2505 lifetime exemption is a later chunk"),
3149 "stale §2505 future-chunk phrase must be absent: {msg}"
3150 );
3151 }
3152}
3153
3154#[cfg(test)]
3155mod schedule_se_tests {
3156 use super::*;
3160 use rust_decimal_macros::dec;
3161
3162 fn golden1() -> SeTaxResult {
3164 SeTaxResult {
3165 net_se: dec!(100000),
3166 base: dec!(92350.00),
3167 ss: dec!(11451.40),
3168 medicare: dec!(2678.15),
3169 addl: dec!(0.00),
3170 total: dec!(14129.55),
3171 deductible_half: dec!(7064.78),
3172 }
3173 }
3174
3175 fn w2_headline() -> SeTaxResult {
3177 SeTaxResult {
3178 net_se: dec!(100000),
3179 base: dec!(92350.00),
3180 ss: dec!(3236.40),
3181 medicare: dec!(2678.15),
3182 addl: dec!(381.15),
3183 total: dec!(6295.70),
3184 deductible_half: dec!(2957.28),
3185 }
3186 }
3187
3188 fn w2_asymmetric() -> SeTaxResult {
3190 SeTaxResult {
3191 net_se: dec!(100000),
3192 base: dec!(92350.00),
3193 ss: dec!(3236.40),
3194 medicare: dec!(2678.15),
3195 addl: dec!(0.00),
3196 total: dec!(5914.55),
3197 deductible_half: dec!(2957.28),
3198 }
3199 }
3200
3201 fn expenses_headline() -> SeTaxResult {
3206 SeTaxResult {
3207 net_se: dec!(80000),
3208 base: dec!(73880.00),
3209 ss: dec!(9161.12),
3210 medicare: dec!(2142.52),
3211 addl: dec!(0.00),
3212 total: dec!(11303.64),
3213 deductible_half: dec!(5651.82),
3214 }
3215 }
3216
3217 fn expenses_w2_combined() -> SeTaxResult {
3227 SeTaxResult {
3228 net_se: dec!(80000),
3229 base: dec!(73880.00),
3230 ss: dec!(3236.40),
3231 medicare: dec!(2142.52),
3232 addl: dec!(214.92),
3233 total: dec!(5593.84),
3234 deductible_half: dec!(2689.46),
3235 }
3236 }
3237
3238 #[test]
3242 fn business_mining_year_renders_full_section() {
3243 let r = golden1();
3244 let s = render_schedule_se(
3245 2025,
3246 Some(&r),
3247 dec!(100000),
3248 true,
3249 Usd::ZERO,
3250 Usd::ZERO,
3251 Usd::ZERO,
3252 )
3253 .expect("SE section expected");
3254 assert!(s.contains("92350.00"), "net SE earnings base: {s}");
3256 assert!(s.contains("11451.40"), "SS component: {s}");
3257 assert!(s.contains("2678.15"), "Medicare component: {s}");
3258 assert!(s.contains("14129.55"), "total SE tax: {s}");
3259 assert!(s.contains("7064.78"), "§164(f) deductible half: {s}");
3260 assert!(
3261 s.contains("Additional Medicare"),
3262 "addl component labeled: {s}"
3263 );
3264 assert!(
3266 s.contains("$0 W-2 wages"),
3267 "short $0-W-2 note must appear (both W-2 = 0): {s}"
3268 );
3269 assert!(
3270 s.contains("--w2-ss-wages"),
3271 "$0 note must mention --w2-ss-wages flag: {s}"
3272 );
3273 assert!(
3274 !s.contains("OVERSTATED"),
3275 "old OVERSTATED text must be absent (Chunk A regression): {s}"
3276 );
3277 assert!(
3278 !s.contains("UNDERSTATED"),
3279 "old UNDERSTATED text must be absent (Chunk A regression): {s}"
3280 );
3281 assert!(
3283 s.contains("NOT auto-coordinated"),
3284 "§164(f) advisory must appear: {s}"
3285 );
3286 assert!(
3287 s.contains("coordinate it on your actual return"),
3288 "§164(f) advisory must include coordination instruction: {s}"
3289 );
3290 assert!(
3292 s.contains("SEPARATE federal liability"),
3293 "standalone note: {s}"
3294 );
3295 assert!(
3296 s.contains("not") && s.contains("§164(f)"),
3297 "notes §164(f) not auto-coordinated: {s}"
3298 );
3299 assert!(
3301 s.contains("no Schedule C expenses supplied"),
3302 "Chunk B $0-expenses note must appear: {s}"
3303 );
3304 assert!(
3305 s.contains("--schedule-c-expenses"),
3306 "$0 note must mention --schedule-c-expenses flag: {s}"
3307 );
3308 assert!(
3309 !s.contains("not modeled"),
3310 "old 'not modeled' caveat must be absent (replaced by Chunk B): {s}"
3311 );
3312 }
3313
3314 #[test]
3316 fn w2_set_renders_coordinated_disclosure() {
3317 let r = w2_headline();
3318 let s = render_schedule_se(
3319 2025,
3320 Some(&r),
3321 dec!(100000),
3322 true,
3323 Usd::ZERO,
3324 dec!(150000),
3325 dec!(150000),
3326 )
3327 .expect("SE section expected");
3328 assert!(
3330 s.contains("W-2 coordination applied"),
3331 "coordinated disclosure must appear: {s}"
3332 );
3333 assert!(
3334 s.contains("§1401(b)(2)(B)"),
3335 "must cite §1401(b)(2)(B): {s}"
3336 );
3337 assert!(
3338 s.contains("Form 8959 Part II"),
3339 "must cite Form 8959 Part II: {s}"
3340 );
3341 assert!(s.contains("150000"), "w2_ss_wages amount must appear: {s}");
3343 assert!(!s.contains("OVERSTATED"), "OVERSTATED must be absent: {s}");
3345 assert!(
3346 !s.contains("UNDERSTATED"),
3347 "UNDERSTATED must be absent: {s}"
3348 );
3349 assert!(s.contains("3236.40"), "reduced SS component: {s}");
3351 assert!(s.contains("381.15"), "non-zero addl: {s}");
3352 assert!(s.contains("6295.70"), "reduced total: {s}");
3353 assert!(s.contains("2957.28"), "deductible_half: {s}");
3354 }
3355
3356 #[test]
3360 fn w2_asymmetric_render_transposition_guard() {
3361 let r = w2_asymmetric();
3362 let s = render_schedule_se(
3363 2025,
3364 Some(&r),
3365 dec!(100000),
3366 true,
3367 Usd::ZERO,
3368 dec!(150000),
3369 Usd::ZERO,
3370 )
3371 .expect("SE section expected");
3372 assert!(
3374 s.contains("W-2 coordination applied"),
3375 "coordinated disclosure must appear: {s}"
3376 );
3377 assert!(s.contains("3236.40"), "ss must be 3236.40 (reduced): {s}");
3379 assert!(
3380 s.contains("0.00"),
3381 "addl must be 0.00 (threshold un-reduced): {s}"
3382 );
3383 assert!(!s.contains("OVERSTATED"), "{s}");
3385 assert!(!s.contains("UNDERSTATED"), "{s}");
3386 }
3387
3388 #[test]
3390 fn no_business_income_no_section() {
3391 assert!(
3392 render_schedule_se(2025, None, Usd::ZERO, true, Usd::ZERO, Usd::ZERO, Usd::ZERO)
3393 .is_none()
3394 );
3395 }
3396
3397 #[test]
3400 fn business_income_but_no_table_emits_note() {
3401 let s = render_schedule_se(
3402 2099,
3403 None,
3404 dec!(100000),
3405 false,
3406 Usd::ZERO,
3407 Usd::ZERO,
3408 Usd::ZERO,
3409 )
3410 .expect("wage-base-unavailable note expected");
3411 assert!(s.contains("SS wage base unavailable"), "{s}");
3412 assert!(s.contains("2099"), "names the year: {s}");
3413 assert!(s.contains("no silent drop"), "{s}");
3414 }
3415
3416 #[test]
3421 fn expenses_20k_no_w2_renders_breakout_and_advisory() {
3422 let r = expenses_headline(); let s = render_schedule_se(
3424 2025,
3425 Some(&r),
3426 dec!(100000), true,
3428 dec!(20000), Usd::ZERO,
3430 Usd::ZERO,
3431 )
3432 .expect("SE section expected");
3433 assert!(
3435 s.contains("gross business income"),
3436 "breakout line must appear: {s}"
3437 );
3438 assert!(
3439 s.contains("100000.00"),
3440 "gross ($100k) must appear in breakout: {s}"
3441 );
3442 assert!(
3443 s.contains("20000.00"),
3444 "expenses ($20k) must appear in breakout: {s}"
3445 );
3446 assert!(
3447 s.contains("80000.00"),
3448 "net_se ($80k) must appear in breakout: {s}"
3449 );
3450 assert!(
3452 s.contains("OVERSTATES"),
3453 "Schedule C advisory OVERSTATES text: {s}"
3454 );
3455 assert!(
3456 s.contains("ORDINARY taxable income"),
3457 "advisory must mention ORDINARY taxable income: {s}"
3458 );
3459 assert!(
3460 s.contains("engine-side coordination is deferred"),
3461 "advisory must mention deferred coordination: {s}"
3462 );
3463 assert!(
3465 !s.contains("reduce your ordinary_taxable_income"),
3466 "NO OTI-edit prescription allowed (spec D3): {s}"
3467 );
3468 assert!(
3469 !s.contains("set --ordinary-taxable-income"),
3470 "NO OTI-edit prescription allowed (spec D3): {s}"
3471 );
3472 assert!(s.contains("73880.00"), "base $73,880: {s}");
3474 assert!(s.contains("9161.12"), "ss $9,161.12: {s}");
3475 assert!(s.contains("2142.52"), "medicare $2,142.52: {s}");
3476 assert!(s.contains("11303.64"), "total $11,303.64: {s}");
3477 assert!(s.contains("5651.82"), "deductible_half $5,651.82: {s}");
3478 assert!(
3480 !s.contains("not modeled"),
3481 "old 'not modeled' caveat must be absent: {s}"
3482 );
3483 }
3484
3485 #[test]
3488 fn fully_expensed_shows_new_line_not_wage_base_note() {
3489 let s = render_schedule_se(
3492 2025,
3493 None,
3494 dec!(10000), true, dec!(15000), Usd::ZERO,
3498 Usd::ZERO,
3499 )
3500 .expect("fully-expensed section expected (not None)");
3501 assert!(
3503 s.contains("fully expensed"),
3504 "fully-expensed line must appear: {s}"
3505 );
3506 assert!(
3507 s.contains("10000.00"),
3508 "gross $10k must appear in fully-expensed line: {s}"
3509 );
3510 assert!(
3511 s.contains("15000.00"),
3512 "expenses $15k must appear in fully-expensed line: {s}"
3513 );
3514 assert!(
3515 s.contains("no §1401 SE tax"),
3516 "must state no SE tax owed: {s}"
3517 );
3518 assert!(s.contains("2025"), "must name the year: {s}");
3519 assert!(
3521 !s.contains("SS wage base unavailable"),
3522 "wage-base-unavailable note must be ABSENT for fully-expensed case: {s}"
3523 );
3524 }
3525
3526 #[test]
3528 fn expenses_w2_combined_renders_both() {
3529 let r = expenses_w2_combined(); let s = render_schedule_se(
3531 2025,
3532 Some(&r),
3533 dec!(100000),
3534 true,
3535 dec!(20000), dec!(150000), dec!(150000), )
3539 .expect("SE section expected");
3540 assert!(s.contains("gross business income"), "breakout line: {s}");
3542 assert!(s.contains("80000.00"), "net_se in breakout: {s}");
3543 assert!(s.contains("OVERSTATES"), "Schedule C advisory: {s}");
3545 assert!(
3547 s.contains("W-2 coordination applied"),
3548 "W-2 coordination: {s}"
3549 );
3550 assert!(s.contains("73880.00"), "base: {s}");
3552 assert!(s.contains("3236.40"), "ss (reduced by W-2 cap): {s}");
3553 assert!(s.contains("2142.52"), "medicare: {s}");
3554 assert!(s.contains("214.92"), "addl: {s}");
3555 assert!(s.contains("5593.84"), "total: {s}");
3556 assert!(s.contains("2689.46"), "deductible_half: {s}");
3557 }
3558
3559 #[test]
3561 fn schedule_se_csv_columns_and_values() {
3562 let dir = tempfile::tempdir().unwrap();
3563 let out = dir.path().join("export");
3564 let st = LedgerState::default();
3565 let r = golden1();
3566 write_csv_exports(&out, &st, Some(2025), Some(&r), &BTreeMap::new()).unwrap();
3567
3568 let path = out.join("schedule_se.csv");
3569 assert!(path.exists(), "schedule_se.csv must be written");
3570 let mut rdr = csv::Reader::from_reader(std::fs::File::open(&path).unwrap());
3571 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3572 assert_eq!(
3573 headers,
3574 vec![
3575 "net_se_earnings",
3576 "se_base_9235",
3577 "ss_component",
3578 "medicare_component",
3579 "additional_medicare_component",
3580 "total_se_tax",
3581 "deductible_half",
3582 ]
3583 );
3584 let rec = rdr
3585 .records()
3586 .next()
3587 .expect("one data row")
3588 .expect("readable");
3589 assert_eq!(&rec[0], "100000"); 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"); }
3597
3598 #[test]
3600 fn schedule_se_csv_omitted_when_no_se_tax() {
3601 let dir = tempfile::tempdir().unwrap();
3602 let out = dir.path().join("export");
3603 let st = LedgerState::default();
3604 write_csv_exports(&out, &st, Some(2025), None, &BTreeMap::new()).unwrap();
3605 assert!(!out.join("schedule_se.csv").exists());
3606 }
3607}
3608
3609#[cfg(test)]
3610mod form8283_csv_tests {
3611 use super::*;
3614
3615 #[test]
3617 fn form8283_csv_detail_columns_present_and_empty() {
3618 use btctax_core::{
3619 BasisSource, DonationDetails, EventId, LedgerState, Removal, RemovalKind, RemovalLeg,
3620 Term,
3621 };
3622 use time::macros::date;
3623
3624 let dir = tempfile::tempdir().unwrap();
3625 let out = dir.path().join("export");
3626
3627 let event = EventId::decision(99);
3629 let leg = RemovalLeg {
3630 lot_id: btctax_core::LotId {
3631 origin_event_id: event.clone(),
3632 split_sequence: 0,
3633 },
3634 sat: 100_000_000,
3635 basis: rust_decimal::Decimal::ZERO,
3636 fmv_at_transfer: rust_decimal::Decimal::from(52000),
3637 term: Term::LongTerm,
3638 basis_source: BasisSource::ComputedFromCost,
3639 acquired_at: date!(2025 - 01 - 01),
3640 pseudo: false,
3641 };
3642 let removal = Removal {
3643 event: event.clone(),
3644 kind: RemovalKind::Donation,
3645 removed_at: date!(2025 - 03 - 01),
3646 legs: vec![leg],
3647 appraisal_required: false,
3648 donor_acquired_at: None,
3649 claimed_deduction: Some(rust_decimal::Decimal::from(52000)),
3650 donee: Some("Test Charity Two".into()),
3651 };
3652 let event2 = EventId::decision(100);
3654 let leg2 = RemovalLeg {
3655 lot_id: btctax_core::LotId {
3656 origin_event_id: event2.clone(),
3657 split_sequence: 0,
3658 },
3659 sat: 10_000_000,
3660 basis: rust_decimal::Decimal::ZERO,
3661 fmv_at_transfer: rust_decimal::Decimal::from(8000),
3662 term: Term::LongTerm,
3663 basis_source: BasisSource::ComputedFromCost,
3664 acquired_at: date!(2025 - 01 - 15),
3665 pseudo: false,
3666 };
3667 let removal2 = Removal {
3668 event: event2.clone(),
3669 kind: RemovalKind::Donation,
3670 removed_at: date!(2025 - 05 - 01),
3671 legs: vec![leg2],
3672 appraisal_required: false,
3673 donor_acquired_at: None,
3674 claimed_deduction: Some(rust_decimal::Decimal::from(8000)),
3675 donee: Some("No Details Org".into()),
3676 };
3677
3678 let st = LedgerState {
3679 removals: vec![removal, removal2],
3680 ..Default::default()
3681 };
3682
3683 let mut dmap: BTreeMap<EventId, DonationDetails> = BTreeMap::new();
3684 dmap.insert(
3685 event,
3686 DonationDetails {
3687 donee_name: "Test Charity".into(),
3688 donee_ein: Some("12-3456789".into()),
3689 donee_address: Some("123 Main".into()),
3690 appraiser_name: "Test Appraiser".into(),
3691 appraiser_tin: Some("987654321".into()),
3692 appraiser_ptin: Some("P01234567".into()),
3693 appraiser_qualifications: Some("Certified".into()),
3694 appraisal_date: Some(date!(2025 - 06 - 01)),
3695 appraiser_address: None,
3696 fmv_method_override: None,
3697 },
3698 );
3699 write_csv_exports(&out, &st, Some(2025), None, &dmap).unwrap();
3702
3703 let path = out.join("form8283.csv");
3704 assert!(path.exists(), "form8283.csv must exist");
3705
3706 let mut rdr = csv::ReaderBuilder::new()
3707 .comment(Some(b'#'))
3708 .from_path(&path)
3709 .unwrap();
3710 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3711 let idx = |name: &str| {
3712 headers
3713 .iter()
3714 .position(|h| h == name)
3715 .unwrap_or_else(|| panic!("header {name} not found"))
3716 };
3717 let all_recs: Vec<csv::StringRecord> = rdr.records().map(|r| r.unwrap()).collect();
3721 assert_eq!(
3722 all_recs.len(),
3723 2,
3724 "must have exactly two data rows (one per removal)"
3725 );
3726 let rec = &all_recs[0];
3727 let no_details_rec = &all_recs[1];
3728
3729 assert_eq!(&rec[idx("donee")], "Test Charity");
3731 assert_eq!(&rec[idx("appraiser")], "Test Appraiser");
3732 assert_eq!(&rec[idx("donee_ein")], "12-3456789");
3733 assert_eq!(&rec[idx("donee_address")], "123 Main");
3734 assert_eq!(&rec[idx("appraiser_tin")], "987654321");
3735 assert_eq!(&rec[idx("appraiser_ptin")], "P01234567");
3736 assert_eq!(&rec[idx("appraiser_qualifications")], "Certified");
3737 assert_eq!(&rec[idx("appraisal_date")], "2025-06-01");
3738 assert_eq!(&rec[idx("needs_review")], "false");
3739
3740 assert_eq!(
3742 &no_details_rec[idx("donee_ein")],
3743 "",
3744 "no-details row: donee_ein must be empty"
3745 );
3746 assert_eq!(
3747 &no_details_rec[idx("donee_address")],
3748 "",
3749 "no-details row: donee_address must be empty"
3750 );
3751 assert_eq!(
3752 &no_details_rec[idx("appraiser_tin")],
3753 "",
3754 "no-details row: appraiser_tin must be empty"
3755 );
3756 assert_eq!(
3757 &no_details_rec[idx("appraiser_ptin")],
3758 "",
3759 "no-details row: appraiser_ptin must be empty"
3760 );
3761 assert_eq!(
3762 &no_details_rec[idx("appraiser_qualifications")],
3763 "",
3764 "no-details row: appraiser_qualifications must be empty"
3765 );
3766 assert_eq!(
3767 &no_details_rec[idx("appraisal_date")],
3768 "",
3769 "no-details row: appraisal_date must be empty"
3770 );
3771 assert_eq!(
3772 &no_details_rec[idx("needs_review")],
3773 "true",
3774 "no-details carrier row: needs_review must be true"
3775 );
3776 }
3777}
3778
3779pub struct EventRow {
3782 pub reff: String,
3784 pub kind: &'static str,
3786 pub date: TaxDate,
3788 pub sat: Option<btctax_core::Sat>,
3790 pub usd: Option<Usd>,
3792 pub decision_ref: Option<String>,
3795}
3796
3797fn fmt_btc(sat: btctax_core::Sat) -> String {
3799 let whole = sat / 100_000_000;
3800 let frac = (sat % 100_000_000).unsigned_abs();
3801 format!("{whole}.{frac:08}")
3802}
3803
3804pub fn render_events_list(rows: &[EventRow]) -> String {
3807 let mut out = String::new();
3808 if rows.is_empty() {
3809 let _ = writeln!(out, "No decidable events.");
3810 return out;
3811 }
3812 let decided = rows.iter().filter(|r| r.decision_ref.is_some()).count();
3813 let _ = writeln!(
3814 out,
3815 "Decidable events — {} ({} decided, {} open):",
3816 rows.len(),
3817 decided,
3818 rows.len() - decided
3819 );
3820 for r in rows {
3821 let amount = match (r.sat, r.usd) {
3822 (Some(s), Some(u)) => format!("{} BTC (~${})", fmt_btc(s), fmt_money(u)),
3823 (Some(s), None) => format!("{} BTC", fmt_btc(s)),
3824 (None, _) => "—".to_string(),
3825 };
3826 let status = match &r.decision_ref {
3827 Some(d) => format!("[decided: {d}]"),
3828 None => "[decidable]".to_string(),
3829 };
3830 let _ = writeln!(
3831 out,
3832 " {} {} @ {} {} {}",
3833 r.reff, r.kind, r.date, amount, status
3834 );
3835 }
3836 out
3837}
3838
3839#[cfg(test)]
3840mod advisory_wrap_tests {
3841 use super::*;
3842
3843 #[test]
3847 fn advisories_wrap_to_the_house_width_with_a_hanging_indent() {
3848 use btctax_core::tax::advisories::Advisory;
3849 let out = render_advisories(&[Advisory::CtcOdcOmitted { dependents: 2 }]);
3850
3851 for line in out.lines() {
3852 assert!(
3853 line.chars().count() <= ADVISORY_WRAP_COLS,
3854 "line is {} cols, over the {}-col house width: {line:?}",
3855 line.chars().count(),
3856 ADVISORY_WRAP_COLS
3857 );
3858 }
3859 assert!(
3861 out.lines()
3862 .any(|l| l.starts_with(" ") && !l.trim().is_empty()),
3863 "a 300-char advisory must wrap onto continuation lines, got:\n{out}"
3864 );
3865 }
3866}
3867
3868#[cfg(test)]
3869mod events_list_render_tests {
3870 use super::*;
3871 use time::macros::date;
3872
3873 fn row(reff: &str, kind: &'static str, decision_ref: Option<&str>) -> EventRow {
3874 EventRow {
3875 reff: reff.to_owned(),
3876 kind,
3877 date: date!(2025 - 03 - 01),
3878 sat: Some(5_000_000),
3879 usd: Some(rust_decimal_macros::dec!(4271.78)),
3880 decision_ref: decision_ref.map(str::to_owned),
3881 }
3882 }
3883
3884 #[test]
3886 fn empty_renders_a_none_line() {
3887 assert_eq!(render_events_list(&[]), "No decidable events.\n");
3888 }
3889
3890 #[test]
3893 fn rows_are_ref_first_with_bracketed_status() {
3894 let out = render_events_list(&[
3895 row("import|coinbase|in|cb-recv", "transfer-in", None),
3896 row(
3897 "import|coinbase|out|cb-send",
3898 "transfer-out",
3899 Some("decision|1"),
3900 ),
3901 ]);
3902 let lines: Vec<&str> = out.lines().collect();
3903 assert!(
3904 lines[0].contains("2 (1 decided, 1 open)"),
3905 "header: {}",
3906 lines[0]
3907 );
3908 assert_eq!(
3910 lines[1].split_whitespace().next(),
3911 Some("import|coinbase|in|cb-recv")
3912 );
3913 assert!(lines[1].contains("[decidable]"), "open row: {}", lines[1]);
3914 assert!(
3915 lines[1].contains("0.05000000 BTC") && lines[1].contains("4271.78"),
3916 "amount: {}",
3917 lines[1]
3918 );
3919 assert!(
3920 lines[2].contains("[decided: decision|1]"),
3921 "decided row: {}",
3922 lines[2]
3923 );
3924 }
3925}
3926
3927#[cfg(test)]
3928mod holdings_pending_tests {
3929 use super::*;
3932 use btctax_core::state::PendingTransfer;
3933 use btctax_core::EventId;
3934
3935 #[test]
3936 fn holdings_pending_line_shows_in_btc_and_hides_when_reconciled() {
3937 let mut pending = LedgerState::default();
3938 pending.stats.sigma_pending = 3_000_000; pending.pending_reconciliation = vec![PendingTransfer {
3940 event: EventId::decision(1),
3941 principal_sat: 3_000_000,
3942 fee_sat: None,
3943 legs: vec![],
3944 }];
3945 let shown = render_report(&pending, None);
3946 assert!(shown.contains("Pending:"), "pending line present: {shown}");
3947 assert!(shown.contains("0.03000000 BTC"), "BTC unit: {shown}");
3948 assert!(
3949 shown.contains("1 unreconciled transfer"),
3950 "names the count (singular): {shown}"
3951 );
3952 assert!(shown.contains("verify"), "points at `verify`: {shown}");
3953
3954 let reconciled = LedgerState::default();
3956 let hidden = render_report(&reconciled, None);
3957 assert!(
3958 !hidden.contains("Pending:"),
3959 "no pending line when reconciled: {hidden}"
3960 );
3961 }
3962
3963 #[test]
3964 fn holdings_pending_line_pluralizes_multiple_transfers() {
3965 let mut pending = LedgerState::default();
3966 pending.stats.sigma_pending = 150_000_000; pending.pending_reconciliation = vec![
3968 PendingTransfer {
3969 event: EventId::decision(1),
3970 principal_sat: 100_000_000,
3971 fee_sat: None,
3972 legs: vec![],
3973 },
3974 PendingTransfer {
3975 event: EventId::decision(2),
3976 principal_sat: 50_000_000,
3977 fee_sat: None,
3978 legs: vec![],
3979 },
3980 ];
3981 let shown = render_report(&pending, None);
3982 assert!(
3983 shown.contains("2 unreconciled transfers"),
3984 "plural: {shown}"
3985 );
3986 assert!(shown.contains("1.50000000 BTC"), "{shown}");
3987 }
3988}
3989
3990#[cfg(test)]
3991mod decision_class_tests {
3992 use super::*;
3996 use btctax_core::{DisposeKind, InboundClass, IncomeKind, OutflowClass};
3997 use rust_decimal_macros::dec;
3998 use time::macros::date;
3999
4000 #[test]
4001 fn inbound_self_transfer_mine_is_human_no_debug_struct() {
4002 let s = describe_inbound_class(&InboundClass::SelfTransferMine {
4003 basis: Some(dec!(19000)),
4004 acquired_at: Some(date!(2026 - 01 - 01)),
4005 });
4006 assert!(s.contains("self-transfer"), "{s}");
4007 assert!(s.contains("$19000.00"), "names the basis in $: {s}");
4008 assert!(s.contains("2026-01-01"), "names the acquired date: {s}");
4009 assert!(!s.contains('{'), "no Debug struct braces: {s}");
4010 assert!(!s.contains("Some("), "no Debug Option wrapper: {s}");
4011 }
4012
4013 #[test]
4014 fn inbound_self_transfer_mine_defaults_are_named_not_none() {
4015 let s = describe_inbound_class(&InboundClass::SelfTransferMine {
4016 basis: None,
4017 acquired_at: None,
4018 });
4019 assert!(s.contains("self-transfer"), "{s}");
4020 assert!(
4021 s.matches("default").count() >= 2,
4022 "None basis AND date read as 'default': {s}"
4023 );
4024 assert!(!s.contains("None"), "no Debug None: {s}");
4025 }
4026
4027 #[test]
4028 fn inbound_income_names_kind_fmv_business() {
4029 let s = describe_inbound_class(&InboundClass::Income {
4030 kind: IncomeKind::Mining,
4031 fmv: Some(dec!(500)),
4032 business: true,
4033 });
4034 assert!(s.contains("income"), "{s}");
4035 assert!(s.contains("mining"), "names the income kind: {s}");
4036 assert!(s.contains("$500.00"), "names the fmv: {s}");
4037 assert!(s.contains("business"), "flags business: {s}");
4038 assert!(!s.contains('{'), "{s}");
4039 }
4040
4041 #[test]
4042 fn inbound_gift_received_names_fmv() {
4043 let s = describe_inbound_class(&InboundClass::GiftReceived {
4044 donor_basis: Some(dec!(1000)),
4045 donor_acquired_at: Some(date!(2024 - 05 - 05)),
4046 fmv_at_gift: dec!(30000),
4047 });
4048 assert!(s.contains("gift"), "{s}");
4049 assert!(s.contains("$30000.00"), "names the FMV at gift: {s}");
4050 assert!(!s.contains('{'), "{s}");
4051 }
4052
4053 #[test]
4054 fn outflow_classes_are_human() {
4055 assert_eq!(
4056 describe_outflow_class(&OutflowClass::Dispose {
4057 kind: DisposeKind::Sell
4058 }),
4059 "sell"
4060 );
4061 assert_eq!(
4062 describe_outflow_class(&OutflowClass::Dispose {
4063 kind: DisposeKind::Spend
4064 }),
4065 "spend"
4066 );
4067 assert_eq!(describe_outflow_class(&OutflowClass::GiftOut), "gift");
4068 let donate = describe_outflow_class(&OutflowClass::Donate {
4069 appraisal_required: true,
4070 });
4071 assert!(
4072 donate.contains("donate") && donate.contains("appraisal"),
4073 "{donate}"
4074 );
4075 assert_eq!(
4076 describe_outflow_class(&OutflowClass::Donate {
4077 appraisal_required: false
4078 }),
4079 "donate"
4080 );
4081 }
4082}