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}
599
600impl VerifyReport {
601 pub fn has_hard_blockers(&self) -> bool {
604 !self.hard.is_empty()
605 }
606}
607
608fn safe_harbor_status(state: &LedgerState, _events: &[LedgerEvent]) -> String {
619 let effective_path_b = state
622 .lots
623 .iter()
624 .any(|l| l.basis_source == BasisSource::SafeHarborAllocated)
625 || state.disposals.iter().any(|d| {
626 d.legs
627 .iter()
628 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
629 })
630 || state.removals.iter().any(|r| {
631 r.legs
632 .iter()
633 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
634 });
635 let unconservable = state
636 .blockers
637 .iter()
638 .any(|b| b.kind == BlockerKind::SafeHarborUnconservable);
639 let timebar = state
640 .blockers
641 .iter()
642 .any(|b| b.kind == BlockerKind::SafeHarborTimebar);
643 if unconservable {
647 "Path B allocation FAILS conservation/eligibility (hard, §7.4) — fix the allocation"
648 .to_string()
649 } else if effective_path_b {
650 "Path B safe-harbor allocation is effective (§7.4)".to_string()
651 } else if timebar {
652 "Path B time-barred -> using Path A (advisory); `reconcile safe-harbor attest` if timely in your books".to_string()
653 } else {
654 "Path A (actual per-wallet reconstruction; default, no election)".to_string()
655 }
656}
657
658pub fn build_verify(state: &LedgerState, events: &[LedgerEvent], cli: &CliConfig) -> VerifyReport {
659 let conservation = conservation_report(state);
660 let mut hard = Vec::new();
661 let mut advisory = Vec::new();
662 for b in &state.blockers {
663 match b.kind.severity() {
664 Severity::Hard => hard.push(b.clone()),
665 Severity::Advisory => advisory.push(b.clone()),
666 }
667 }
668 let unknown_basis_inbounds = state
669 .blockers
670 .iter()
671 .filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
672 .count();
673
674 let voided = voided_targets(events);
676
677 let elections = method_election_lines(events, &voided);
679
680 let selection_count = events
685 .iter()
686 .filter(|e| matches!(e.payload, EventPayload::LotSelection(_)) && !voided.contains(&e.id))
687 .count();
688
689 let compliance = disposal_compliance(events, state);
691
692 VerifyReport {
693 conservation,
694 hard,
695 advisory,
696 pending: state.pending_reconciliation.len(),
697 unknown_basis_inbounds,
698 safe_harbor: safe_harbor_status(state, events),
699 declared_pre2025_method: cli.pre2025_method,
700 pre2025_method_attested: cli.pre2025_method_attested,
701 elections,
702 selection_count,
703 compliance,
704 }
705}
706
707pub fn write_csv_exports(
719 out_dir: &Path,
720 state: &LedgerState,
721 tax_year: Option<i32>,
722 se_result: Option<&SeTaxResult>,
723 donation_details: &BTreeMap<EventId, DonationDetails>,
724) -> Result<(), crate::CliError> {
725 fsperms::mkdir_owner_only(out_dir)?;
726
727 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("lots.csv"))?);
728 w.write_record([
729 "origin_event",
730 "split",
731 "wallet",
732 "acquired_at",
733 "remaining_sat",
734 "usd_basis",
735 "basis_source",
736 "basis_pending",
737 ])?;
738 for l in &state.lots {
739 w.write_record([
740 l.lot_id.origin_event_id.canonical(),
741 l.lot_id.split_sequence.to_string(),
742 wallet_label(&l.wallet),
743 l.acquired_at.to_string(),
744 l.remaining_sat.to_string(),
745 l.usd_basis.to_string(),
746 basis_source_tag(l.basis_source).to_string(),
747 l.basis_pending.to_string(),
748 ])?;
749 }
750 w.flush()?;
751
752 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("disposals.csv"))?);
753 w.write_record([
754 "event",
755 "kind",
756 "disposed_at",
757 "lot",
758 "sat",
759 "proceeds",
760 "basis",
761 "gain",
762 "term",
763 "gift_zone",
764 "acquired_at",
765 "wallet",
766 ])?;
767 for d in &state.disposals {
768 for leg in &d.legs {
769 w.write_record([
770 d.event.canonical(),
771 dispose_kind_tag(d.kind).to_string(),
772 d.disposed_at.to_string(),
773 format!(
774 "{}#{}",
775 leg.lot_id.origin_event_id.canonical(),
776 leg.lot_id.split_sequence
777 ),
778 leg.sat.to_string(),
779 leg.proceeds.to_string(),
780 leg.basis.to_string(),
781 leg.gain.to_string(),
782 term_tag(leg.term).to_string(),
783 leg.gift_zone
784 .map(|z| gift_zone_tag(z).to_string())
785 .unwrap_or_default(),
786 leg.acquired_at.to_string(),
787 wallet_label(&leg.wallet),
788 ])?;
789 }
790 }
791 w.flush()?;
792
793 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("removals.csv"))?);
794 w.write_record([
801 "event",
802 "kind",
803 "removed_at",
804 "lot",
805 "sat",
806 "basis",
807 "fmv_at_transfer",
808 "term",
809 "acquired_at",
810 "claimed_deduction",
811 "donee",
812 ])?;
813 for r in &state.removals {
814 let deduction_first = r
815 .claimed_deduction
816 .map(|d| d.to_string())
817 .unwrap_or_default();
818 let donee_cell = r.donee.clone().unwrap_or_default();
819 for (leg_idx, leg) in r.legs.iter().enumerate() {
820 let deduction_cell: &str = if leg_idx == 0 { &deduction_first } else { "" };
823 w.write_record([
824 r.event.canonical(),
825 removal_kind_tag(r.kind).to_string(),
826 r.removed_at.to_string(),
827 format!(
828 "{}#{}",
829 leg.lot_id.origin_event_id.canonical(),
830 leg.lot_id.split_sequence
831 ),
832 leg.sat.to_string(),
833 leg.basis.to_string(),
834 leg.fmv_at_transfer.to_string(),
835 term_tag(leg.term).to_string(),
836 leg.acquired_at.to_string(),
837 deduction_cell.to_string(),
838 donee_cell.clone(),
839 ])?;
840 }
841 }
842 w.flush()?;
843
844 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("income.csv"))?);
845 w.write_record([
846 "event",
847 "kind",
848 "recognized_at",
849 "sat",
850 "usd_fmv",
851 "business",
852 ])?;
853 for i in &state.income_recognized {
854 w.write_record([
855 i.event.canonical(),
856 income_kind_tag(i.kind).to_string(),
857 i.recognized_at.to_string(),
858 i.sat.to_string(),
859 i.usd_fmv.to_string(),
860 i.business.to_string(),
861 ])?;
862 }
863 w.flush()?;
864
865 if let Some(year) = tax_year {
868 write_form8949_csv(out_dir, state, year)?;
869 write_schedule_d_csv(out_dir, state, year)?;
870 write_form8283_csv(out_dir, state, year, donation_details)?;
871 write_basis_methodology_txt(out_dir, state, year)?; if let Some(se) = se_result {
879 write_schedule_se_csv(out_dir, se)?;
880 }
881 }
882 Ok(())
883}
884
885pub fn write_form_csvs(
901 out_dir: &Path,
902 state: &LedgerState,
903 year: i32,
904 se_result: Option<&SeTaxResult>,
905 donation_details: &BTreeMap<EventId, DonationDetails>,
906) -> Result<(), crate::CliError> {
907 fsperms::mkdir_owner_only(out_dir)?;
908 write_form8949_csv(out_dir, state, year)?;
909 write_schedule_d_csv(out_dir, state, year)?;
910 write_form8283_csv(out_dir, state, year, donation_details)?;
911 write_basis_methodology_txt(out_dir, state, year)?; if let Some(se) = se_result {
913 write_schedule_se_csv(out_dir, se)?;
914 }
915 Ok(())
916}
917
918pub(crate) fn write_basis_methodology_txt(
923 out_dir: &Path,
924 state: &LedgerState,
925 year: i32,
926) -> Result<(), crate::CliError> {
927 use std::io::Write as _;
928 if let Some(text) = btctax_core::conservative::basis_methodology(state, year) {
929 let mut file = fsperms::open_owner_only(&out_dir.join("basis_methodology.txt"))?;
930 writeln!(file, "{text}")?;
931 }
932 Ok(())
933}
934
935fn write_schedule_se_csv(out_dir: &Path, se: &SeTaxResult) -> Result<(), crate::CliError> {
939 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_se.csv"))?);
940 w.write_record([
941 "net_se_earnings",
942 "se_base_9235",
943 "ss_component",
944 "medicare_component",
945 "additional_medicare_component",
946 "total_se_tax",
947 "deductible_half",
948 ])?;
949 w.write_record([
950 se.net_se.to_string(),
951 se.base.to_string(),
952 se.ss.to_string(),
953 se.medicare.to_string(),
954 se.addl.to_string(),
955 se.total.to_string(),
956 se.deductible_half.to_string(),
957 ])?;
958 w.flush()?;
959 Ok(())
960}
961
962pub const FORM_8283_AGGREGATION_CAVEAT: &str =
968 "Section A/B reflects the \u{00a7}170(f)(11)(F) year-aggregate for similar property: all BTC \
969 donations in the year are summed (all BTC is 'similar property'); if the year-total claimed \
970 deduction exceeds $5,000 every row is Section B (qualified appraisal required), otherwise \
971 Section A. CCA 202302012: the readily-valued exception does not apply to crypto.";
972
973pub fn render_donation_appraisal_advisory(state: &LedgerState, year: i32) -> Option<String> {
991 use btctax_core::QUALIFIED_APPRAISAL_THRESHOLD;
992 let agg = year_donation_deduction(state, year);
993 if agg <= QUALIFIED_APPRAISAL_THRESHOLD {
994 return None;
995 }
996 let threshold = fmt_money(QUALIFIED_APPRAISAL_THRESHOLD);
999 Some(format!(
1000 "\u{00a7}170(f)(11)(F): your {year} BTC donations aggregate ${} of claimed deduction \
1001 (> ${threshold}) \u{2014} a qualified appraisal is required for the donated BTC even if \
1002 no single donation exceeds ${threshold} (all BTC is 'similar property'; CCA 202302012 \
1003 \u{2014} no readily-valued exception for crypto).",
1004 fmt_money(agg)
1005 ))
1006}
1007
1008fn write_form8283_csv(
1015 out_dir: &Path,
1016 state: &LedgerState,
1017 year: i32,
1018 details: &BTreeMap<EventId, DonationDetails>,
1019) -> Result<(), crate::CliError> {
1020 use std::io::Write as _;
1021 let mut file = fsperms::open_owner_only(&out_dir.join("form8283.csv"))?;
1022
1023 writeln!(file, "# {FORM_8283_AGGREGATION_CAVEAT}")?;
1025
1026 let total_deduction = year_donation_deduction(state, year);
1032 if total_deduction <= rust_decimal::Decimal::from(500) {
1033 writeln!(
1034 file,
1035 "# [R0-M1] The year's total noncash charitable deduction ({}) is <= $500; Form 8283 is \
1036 NOT required at that level (rows below are informational only).",
1037 fmt_money(total_deduction)
1038 )?;
1039 }
1040
1041 let mut w = Writer::from_writer(file);
1042 w.write_record([
1043 "section",
1044 "description",
1045 "how_acquired",
1046 "date_acquired",
1047 "date_contributed",
1048 "cost_basis",
1049 "fmv",
1050 "claimed_deduction",
1051 "fmv_method",
1052 "donee",
1053 "appraiser",
1054 "needs_review",
1055 "donee_ein",
1057 "donee_address",
1058 "appraiser_tin",
1059 "appraiser_ptin",
1060 "appraiser_qualifications",
1061 "appraisal_date",
1062 ])?;
1063 for row in form_8283(state, year, details) {
1064 let d = row.details.as_ref();
1065 w.write_record([
1066 row.section
1067 .map(form8283_section_tag)
1068 .unwrap_or("")
1069 .to_string(),
1070 row.description,
1071 form8283_how_acquired_tag(row.how_acquired).to_string(),
1072 row.date_acquired.to_string(),
1073 row.date_contributed.to_string(),
1074 row.cost_basis.to_string(),
1075 row.fmv.to_string(),
1076 row.claimed_deduction
1077 .map(|d| d.to_string())
1078 .unwrap_or_default(),
1079 row.fmv_method,
1080 row.donee,
1081 row.appraiser,
1082 row.needs_review.to_string(),
1083 d.and_then(|d| d.donee_ein.clone()).unwrap_or_default(),
1085 d.and_then(|d| d.donee_address.clone()).unwrap_or_default(),
1086 d.and_then(|d| d.appraiser_tin.clone()).unwrap_or_default(),
1087 d.and_then(|d| d.appraiser_ptin.clone()).unwrap_or_default(),
1088 d.and_then(|d| d.appraiser_qualifications.clone())
1089 .unwrap_or_default(),
1090 d.and_then(|d| d.appraisal_date.map(|dt| dt.to_string()))
1091 .unwrap_or_default(),
1092 ])?;
1093 }
1094 w.flush()?;
1095 Ok(())
1096}
1097
1098fn write_form8949_csv(
1101 out_dir: &Path,
1102 state: &LedgerState,
1103 year: i32,
1104) -> Result<(), crate::CliError> {
1105 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("form8949.csv"))?);
1106 w.write_record([
1107 "part",
1108 "box",
1109 "box_needs_review",
1110 "description",
1111 "date_acquired",
1112 "date_sold",
1113 "proceeds",
1114 "cost_basis",
1115 "adjustment_code",
1116 "adjustment_amount",
1117 "gain",
1118 "wallet",
1119 "disposition_kind",
1120 ])?;
1121 for r in form_8949(state, year) {
1122 w.write_record([
1123 form8949_part_tag(r.part).to_string(),
1124 form8949_box_tag(r.box_).to_string(),
1125 r.box_needs_review.to_string(),
1126 r.description,
1127 r.date_acquired.to_string(),
1128 r.date_sold.to_string(),
1129 r.proceeds.to_string(),
1130 r.cost_basis.to_string(),
1131 r.adjustment_code,
1132 r.adjustment_amount.to_string(),
1133 r.gain.to_string(),
1134 wallet_label(&r.wallet),
1135 dispose_kind_tag(r.disposition_kind).to_string(),
1136 ])?;
1137 }
1138 w.flush()?;
1139 Ok(())
1140}
1141
1142fn write_schedule_d_csv(
1145 out_dir: &Path,
1146 state: &LedgerState,
1147 year: i32,
1148) -> Result<(), crate::CliError> {
1149 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_d.csv"))?);
1150 w.write_record(["part", "proceeds", "cost_basis", "gain"])?;
1151 let totals = schedule_d(state, year);
1152 for (part, p) in [("ST", &totals.st), ("LT", &totals.lt)] {
1153 w.write_record([
1154 part.to_string(),
1155 p.proceeds.to_string(),
1156 p.cost_basis.to_string(),
1157 p.gain.to_string(),
1158 ])?;
1159 }
1160 w.flush()?;
1161 Ok(())
1162}
1163
1164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1171pub enum PseudoDisclosure {
1172 None,
1174 Synthetic,
1176 Placeholder,
1178}
1179
1180impl PseudoDisclosure {
1181 pub fn contributed(self) -> bool {
1183 self != PseudoDisclosure::None
1184 }
1185 pub fn suffix(self) -> &'static str {
1188 if self.contributed() {
1189 " [PSEUDO]"
1190 } else {
1191 ""
1192 }
1193 }
1194 pub fn banner(self) -> &'static str {
1197 match self {
1198 PseudoDisclosure::None => "",
1199 PseudoDisclosure::Synthetic => {
1200 "⚠ [PSEUDO] This vault has pseudo-reconciled (deliberately-synthetic) entries; figures shown \
1201 are an ESTIMATE, not filing-ready. See '[PSEUDO]' rows in 'btctax report' and the \
1202 [PseudoReconcileActive] advisory in 'btctax verify'; resolve them before filing.\n"
1203 }
1204 PseudoDisclosure::Placeholder => {
1205 "⚠ [PSEUDO] These figures are estimated on a synthetic $0 placeholder profile — no tax \
1206 profile or full-return inputs are stored for this year. This is an ESTIMATE, not \
1207 filing-ready. Set a tax profile ('btctax tax-profile --year <Y> …' — setting is the \
1208 default; '--show' inverts), import inputs ('btctax income import'), or turn pseudo mode off \
1209 ('btctax reconcile pseudo off').\n"
1210 }
1211 }
1212 }
1213}
1214
1215pub fn render_tax_outcome(
1226 year: i32,
1227 out: &btctax_core::TaxOutcome,
1228 advisory: Option<&str>,
1229 pseudo: PseudoDisclosure,
1230) -> String {
1231 use btctax_core::TaxOutcome::*;
1232 let mut s = String::new();
1233 s.push_str(pseudo.banner());
1234 let _ = writeln!(s, "Federal tax attributable to crypto — tax year {year}");
1235 match out {
1236 NotComputable(b) => {
1237 let _ = writeln!(s, " NOT COMPUTABLE [{:?}]: {}", b.kind, b.detail);
1238 }
1239 Computed(r) => {
1240 let _ = writeln!(
1241 s,
1242 " net short-term: {} net long-term: {}",
1243 fmt_money(r.st_net),
1244 fmt_money(r.lt_net)
1245 );
1246 let _ = writeln!(
1247 s,
1248 " crypto ordinary income (level): {}",
1249 fmt_money(r.ordinary_from_crypto)
1250 );
1251 let ordinary_rate_attributable = r.total_federal_tax_attributable - r.ltcg_tax - r.niit;
1254 let _ = writeln!(
1255 s,
1256 " ordinary-rate tax (attributable): {}",
1257 fmt_money(ordinary_rate_attributable)
1258 );
1259 let _ = writeln!(
1260 s,
1261 " LTCG tax (attributable): {} NIIT (attributable): {}",
1262 fmt_money(r.ltcg_tax),
1263 fmt_money(r.niit)
1264 );
1265 let _ = writeln!(
1266 s,
1267 " TOTAL federal tax attributable to crypto (delta): {}{} \
1268 (= ordinary-rate + LTCG + NIIT attributable)",
1269 fmt_money(r.total_federal_tax_attributable),
1270 pseudo.suffix()
1271 );
1272 let _ = writeln!(
1273 s,
1274 " §1211 loss deduction (level): {} carryforward out: short {} / long {}",
1275 fmt_money(r.loss_deduction),
1276 fmt_money(r.carryforward_out.short),
1277 fmt_money(r.carryforward_out.long)
1278 );
1279 let _ = writeln!(
1280 s,
1281 " marginal rates: ordinary {} / LTCG {} / NIIT increased by crypto: {}",
1282 r.marginal_rates.ordinary,
1283 r.marginal_rates.ltcg,
1284 if r.marginal_rates.niit_applies {
1285 "yes"
1286 } else {
1287 "no"
1288 }
1289 );
1290 let _ = writeln!(
1291 s,
1292 " (incremental ceteris-paribus delta on the minimal profile; \
1293 excludes AGI-driven SS/IRMAA/AMT/QBI/phaseout effects — I5. §1411 NIIT reduces NII by the \
1294 §1211(b)-allowed net capital loss (≤ $3,000 / $1,500 MFS — Form 8960 line 5a / §1.1411-4(d)) \
1295 and is floored at $0; crypto ordinary income (mining/staking/airdrops/rewards) is correctly \
1296 excluded from NII; crypto-lending interest income (§1411(c)(1)(A)(i)) is INCLUDED in NII; \
1297 mining/staking/airdrops/rewards remain excluded (SE income per §1411(c)(6) or non-NII other income).)"
1298 );
1299 }
1300 }
1301 if let Some(msg) = advisory {
1304 let _ = writeln!(s, " ADVISORY (M4): {msg}");
1305 }
1306 s
1307}
1308
1309pub(crate) const ADVISORY_WRAP_COLS: usize = 92;
1311
1312pub(crate) fn wrap_bulleted(text: &str) -> String {
1317 const BULLET: &str = " \u{2022} ";
1318 const HANG: &str = " ";
1319 let mut out = String::new();
1320 let mut line = String::from(BULLET);
1321 let mut have_word = false;
1322
1323 for word in text.split_whitespace() {
1324 let prospective = line.chars().count() + usize::from(have_word) + word.chars().count();
1325 if have_word && prospective > ADVISORY_WRAP_COLS {
1326 out.push_str(line.trim_end());
1327 out.push('\n');
1328 line = String::from(HANG);
1329 have_word = false;
1330 }
1331 if have_word {
1332 line.push(' ');
1333 }
1334 line.push_str(word);
1335 have_word = true;
1336 }
1337 out.push_str(line.trim_end());
1338 out
1339}
1340
1341pub fn render_advisories(advisories: &[btctax_core::tax::advisories::Advisory]) -> String {
1345 let mut s = String::new();
1346 if advisories.is_empty() {
1347 return s;
1348 }
1349 let _ = writeln!(s, "\n ── ADVISORIES ({}) ──", advisories.len());
1350 for a in advisories {
1351 let _ = writeln!(s, "{}", wrap_bulleted(&a.message()));
1355 }
1356 let _ = writeln!(
1357 s,
1358 " (Advisories never change a number and never fail the command. See `btctax limitations`.)"
1359 );
1360 s
1361}
1362
1363pub fn provenance_label(p: crate::resolve::Provenance) -> &'static str {
1366 use crate::resolve::Provenance::*;
1367 match p {
1368 ReturnInputs => "ReturnInputs (derived from line items)",
1369 StoredProfile => "stored TaxProfile (raw override)",
1370 PseudoPlaceholder => "pseudo-reconcile placeholder ($0)",
1371 Missing => "none (TaxProfileMissing)",
1372 }
1373}
1374
1375pub fn render_dual_report(
1390 year: i32,
1391 ar: &btctax_core::AbsoluteReturn,
1392 printed: &btctax_core::tax::packet::PrintedForms,
1393 delta: &btctax_core::TaxOutcome,
1394 provenance: crate::resolve::Provenance,
1395 pseudo: PseudoDisclosure,
1396) -> String {
1397 let f = &printed.f1040;
1398 let mut s = String::new();
1399 let _ = writeln!(
1400 s,
1401 "\n═══ Absolute filed return (Form 1040) — tax year {year} ═══"
1402 );
1403 let _ = writeln!(s, " Profile source: {}", provenance_label(provenance));
1404 let _ = writeln!(s, " Total income (1040 L9): {}", fmt_money(f.line9));
1405 let _ = writeln!(s, " Adjustments (L10): {}", fmt_money(f.line10));
1406 let _ = writeln!(s, " AGI (L11): {}", fmt_money(f.line11));
1407 let ded_kind = if ar.deduction_is_itemized {
1408 "itemized"
1409 } else {
1410 "standard"
1411 };
1412 let _ = writeln!(s, " Deduction (L12, {ded_kind}): {}", fmt_money(f.line12));
1413 if ar.qbi_deduction > Usd::ZERO {
1414 let _ = writeln!(s, " QBI deduction (L13): {}", fmt_money(f.line13));
1415 }
1416 let _ = writeln!(s, " Taxable income (L15): {}", fmt_money(f.line15));
1417 let _ = writeln!(s, " Tax (L16): {}", fmt_money(f.line16));
1418 if ar.foreign_tax_credit > Usd::ZERO {
1419 let _ = writeln!(
1420 s,
1421 " Foreign tax credit (Sch 3 L1): {}",
1422 fmt_money(printed.sch_3.map_or(Usd::ZERO, |s3| s3.line1))
1423 );
1424 }
1425 if ar.se_tax_sch2_l4 > Usd::ZERO {
1426 let _ = writeln!(
1427 s,
1428 " Self-employment tax (Sch 2 L4): {}",
1429 fmt_money(printed.sch_2.map_or(Usd::ZERO, |s2| s2.line4))
1430 );
1431 }
1432 if ar.additional_medicare.additional_medicare_tax > Usd::ZERO {
1433 let _ = writeln!(
1434 s,
1435 " Additional Medicare (Form 8959 → Sch 2 L11): {}",
1436 fmt_money(printed.f8959.line18)
1437 );
1438 }
1439 if ar.niit.tax > Usd::ZERO {
1440 let _ = writeln!(
1441 s,
1442 " Net Investment Income Tax (Form 8960 → Sch 2 L12): {}",
1443 fmt_money(printed.f8960.map_or(Usd::ZERO, |f| f.line17))
1444 );
1445 }
1446 let _ = writeln!(
1447 s,
1448 " TOTAL TAX (L24): {}{}",
1449 fmt_money(f.line24),
1450 pseudo.suffix()
1451 );
1452 let _ = writeln!(s, " Total payments (L33): {}", fmt_money(f.line33));
1453 if f.line34 > Usd::ZERO {
1454 let _ = writeln!(s, " → REFUND (L35a): {}", fmt_money(f.line34));
1455 } else {
1456 let _ = writeln!(s, " → AMOUNT OWED (L37): {}", fmt_money(f.line37));
1457 }
1458 let delta_str = match delta {
1460 btctax_core::TaxOutcome::Computed(r) => fmt_money(r.total_federal_tax_attributable),
1461 btctax_core::TaxOutcome::NotComputable(_) => "not computable".to_string(),
1462 };
1463 let _ = writeln!(
1464 s,
1465 "\n ── Two DIFFERENT questions — NOT reconciled (SPEC §6) ──"
1466 );
1467 let _ = writeln!(
1468 s,
1469 " • Absolute TOTAL TAX (this filed return, WITH crypto): {}{}",
1470 fmt_money(f.line24),
1471 pseudo.suffix()
1472 );
1473 let _ = writeln!(
1474 s,
1475 " • Crypto-attributable tax (DELTA, shown above): {delta_str}"
1476 );
1477 let _ = writeln!(
1478 s,
1479 " The delta's implied deduction is fixed at derivation time (non-crypto AGI), so it is \
1480 APPROXIMATE where a\n deduction is AGI-sensitive (e.g. the 7.5% medical floor); the two do NOT \
1481 reconcile to the dollar."
1482 );
1483 s
1484}
1485
1486pub fn render_schedule_d(
1496 year: i32,
1497 totals: &ScheduleDTotals,
1498 outcome: &btctax_core::TaxOutcome,
1499) -> String {
1500 let mut s = String::new();
1501 let _ = writeln!(
1502 s,
1503 "Schedule D (raw pre-netting part totals) — tax year {year}"
1504 );
1505 let _ = writeln!(
1506 s,
1507 " Part I (short-term): proceeds {} cost basis {} gain {}",
1508 fmt_money(totals.st.proceeds),
1509 fmt_money(totals.st.cost_basis),
1510 fmt_money(totals.st.gain)
1511 );
1512 let _ = writeln!(
1513 s,
1514 " Part II (long-term): proceeds {} cost basis {} gain {}",
1515 fmt_money(totals.lt.proceeds),
1516 fmt_money(totals.lt.cost_basis),
1517 fmt_money(totals.lt.gain)
1518 );
1519 match outcome {
1520 btctax_core::TaxOutcome::NotComputable(_) => {
1521 let _ = writeln!(
1522 s,
1523 " (raw disposition totals shown above; the year's tax is NOT COMPUTABLE until \
1524 the blocker is resolved — these Form 8949/Schedule D part totals are \
1525 informational and are not netted/carried until the tax computes)."
1526 );
1527 }
1528 btctax_core::TaxOutcome::Computed(_) => {
1529 let _ = writeln!(
1530 s,
1531 " Note: §1222/§1211/§1212 netting + carryforward are applied in the tax \
1532 computation (report --tax-year); these are the raw pre-netting Form \
1533 8949/Schedule D part totals."
1534 );
1535 }
1536 }
1537 s
1538}
1539
1540pub fn render_schedule_se(
1562 year: i32,
1563 result: Option<&SeTaxResult>,
1564 gross_se: Usd,
1565 table_present: bool,
1566 schedule_c_expenses: Usd,
1567 w2_ss_wages: Usd,
1568 w2_medicare_wages: Usd,
1569) -> Option<String> {
1570 match result {
1571 Some(r) => {
1572 let mut s = String::new();
1573 let _ = writeln!(
1574 s,
1575 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1576 );
1577 if schedule_c_expenses > Usd::ZERO {
1579 let gross_display = r.net_se + schedule_c_expenses;
1582 let _ = writeln!(
1583 s,
1584 " gross business income {} \u{2212} Schedule C expenses {} = net SE earnings {}",
1585 fmt_money(gross_display),
1586 fmt_money(schedule_c_expenses),
1587 fmt_money(r.net_se)
1588 );
1589 let _ = writeln!(
1591 s,
1592 " (Schedule C advisory) Schedule C expenses also reduce your ORDINARY taxable \
1593 income, but the income-tax total above uses GROSS crypto income \u{2014} to first \
1594 order it OVERSTATES your tax by your marginal ordinary rate applied to {}. The tax \
1595 profile cannot express this (an `ordinary_taxable_income` edit would shift both \
1596 legs of the crypto-attributable delta); the engine-side coordination is deferred \
1597 \u{2014} coordinate it on your actual return.",
1598 fmt_money(schedule_c_expenses)
1599 );
1600 } else {
1601 let _ = writeln!(
1602 s,
1603 " net self-employment income (business crypto, Interest excluded): {}",
1604 fmt_money(r.net_se)
1605 );
1606 let _ = writeln!(
1607 s,
1608 " (Schedule C) no Schedule C expenses supplied (--schedule-c-expenses)"
1609 );
1610 }
1611 let _ = writeln!(
1612 s,
1613 " \u{00d7} 92.35% net-earnings factor (\u{00a7}1402(a)) = net SE earnings: {}",
1614 fmt_money(r.base)
1615 );
1616 let _ = writeln!(
1617 s,
1618 " Social Security component (12.4%, §1401(a); capped at the SS wage base): {}",
1619 fmt_money(r.ss)
1620 );
1621 let _ = writeln!(
1622 s,
1623 " Medicare component (2.9%, §1401(b); uncapped): {}",
1624 fmt_money(r.medicare)
1625 );
1626 let _ = writeln!(
1627 s,
1628 " Additional Medicare component (0.9%, §1401(b)(2)): {}",
1629 fmt_money(r.addl)
1630 );
1631 let _ = writeln!(
1632 s,
1633 " TOTAL self-employment tax (§1401): {}",
1634 fmt_money(r.total)
1635 );
1636 let _ = writeln!(
1637 s,
1638 " §164(f) one-half-SE-tax deduction (above-the-line; EXCLUDES Additional Medicare per \
1639 §164(f)(1)): {}",
1640 fmt_money(r.deductible_half)
1641 );
1642 let _ = writeln!(
1645 s,
1646 " (§164(f) advisory) The §164(f) deduction ({}) is NOT auto-coordinated into the \
1647 income-tax total above — to first order, that total overstates your combined tax by \
1648 your marginal ordinary rate applied to {}. The tax profile cannot express this deduction \
1649 directly (reducing `ordinary_taxable_income` would shift BOTH legs of the \
1650 crypto-attributable delta and only correct the bracket differential, not the level) — \
1651 coordinate it on your actual return.",
1652 fmt_money(r.deductible_half),
1653 fmt_money(r.deductible_half)
1654 );
1655 if w2_ss_wages > Usd::ZERO || w2_medicare_wages > Usd::ZERO {
1658 let _ = writeln!(
1659 s,
1660 " (W-2 coordination applied) SS cap = max(0, wage base \u{2212} {}) (Box 3+7); \
1661 Additional-Medicare threshold reduced (not below 0) by {} (Box 5, \
1662 §1401(b)(2)(B)/Form 8959 Part II).",
1663 fmt_money(w2_ss_wages),
1664 fmt_money(w2_medicare_wages)
1665 );
1666 } else {
1667 let _ = writeln!(
1668 s,
1669 " (W-2) assumes $0 W-2 wages (set --w2-ss-wages/--w2-medicare-wages on the tax \
1670 profile if you had a wage job)."
1671 );
1672 }
1673 if r.base < rust_decimal::Decimal::from(400) {
1676 let _ = writeln!(
1677 s,
1678 " (§6017 filing floor) Net earnings from self-employment ({base}) are below $400: \
1679 a Schedule SE filing is required on account of this income only when net earnings \
1680 from self-employment (the ×92.35% base, §1402(a)) are $400 or more (§6017), and \
1681 below that floor no §1401 SE tax is imposed (§1402(b)(2); church employee income \
1682 excepted — §1402(j)(2), not modeled) — the figures above are shown for \
1683 transparency (other self-employment activities, if any, combine on your actual \
1684 Schedule SE).",
1685 base = fmt_money(r.base)
1686 );
1687 }
1688 let _ = writeln!(
1690 s,
1691 " (standalone) This §1401 SE tax is a SEPARATE federal liability, NOT included in the \
1692 income-tax + NIIT total above; the §164(f) one-half-SE-tax deduction is not \
1693 auto-coordinated into that total."
1694 );
1695 Some(s)
1696 }
1697 None => {
1698 if gross_se.is_zero() {
1699 None } else if !table_present {
1701 let mut s = String::new();
1703 let _ = writeln!(
1704 s,
1705 "Schedule SE (§1401 self-employment tax) — tax year {year}"
1706 );
1707 let _ = writeln!(
1708 s,
1709 " SS wage base unavailable for {year}: business self-employment income is present \
1710 but no bundled tax table (ss_wage_base) exists for {year}; the §1401 SE tax was \
1711 NOT computed (no silent drop)."
1712 );
1713 Some(s)
1714 } else {
1715 let mut s = String::new();
1718 let _ = writeln!(
1719 s,
1720 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1721 );
1722 let _ = writeln!(
1723 s,
1724 " fully expensed: gross {} \u{2212} Schedule C expenses {} \u{2264} $0 \
1725 \u{2192} no \u{00a7}1401 SE tax for {year}.",
1726 fmt_money(gross_se),
1727 fmt_money(schedule_c_expenses)
1728 );
1729 Some(s)
1730 }
1731 }
1732 }
1733}
1734
1735pub fn render_gift_advisory(
1758 state: &LedgerState,
1759 year: i32,
1760 prior_taxable_gifts: btctax_core::conventions::Usd,
1761 tables: &impl btctax_core::TaxTables,
1762) -> Option<String> {
1763 use std::collections::BTreeMap;
1764
1765 let any_gift = state
1767 .removals
1768 .iter()
1769 .any(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year);
1770 if !any_gift {
1771 return None; }
1773
1774 let t = match tables.table_for(year) {
1776 None => {
1777 let total: btctax_core::conventions::Usd = state
1778 .removals
1779 .iter()
1780 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1781 .flat_map(|r| r.legs.iter())
1782 .map(|leg| leg.fmv_at_transfer)
1783 .sum();
1784 return Some(format!(
1785 "Form 709 gift advisory ({year}): gift annual-exclusion table unavailable for \
1786 {year}; Form 709 exposure not evaluated — ${} in gifts recorded.",
1787 fmt_money(total)
1788 ));
1789 }
1790 Some(t) => t,
1791 };
1792 let excl = t.gift_annual_exclusion;
1793
1794 let mut labeled: BTreeMap<String, btctax_core::conventions::Usd> = BTreeMap::new();
1796 let mut unlabeled_count: usize = 0;
1797 let mut unlabeled_total: btctax_core::conventions::Usd = Default::default();
1798
1799 for r in state
1800 .removals
1801 .iter()
1802 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1803 {
1804 let fmv: btctax_core::conventions::Usd = r.legs.iter().map(|l| l.fmv_at_transfer).sum();
1805 match &r.donee {
1806 Some(label) => {
1807 *labeled.entry(label.clone()).or_default() += fmv;
1808 }
1809 None => {
1810 unlabeled_count += 1;
1811 unlabeled_total += fmv;
1812 }
1813 }
1814 }
1815
1816 let mut filing_required_donees: Vec<String> = Vec::new();
1818 let mut total_taxable: btctax_core::conventions::Usd = Default::default();
1819 let mut s = format!("Form 709 gift advisory ({year}):");
1820
1821 if !labeled.is_empty() {
1822 s.push_str(&format!(
1823 "\n§2503(b) per-donee annual exclusion analysis (TY{year}, exclusion ${}):",
1824 fmt_money(excl)
1825 ));
1826 for (donee, &total) in &labeled {
1827 let applied = if total < excl { total } else { excl };
1828 let taxable: btctax_core::conventions::Usd = if total > excl {
1829 total - excl
1830 } else {
1831 Default::default()
1832 };
1833 s.push_str(&format!(
1834 "\n {donee}: total ${}, exclusion applied ${}, taxable ${}",
1835 fmt_money(total),
1836 fmt_money(applied),
1837 fmt_money(taxable)
1838 ));
1839 if total > excl {
1840 filing_required_donees.push(donee.clone());
1841 total_taxable += taxable;
1842 }
1843 }
1844 if !filing_required_donees.is_empty() {
1845 s.push_str(&format!(
1846 "\nForm 709 filing required (donee(s): {}). Total taxable gifts: ${}.",
1847 filing_required_donees.join(", "),
1848 fmt_money(total_taxable)
1849 ));
1850 } else {
1851 s.push_str(&format!(
1852 "\nNo Form 709 filing required based on per-donee totals \
1853 (each ≤ ${} exclusion). Total taxable gifts: $0.00.",
1854 fmt_money(excl)
1855 ));
1856 }
1857 }
1858
1859 if unlabeled_count > 0 {
1860 s.push_str(&format!(
1861 "\nNOTE: {unlabeled_count} gift(s) totalling ${} have no donee label — the §2503(b) \
1862 annual exclusion is PER DONEE and cannot be applied without one; label them via \
1863 `reconcile reclassify-outflow --donee`. Shown as a single conservative aggregate.",
1864 fmt_money(unlabeled_total)
1865 ));
1866 if unlabeled_total > excl {
1869 s.push_str(&format!(
1870 "\n Conservative aggregate ${} > ${} (one exclusion); \
1871 verify per-donee totals after labelling.",
1872 fmt_money(unlabeled_total),
1873 fmt_money(excl)
1874 ));
1875 } else {
1876 s.push_str(&format!(
1877 "\n Conservative aggregate ${} \u{2264} ${} (one exclusion); \
1878 per-donee exposure unverifiable without labels.",
1879 fmt_money(unlabeled_total),
1880 fmt_money(excl)
1881 ));
1882 }
1883 }
1884
1885 let lifetime_excl = t.gift_lifetime_exclusion;
1889 let cumulative_taxable = prior_taxable_gifts + total_taxable;
1890
1891 if cumulative_taxable > btctax_core::conventions::Usd::ZERO {
1893 let remaining = if cumulative_taxable >= lifetime_excl {
1894 btctax_core::conventions::Usd::ZERO
1895 } else {
1896 lifetime_excl - cumulative_taxable
1897 };
1898 s.push_str(&format!(
1899 "\n§2505 lifetime (basic) exclusion: you have used ${} of your ${} ({year}) lifetime \
1900 exclusion (${} remaining). No gift tax is DUE until cumulative taxable gifts exceed \
1901 the lifetime exclusion.",
1902 fmt_money(cumulative_taxable),
1903 fmt_money(lifetime_excl),
1904 fmt_money(remaining),
1905 ));
1906 if cumulative_taxable > lifetime_excl {
1908 let excess = cumulative_taxable - lifetime_excl;
1909 s.push_str(&format!(
1910 "\nlifetime exclusion EXCEEDED — gift tax may be due on ${} \
1911 (the excess base past the unified credit, not a computed tax); \
1912 consult a professional.",
1913 fmt_money(excess),
1914 ));
1915 }
1916 if unlabeled_count > 0 {
1919 s.push_str(&format!(
1920 "\n§2505 consumption reflects LABELED-donee taxable gifts only; \
1921 {unlabeled_count} unlabeled gift(s) totalling ${} are NOT included — \
1922 label them via `--donee` for a complete figure; consumption may be \
1923 understated / remaining overstated.",
1924 fmt_money(unlabeled_total),
1925 ));
1926 }
1927 }
1928
1929 s.push_str(
1932 "\nCaveats: §2513 gift-splitting (MFJ) not modeled (single-filer advisory only); \
1933 future-interest gifts (which require Form 709 filing regardless of amount) not \
1934 detectable; §2505 figures are advisory only — no portability/DSUE (§2010(c)(4)) \
1935 applied; prior cumulative taxable gifts are user-supplied (default $0 if \
1936 --prior-taxable-gifts not given).",
1937 );
1938
1939 Some(s)
1940}
1941
1942fn picks_str(picks: &[btctax_core::LotPick]) -> String {
1948 if picks.is_empty() {
1949 return "(none)".to_string();
1950 }
1951 picks
1952 .iter()
1953 .map(|p| {
1954 format!(
1955 "{}#{}:{}",
1956 p.lot.origin_event_id.canonical(),
1957 p.lot.split_sequence,
1958 p.sat
1959 )
1960 })
1961 .collect::<Vec<_>>()
1962 .join(", ")
1963}
1964
1965pub fn render_optimize_proposal(p: &btctax_core::OptimizeProposal) -> String {
1978 use btctax_core::{ApproxReason, Persistability};
1979 let mut s = String::new();
1980 let _ = writeln!(
1981 s,
1982 "Optimize (what-if) — tax year {} — NOTHING is filed or bound by running this.",
1983 p.year
1984 );
1985 if p.approximate {
1987 let why = match p.approx_reason {
1988 Some(ApproxReason::ComboCapExceeded { combos, cap }) => format!(
1989 "input exceeded the exhaustive bound ({combos} combos > {cap}); \
1990 a coordinate-descent fallback ran"
1991 ),
1992 Some(ApproxReason::ContentionUnenumerated { contended, .. }) => format!(
1993 "{contended} contended same-wallet disposal(s) could not be fully joint-enumerated"
1994 ),
1995 Some(ApproxReason::PoolHeuristic { lots, bound }) => format!(
1996 "a pool of {lots} lots exceeds the {bound}-lot exhaustive-enumeration bound; \
1997 only a deterministic heuristic SUBSET of that pool's identifications was searched"
1998 ),
1999 None => "approximate".to_string(),
2000 };
2001 let _ = writeln!(
2002 s,
2003 " \u{26a0} APPROXIMATE \u{2014} NOT a guaranteed global minimum: {why}."
2004 );
2005 let _ = writeln!(
2006 s,
2007 " The true least-tax assignment may be lower; this is a disclosed improvement over your"
2008 );
2009 let _ = writeln!(
2010 s,
2011 " current filing position (delta \u{2264} 0), NOT \u{2018}the least tax.\u{2019}"
2012 );
2013 }
2014 let _ = writeln!(
2015 s,
2016 " current federal tax (attributable): {}",
2017 p.baseline_tax
2018 );
2019 let _ = writeln!(
2020 s,
2021 " optimized federal tax (attributable): {}",
2022 p.optimized_tax
2023 );
2024 let _ = writeln!(
2025 s,
2026 " delta (optimized \u{2212} current): {} (negative = saving; always \u{2264} 0)",
2027 p.delta
2028 );
2029 for d in &p.per_disposal {
2030 let _ = writeln!(
2031 s,
2032 " {} @ {} [{}] :: {}",
2033 d.disposal.canonical(),
2034 d.date,
2035 wallet_label(&d.wallet),
2036 compliance_status_tag(&d.status)
2037 );
2038 if d.proposed_selection == d.current_selection {
2043 let _ = writeln!(
2044 s,
2045 " proposed: {} [no change \u{2014} already optimal under current identification]",
2046 picks_str(&d.proposed_selection)
2047 );
2048 continue;
2049 }
2050 let persist = match d.persistable {
2051 Persistability::ContemporaneousNow => {
2052 "persistable now (made \u{2264} sale \u{2192} Contemporaneous)"
2053 }
2054 Persistability::NeedsAttestation => {
2055 "already executed \u{2014} needs `optimize accept --disposal <ref> \
2056 --attest \"\u{2026}\"` (genuine contemporaneous ID only)"
2057 }
2058 Persistability::ForbiddenBroker2027 => {
2059 "2027+ broker-held \u{2014} CANNOT be persisted (own-books insufficient); \
2060 FIFO is the defensible position"
2061 }
2062 };
2063 let _ = writeln!(
2064 s,
2065 " proposed: {} [{}]",
2066 picks_str(&d.proposed_selection),
2067 persist
2068 );
2069 }
2070 let _ = writeln!(
2072 s,
2073 " (vertex-granularity identification: a multi-partial split landing exactly on a \
2074 tax-bracket kink is out of scope.)"
2075 );
2076 let _ = writeln!(
2077 s,
2078 " (this is the tax IF you had identified thus; adequate ID must exist by the time \
2079 of sale \u{2014} \u{a7}1.1012-1(j))"
2080 );
2081 let _ = writeln!(
2083 s,
2084 " (scope: global over taxable-disposal lot selections; self-transfer lot routing is \
2085 held at its baseline position and is not re-optimized.)"
2086 );
2087 s
2088}
2089
2090pub fn render_accept_outcome(o: &crate::cmd::optimize::AcceptOutcome) -> String {
2095 let mut s = String::new();
2096 let _ = writeln!(
2097 s,
2098 "Optimize accept \u{2014} {} persisted, {} skipped.",
2099 o.persisted.len(),
2100 o.skipped.len()
2101 );
2102 for (disposal, decision, basis) in &o.persisted {
2103 let _ = writeln!(
2104 s,
2105 " PERSISTED {} \u{2192} LotSelection {} [{}]{}",
2106 disposal.canonical(),
2107 decision.canonical(),
2108 basis,
2109 if *basis == "AttestedRecording" {
2110 " (+ attestation recorded; revoke with `reconcile void`)"
2111 } else {
2112 " (revoke with `reconcile void`)"
2113 }
2114 );
2115 }
2116 for (disposal, reason) in &o.skipped {
2117 let _ = writeln!(s, " skipped {}: {}", disposal.canonical(), reason);
2118 }
2119 if o.persisted.is_empty() && o.skipped.is_empty() {
2120 let _ = writeln!(s, " (no disposals matched)");
2121 }
2122 s
2123}
2124
2125pub fn render_consult(r: &btctax_core::ConsultReport) -> String {
2135 let mut s = String::new();
2136 let _ = writeln!(
2137 s,
2138 "Consult (read-only what-if): sell {} sat from {} on {}",
2139 r.req.sell_sat,
2140 wallet_label(&r.req.wallet),
2141 r.req.at
2142 );
2143 if r.approximate {
2145 let _ = writeln!(
2146 s,
2147 " \u{26a0} heuristic \u{2014} searched a subset of a large (>12-lot) pool; \
2148 the proposed selection may not be the exact minimum."
2149 );
2150 }
2151 let _ = writeln!(
2152 s,
2153 " proposed selection: {}",
2154 picks_str(&r.proposed_selection)
2155 );
2156 let _ = writeln!(
2157 s,
2158 " short-term gain: {} long-term gain: {}",
2159 r.st_gain, r.lt_gain
2160 );
2161 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
2164 let _ = writeln!(
2165 s,
2166 " whole-year federal tax attributable (with this sale): {}",
2167 r.total_federal_tax_attributable
2168 );
2169 if let Some(t) = &r.timing {
2170 let _ = writeln!(
2171 s,
2172 " timing: {} sat of the best selection is short-term until {}; \
2173 selling on/after then would be taxed long-term, a \u{2248} {} difference.",
2174 t.st_sat_in_selection, t.latest_crossover, t.saving_if_waited
2175 );
2176 }
2177 let _ = writeln!(
2178 s,
2179 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
2180 not investment advice (no buy/sell/hold recommendation)."
2181 );
2182 s
2183}
2184
2185fn ltcg_bracket_label(b: LtcgBracket) -> &'static str {
2187 match b {
2188 LtcgBracket::Zero => "0%",
2189 LtcgBracket::Fifteen => "15%",
2190 LtcgBracket::Twenty => "20%",
2191 }
2192}
2193
2194pub fn render_whatif_sell(r: &SellReport, magi_caveat: bool) -> String {
2200 let mut s = String::new();
2201 let _ = writeln!(
2202 s,
2203 "What-if (read-only): sell {} sat from {} on {}",
2204 r.req.sell_sat,
2205 wallet_label(&r.req.wallet),
2206 r.req.at
2207 );
2208 if magi_caveat {
2209 let _ = writeln!(
2210 s,
2211 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
2212 );
2213 }
2214 let _ = writeln!(s, " proceeds: {}", r.proceeds);
2215 let _ = writeln!(s, " lots consumed:");
2216 for leg in &r.lots {
2217 let term = match leg.term {
2218 Term::ShortTerm => "ST",
2219 Term::LongTerm => "LT",
2220 };
2221 let _ = writeln!(
2222 s,
2223 " {}#{} {} sat basis {} {} \u{2192} {} {} gain {}",
2224 leg.lot_id.origin_event_id.canonical(),
2225 leg.lot_id.split_sequence,
2226 leg.sat,
2227 leg.basis,
2228 leg.acquired_at,
2229 leg.sold_at,
2230 term,
2231 leg.gain
2232 );
2233 }
2234 let _ = writeln!(
2235 s,
2236 " short-term gain: {} long-term gain: {}",
2237 r.st_gain, r.lt_gain
2238 );
2239 match r.bracket_room {
2241 Some(room) => {
2242 let _ = writeln!(
2243 s,
2244 " \u{00a7}1(h) LTCG bracket: {} (room {} before the next breakpoint)",
2245 ltcg_bracket_label(r.bracket),
2246 room
2247 );
2248 }
2249 None => {
2250 let _ = writeln!(
2251 s,
2252 " \u{00a7}1(h) LTCG bracket: {} (top bracket \u{2014} no headroom)",
2253 ltcg_bracket_label(r.bracket)
2254 );
2255 }
2256 }
2257 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
2259 match r.effective_rate {
2260 Some(rate) => {
2261 let _ = writeln!(s, " effective rate: {rate}");
2262 }
2263 None => {
2264 let _ = writeln!(
2265 s,
2266 " effective rate: n/a (a loss/zero-gain sale \u{2014} its value is the carryforward, \
2267 not this-year tax)"
2268 );
2269 }
2270 }
2271 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2274 if carried != Usd::ZERO || r.ordinary_offset_delta != Usd::ZERO {
2275 let _ = writeln!(
2276 s,
2277 " \u{00a7}1212: {} offsets ordinary income this year, {} carried to next year \
2278 (short {} / long {})",
2279 r.ordinary_offset_delta, carried, r.carryforward_delta.short, r.carryforward_delta.long
2280 );
2281 }
2282 if r.niit_applies {
2284 let dir = if r.niit_incremental < Usd::ZERO {
2285 "decrease"
2286 } else {
2287 "increase"
2288 };
2289 let _ = writeln!(
2290 s,
2291 " \u{00a7}1411 NIIT: {} ({dir}) attributable to this sale",
2292 r.niit_incremental
2293 );
2294 }
2295 let status = match r.status {
2296 SellStatus::Gain => "net gain",
2297 SellStatus::Loss => "net loss (the carryforward is the value \u{2014} not this-year tax)",
2298 };
2299 let _ = writeln!(s, " status: {status}");
2300 let _ = writeln!(
2301 s,
2302 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
2303 not investment advice (no buy/sell/hold recommendation)."
2304 );
2305 s
2306}
2307
2308fn harvest_target_label(t: &HarvestTarget) -> String {
2310 match t {
2311 HarvestTarget::ZeroLtcg => {
2312 "zero-ltcg (all gain in the \u{00a7}1(h) 0% bracket)".to_string()
2313 }
2314 HarvestTarget::FifteenLtcg => "fifteen-ltcg (stay at/under 15%)".to_string(),
2315 HarvestTarget::Gain(x) => format!("gain \u{2264} {x}"),
2316 HarvestTarget::Tax(x) => format!("marginal tax \u{2264} {x}"),
2317 }
2318}
2319
2320pub fn render_whatif_harvest(r: &HarvestReport, magi_caveat: bool) -> String {
2326 let mut s = String::new();
2327 let _ = writeln!(
2328 s,
2329 "What-if HARVEST (read-only): {} from {} on {}",
2330 harvest_target_label(&r.req.target),
2331 wallet_label(&r.req.wallet),
2332 r.req.at
2333 );
2334 if magi_caveat {
2335 let _ = writeln!(
2336 s,
2337 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
2338 );
2339 }
2340 let status = match &r.status {
2341 HarvestStatus::Found => "FOUND (the target binds)",
2342 HarvestStatus::NotBinding => "NOT BINDING (the whole position fits)",
2343 HarvestStatus::AlreadyBreached => "ALREADY BREACHED at N=0 (nothing can be harvested)",
2344 HarvestStatus::NoLots => "NO LOTS (nothing to harvest from that wallet)",
2345 HarvestStatus::ProceedsRequired => "PROCEEDS REQUIRED",
2346 HarvestStatus::PreTransitionYear => "PRE-2025 (a closed year, not a plan)",
2347 HarvestStatus::YearNotComputable(_) => "YEAR NOT COMPUTABLE",
2348 };
2349 let _ = writeln!(s, " status: {status}");
2350 let _ = writeln!(s, " \u{2192} sell up to {} BTC ({} sat)", r.n_btc, r.n_sat);
2351 let _ = writeln!(s, " bound by: {}", r.binding_constraint);
2352 let _ = writeln!(
2353 s,
2354 " realized gain at N*: short-term {} long-term {}",
2355 r.st_gain, r.lt_gain
2356 );
2357 let ps = &r.with_result.pref_split;
2359 let bracket = if ps.at_20 > Usd::ZERO {
2360 "20%"
2361 } else if ps.at_15 > Usd::ZERO {
2362 "15%"
2363 } else {
2364 "0%"
2365 };
2366 let _ = writeln!(
2367 s,
2368 " \u{00a7}1(h) preferential dollars at N*: {} in 0% / {} in 15% / {} in 20% (top bracket: {})",
2369 ps.at_0, ps.at_15, ps.at_20, bracket
2370 );
2371 let _ = writeln!(s, " marginal federal tax at N*: {}", r.marginal_tax);
2372 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2374 if carried != Usd::ZERO {
2375 let dir = if carried < Usd::ZERO {
2376 "burned (spent)"
2377 } else {
2378 "carried to next year"
2379 };
2380 let _ = writeln!(
2381 s,
2382 " \u{00a7}1212 carryforward {}: {} (short {} / long {})",
2383 dir, carried, r.carryforward_delta.short, r.carryforward_delta.long
2384 );
2385 }
2386 if r.niit_applies {
2388 let dir = if r.niit_incremental < Usd::ZERO {
2389 "decrease"
2390 } else {
2391 "increase"
2392 };
2393 let _ = writeln!(
2394 s,
2395 " \u{00a7}1411 NIIT: {} ({dir}) at N* \u{2014} the +3.8% kink applies even inside a 0%/15% bracket answer",
2396 r.niit_incremental
2397 );
2398 }
2399 if let Some(note) = &r.plateau_note {
2400 let _ = writeln!(s, " \u{2139} {note}");
2401 }
2402 let _ = writeln!(
2403 s,
2404 "Tax decision-support only \u{2014} the engine-verified consequences of a contemplated harvest; \
2405 not investment advice (no buy/sell/hold recommendation)."
2406 );
2407 s
2408}
2409
2410pub fn render_verify(r: &VerifyReport) -> String {
2411 let mut out = String::new();
2412 let c = &r.conservation;
2413 let _ = writeln!(
2414 out,
2415 "Conservation (FR9): {}",
2416 if c.balanced { "BALANCED" } else { "DRIFT" }
2417 );
2418 let _ = writeln!(
2419 out,
2420 " in {} = disposed {} + removed {} + held {} + fee-sats {} + pending {}{}",
2421 c.sigma_in,
2422 c.sigma_disposed,
2423 c.sigma_removed,
2424 c.sigma_held,
2425 c.sigma_fee_sats,
2426 c.sigma_pending,
2427 if c.has_uncovered {
2428 " [identity undefined: uncovered disposal open]"
2429 } else {
2430 ""
2431 }
2432 );
2433 let _ = writeln!(out, "2025 transition: {}", r.safe_harbor);
2434 let _ = writeln!(
2435 out,
2436 "Pending reconciliation: {} transfer(s); unknown-basis inbounds: {}",
2437 r.pending, r.unknown_basis_inbounds
2438 );
2439
2440 let _ = writeln!(
2441 out,
2442 "Hard blockers (gate tax computation): {}",
2443 r.hard.len()
2444 );
2445 for b in &r.hard {
2446 let evt = b
2447 .event
2448 .as_ref()
2449 .map(|e| e.canonical())
2450 .unwrap_or_else(|| "-".to_string());
2451 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2452 if b.kind == BlockerKind::FmvMissing {
2455 let _ = writeln!(out, " ↳ {}", crate::price_cache::UPDATE_PRICES_HINT);
2456 }
2457 }
2458 let _ = writeln!(out, "Advisory blockers: {}", r.advisory.len());
2459 for b in &r.advisory {
2460 let evt = b
2461 .event
2462 .as_ref()
2463 .map(|e| e.canonical())
2464 .unwrap_or_else(|| "-".to_string());
2465 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2466 }
2467 let _ = writeln!(
2468 out,
2469 "Pre-2025 method (attested historical fact): {} (attested: {})",
2470 lot_method_display(r.declared_pre2025_method),
2471 r.pre2025_method_attested
2472 );
2473 let _ = writeln!(
2474 out,
2475 "Standing orders (MethodElection): {}",
2476 r.elections.len()
2477 );
2478 for e in &r.elections {
2479 let _ = writeln!(
2480 out,
2481 " recorded {} effective {} -> {} [{}]",
2482 e.recorded,
2483 e.effective_from,
2484 lot_method_display(e.method),
2485 e.note
2486 );
2487 }
2488 let _ = writeln!(out, "Lot selections recorded: {}", r.selection_count);
2489 let _ = writeln!(
2490 out,
2491 "Per-disposal compliance (post-2025): {}",
2492 r.compliance.len()
2493 );
2494 for c in &r.compliance {
2495 let _ = writeln!(
2496 out,
2497 " {} @ {} :: {}",
2498 c.disposal.canonical(),
2499 c.date,
2500 compliance_status_tag(&c.status)
2501 );
2502 }
2503 out
2504}
2505
2506#[cfg(test)]
2507mod gift_advisory_tests {
2508 use super::*;
2514 use btctax_core::conventions::Usd;
2515 use btctax_core::{EventId, LotId, Removal, RemovalLeg, TaxTable};
2516 use rust_decimal_macros::dec;
2517 use std::collections::BTreeMap;
2518 use time::macros::date;
2519
2520 fn gift_removal(seq: u64, removed_at: TaxDate, fmv: Usd) -> Removal {
2522 Removal {
2523 event: EventId::decision(seq),
2524 kind: RemovalKind::Gift,
2525 removed_at,
2526 legs: vec![RemovalLeg {
2527 lot_id: LotId {
2528 origin_event_id: EventId::decision(seq),
2529 split_sequence: 0,
2530 },
2531 sat: 100,
2532 basis: dec!(0),
2533 fmv_at_transfer: fmv,
2534 term: Term::LongTerm,
2535 basis_source: BasisSource::ComputedFromCost,
2536 acquired_at: date!(2024 - 01 - 01),
2537 pseudo: false,
2538 }],
2539 appraisal_required: false,
2540 donor_acquired_at: None,
2541 claimed_deduction: None,
2542 donee: None,
2543 }
2544 }
2545 fn gift_removal_labeled(seq: u64, removed_at: TaxDate, fmv: Usd, label: &str) -> Removal {
2547 Removal {
2548 donee: Some(label.to_string()),
2549 ..gift_removal(seq, removed_at, fmv)
2550 }
2551 }
2552 fn state_with(removals: Vec<Removal>) -> LedgerState {
2553 LedgerState {
2554 removals,
2555 ..Default::default()
2556 }
2557 }
2558 fn tables_with(year: i32, excl: Usd) -> BTreeMap<i32, TaxTable> {
2562 tables_with_lifetime(year, excl, dec!(13_990_000))
2563 }
2564
2565 fn tables_with_lifetime(year: i32, excl: Usd, lifetime_excl: Usd) -> BTreeMap<i32, TaxTable> {
2567 let mut m = BTreeMap::new();
2568 m.insert(
2569 year,
2570 TaxTable {
2571 year,
2572 source: "TEST",
2573 ordinary: BTreeMap::new(),
2574 ltcg: BTreeMap::new(),
2575 gift_annual_exclusion: excl,
2576 ss_wage_base: dec!(176100),
2577 gift_lifetime_exclusion: lifetime_excl,
2578 },
2579 );
2580 m
2581 }
2582
2583 #[test]
2587 fn no_gifts_is_none() {
2588 let st = state_with(vec![]);
2589 let tables = tables_with(2025, dec!(19000));
2590 assert!(render_gift_advisory(&st, 2025, dec!(0), &tables).is_none());
2591 }
2592
2593 #[test]
2596 fn gifts_present_but_no_table_emits_note_not_none() {
2597 let st = state_with(vec![gift_removal(1, date!(2026 - 06 - 01), dec!(50000))]);
2599 let tables = tables_with(2025, dec!(19000));
2601 let msg =
2602 render_gift_advisory(&st, 2026, dec!(0), &tables).expect("note expected, not None");
2603 assert!(msg.contains("unavailable"), "{msg}");
2604 assert!(msg.contains("Form 709 exposure not evaluated"), "{msg}");
2605 assert!(
2606 msg.contains("50000.00"),
2607 "must record the gift total: {msg}"
2608 );
2609 }
2610
2611 #[test]
2617 fn labeled_donee_over_exclusion_emits_advisory() {
2618 let st = state_with(vec![gift_removal_labeled(
2619 1,
2620 date!(2025 - 06 - 01),
2621 dec!(20000),
2622 "Alice",
2623 )]);
2624 let tables = tables_with(2025, dec!(19000));
2625 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2626 assert!(msg.contains("20000.00"), "must show Alice's total: {msg}");
2627 assert!(msg.contains("19000.00"), "must show the exclusion: {msg}");
2628 assert!(
2629 msg.contains("Form 709 filing required"),
2630 "must flag filing required: {msg}"
2631 );
2632 assert!(msg.contains("Alice"), "must name Alice: {msg}");
2633 assert!(msg.contains("1000.00"), "taxable must be $1000.00: {msg}");
2635 assert!(
2637 !msg.contains("donee identity is not modeled"),
2638 "stale aggregate caveat must not appear: {msg}"
2639 );
2640 }
2641
2642 #[test]
2645 fn labeled_donee_under_exclusion_no_filing_required() {
2646 let st = state_with(vec![gift_removal_labeled(
2647 1,
2648 date!(2025 - 06 - 01),
2649 dec!(10000),
2650 "Alice",
2651 )]);
2652 let tables = tables_with(2025, dec!(19000));
2653 let msg =
2655 render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected, not None");
2656 assert!(
2657 msg.contains("No Form 709 filing required"),
2658 "must say no filing required: {msg}"
2659 );
2660 assert!(msg.contains("Alice"), "must mention Alice: {msg}");
2661 assert!(msg.contains("10000.00"), "must show Alice's total: {msg}");
2662 }
2663
2664 #[test]
2670 fn per_donee_under_exclusion_two_donees_no_filing_required() {
2671 let st = state_with(vec![
2672 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(15000), "Alice"),
2673 gift_removal_labeled(2, date!(2025 - 06 - 01), dec!(15000), "Bob"),
2674 ]);
2675 let tables = tables_with(2025, dec!(19000));
2676 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2677 assert!(
2679 msg.contains("No Form 709 filing required"),
2680 "neither donee exceeds exclusion → no filing required: {msg}"
2681 );
2682 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2684 assert!(msg.contains("Bob"), "Bob must appear: {msg}");
2685 assert!(msg.contains("15000.00"), "donee total must appear: {msg}");
2687 assert!(
2689 !msg.contains("Form 709 filing required (donee(s):"),
2690 "filing trigger must NOT fire: {msg}"
2691 );
2692 assert!(
2694 msg.contains("Total taxable gifts: $0.00"),
2695 "total taxable must be $0.00: {msg}"
2696 );
2697 }
2698
2699 #[test]
2702 fn one_donee_over_exclusion_filing_required() {
2703 let st = state_with(vec![gift_removal_labeled(
2704 1,
2705 date!(2025 - 06 - 01),
2706 dec!(25000),
2707 "Alice",
2708 )]);
2709 let tables = tables_with(2025, dec!(19000));
2710 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2711 assert!(
2712 msg.contains("Form 709 filing required (donee(s): Alice)"),
2713 "must trigger filing required for Alice: {msg}"
2714 );
2715 assert!(msg.contains("25000.00"), "Alice total must appear: {msg}");
2716 assert!(
2717 msg.contains("19000.00"),
2718 "exclusion applied must appear: {msg}"
2719 );
2720 assert!(
2722 msg.contains("6000.00"),
2723 "taxable $6,000.00 must appear: {msg}"
2724 );
2725 }
2726
2727 #[test]
2730 fn unlabeled_bucket_caveat_with_conservative_aggregate() {
2731 let st = state_with(vec![gift_removal(1, date!(2025 - 06 - 01), dec!(30000))]);
2732 let tables = tables_with(2025, dec!(19000));
2733 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2734 assert!(
2736 msg.contains("no donee label"),
2737 "unlabeled caveat must appear: {msg}"
2738 );
2739 assert!(
2740 msg.contains("30000.00"),
2741 "unlabeled total must appear: {msg}"
2742 );
2743 assert!(
2745 msg.contains("Conservative aggregate"),
2746 "conservative aggregate signal must appear: {msg}"
2747 );
2748 assert!(
2749 msg.contains("19000.00"),
2750 "one-exclusion comparison must appear: {msg}"
2751 );
2752 assert!(
2754 !msg.contains("Form 709 filing required (donee(s):"),
2755 "labeled filing trigger must NOT fire for unlabeled gifts: {msg}"
2756 );
2757 }
2758
2759 #[test]
2762 fn mixed_labeled_over_and_unlabeled_shows_both() {
2763 let st = state_with(vec![
2764 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(25000), "Alice"),
2765 gift_removal(2, date!(2025 - 06 - 01), dec!(5000)), ]);
2767 let tables = tables_with(2025, dec!(19000));
2768 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2769 assert!(
2771 msg.contains("Form 709 filing required"),
2772 "filing required for Alice: {msg}"
2773 );
2774 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2775 assert!(
2777 msg.contains("no donee label"),
2778 "unlabeled caveat must appear: {msg}"
2779 );
2780 assert!(
2781 msg.contains("5000.00"),
2782 "unlabeled total must appear: {msg}"
2783 );
2784 }
2785
2786 #[test]
2789 fn donations_excluded_from_form709_advisory() {
2790 let donation_removal = Removal {
2791 event: EventId::decision(1),
2792 kind: RemovalKind::Donation,
2793 removed_at: date!(2025 - 06 - 01),
2794 legs: vec![RemovalLeg {
2795 lot_id: LotId {
2796 origin_event_id: EventId::decision(1),
2797 split_sequence: 0,
2798 },
2799 sat: 100,
2800 basis: dec!(0),
2801 fmv_at_transfer: dec!(50000), term: Term::LongTerm,
2803 basis_source: BasisSource::ComputedFromCost,
2804 acquired_at: date!(2024 - 01 - 01),
2805 pseudo: false,
2806 }],
2807 appraisal_required: false,
2808 donor_acquired_at: None,
2809 claimed_deduction: Some(dec!(50000)),
2810 donee: Some("Charity X".to_string()),
2811 };
2812 let st = state_with(vec![donation_removal]);
2813 let tables = tables_with(2025, dec!(19000));
2814 assert!(
2816 render_gift_advisory(&st, 2025, dec!(0), &tables).is_none(),
2817 "Donation must be excluded from the Form 709 advisory"
2818 );
2819 }
2820
2821 #[test]
2827 fn section_2505_under_lifetime_shows_used_and_remaining() {
2828 let st = state_with(vec![gift_removal_labeled(
2829 1,
2830 date!(2025 - 06 - 01),
2831 dec!(100000),
2832 "Alice",
2833 )]);
2834 let tables = tables_with(2025, dec!(19000)); let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2836 assert!(
2838 msg.contains("81000.00"),
2839 "taxable $81,000 must appear: {msg}"
2840 );
2841 assert!(
2843 msg.contains("§2505 lifetime (basic) exclusion"),
2844 "§2505 block must appear: {msg}"
2845 );
2846 assert!(
2847 msg.contains("13990000.00"),
2848 "lifetime exclusion $13,990,000 must appear: {msg}"
2849 );
2850 assert!(
2852 msg.contains("13909000.00"),
2853 "remaining $13,909,000 must appear: {msg}"
2854 );
2855 assert!(
2857 !msg.contains("EXCEEDED"),
2858 "must NOT say EXCEEDED when under limit: {msg}"
2859 );
2860 assert!(
2862 !msg.contains("later chunk (Chunk 3)"),
2863 "stale Chunk-3 caveat must be absent: {msg}"
2864 );
2865 }
2866
2867 #[test]
2870 fn section_2505_prior_gifts_accumulate() {
2871 let st = state_with(vec![gift_removal_labeled(
2872 1,
2873 date!(2025 - 06 - 01),
2874 dec!(100000),
2875 "Alice",
2876 )]);
2877 let tables = tables_with(2025, dec!(19000));
2878 let msg =
2879 render_gift_advisory(&st, 2025, dec!(13_900_000), &tables).expect("advisory expected");
2880 assert!(
2882 msg.contains("13981000.00"),
2883 "cumulative $13,981,000 must appear: {msg}"
2884 );
2885 assert!(
2887 msg.contains("9000.00"),
2888 "remaining $9,000 must appear: {msg}"
2889 );
2890 assert!(
2891 !msg.contains("EXCEEDED"),
2892 "must NOT say EXCEEDED when under limit: {msg}"
2893 );
2894 }
2895
2896 #[test]
2900 fn section_2505_exceeds_lifetime_shows_exceeded_and_excess() {
2901 let st = state_with(vec![gift_removal_labeled(
2902 1,
2903 date!(2025 - 06 - 01),
2904 dec!(100000),
2905 "Alice",
2906 )]);
2907 let tables = tables_with(2025, dec!(19000));
2908 let msg =
2909 render_gift_advisory(&st, 2025, dec!(13_950_000), &tables).expect("advisory expected");
2910 assert!(
2911 msg.contains("14031000.00"),
2912 "cumulative $14,031,000 must appear: {msg}"
2913 );
2914 assert!(
2915 msg.contains("EXCEEDED"),
2916 "must say EXCEEDED when over lifetime limit: {msg}"
2917 );
2918 assert!(
2920 msg.contains("41000.00"),
2921 "excess $41,000 must appear: {msg}"
2922 );
2923 }
2924
2925 #[test]
2929 fn section_2505_exact_boundary_remaining_zero_not_exceeded() {
2930 let st = state_with(vec![gift_removal_labeled(
2931 1,
2932 date!(2025 - 06 - 01),
2933 dec!(100000),
2934 "Alice",
2935 )]);
2936 let tables = tables_with(2025, dec!(19000));
2937 let msg =
2939 render_gift_advisory(&st, 2025, dec!(13_909_000), &tables).expect("advisory expected");
2940 assert!(
2941 msg.contains("13990000.00"),
2942 "cumulative $13,990,000 must appear: {msg}"
2943 );
2944 assert!(
2946 msg.contains("($0.00 remaining)"),
2947 "remaining $0.00 in exact phrasing '($0.00 remaining)' must appear: {msg}"
2948 );
2949 assert!(
2951 !msg.contains("EXCEEDED"),
2952 "must NOT say EXCEEDED at exactly the limit: {msg}"
2953 );
2954 }
2955
2956 #[test]
2960 fn section_2505_prior_only_block_shows_even_when_current_taxable_zero() {
2961 let st = state_with(vec![gift_removal_labeled(
2962 1,
2963 date!(2025 - 06 - 01),
2964 dec!(10000), "Alice",
2966 )]);
2967 let tables = tables_with(2025, dec!(19000));
2968 let msg =
2969 render_gift_advisory(&st, 2025, dec!(5_000_000), &tables).expect("advisory expected");
2970 assert!(
2972 msg.contains("5000000.00"),
2973 "cumulative $5,000,000 must appear: {msg}"
2974 );
2975 assert!(
2976 msg.contains("§2505 lifetime (basic) exclusion"),
2977 "§2505 block must appear for prior-only case: {msg}"
2978 );
2979 assert!(!msg.contains("EXCEEDED"), "must NOT say EXCEEDED: {msg}");
2980 }
2981
2982 #[test]
2985 fn section_2505_no_block_when_cumulative_zero() {
2986 let st = state_with(vec![gift_removal_labeled(
2987 1,
2988 date!(2025 - 06 - 01),
2989 dec!(10000), "Alice",
2991 )]);
2992 let tables = tables_with(2025, dec!(19000));
2993 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2994 assert!(
2995 !msg.contains("§2505 lifetime"),
2996 "§2505 block must NOT appear when cumulative = 0: {msg}"
2997 );
2998 }
2999
3000 #[test]
3002 fn section_2505_default_zero_prior_shows_caveats() {
3003 let st = state_with(vec![gift_removal_labeled(
3004 1,
3005 date!(2025 - 06 - 01),
3006 dec!(100000), "Alice",
3008 )]);
3009 let tables = tables_with(2025, dec!(19000));
3010 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3011 assert!(
3013 !msg.contains("later chunk (Chunk 3)"),
3014 "stale 'later chunk (Chunk 3)' must be absent: {msg}"
3015 );
3016 assert!(
3018 msg.contains("§2513 gift-splitting"),
3019 "§2513 caveat must be present: {msg}"
3020 );
3021 assert!(
3022 msg.contains("portability/DSUE"),
3023 "portability/DSUE caveat must be present: {msg}"
3024 );
3025 assert!(
3026 msg.contains("prior cumulative taxable gifts are user-supplied"),
3027 "prior-cumulative disclosure caveat must be present: {msg}"
3028 );
3029 }
3030
3031 #[test]
3034 fn section_2505_mixed_shows_omission_disclosure_for_unlabeled() {
3035 let st = state_with(vec![
3036 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(100000), "Alice"),
3037 gift_removal(2, date!(2025 - 06 - 01), dec!(50000)), ]);
3039 let tables = tables_with(2025, dec!(19000));
3040 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3041 assert!(
3043 msg.contains("§2505 lifetime (basic) exclusion"),
3044 "§2505 block must appear: {msg}"
3045 );
3046 assert!(
3047 msg.contains("81000.00"),
3048 "used $81,000 (labeled only) must appear: {msg}"
3049 );
3050 assert!(
3052 msg.contains("§2505 consumption reflects LABELED-donee taxable gifts only"),
3053 "omission disclosure must appear: {msg}"
3054 );
3055 assert!(
3056 msg.contains("50000.00"),
3057 "unlabeled total $50,000 must appear in omission disclosure: {msg}"
3058 );
3059 assert!(
3060 msg.contains("consumption may be understated"),
3061 "under-stated warning must appear: {msg}"
3062 );
3063 }
3064
3065 #[test]
3067 fn section_2505_stale_chunk3_caveat_is_absent() {
3068 let st = state_with(vec![gift_removal_labeled(
3069 1,
3070 date!(2025 - 06 - 01),
3071 dec!(20000),
3072 "Alice",
3073 )]);
3074 let tables = tables_with(2025, dec!(19000));
3075 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
3076 assert!(
3077 !msg.contains("later chunk (Chunk 3)"),
3078 "stale Chunk-3 caveat must be absent: {msg}"
3079 );
3080 assert!(
3081 !msg.contains("§2505 lifetime exemption is a later chunk"),
3082 "stale §2505 future-chunk phrase must be absent: {msg}"
3083 );
3084 }
3085}
3086
3087#[cfg(test)]
3088mod schedule_se_tests {
3089 use super::*;
3093 use rust_decimal_macros::dec;
3094
3095 fn golden1() -> SeTaxResult {
3097 SeTaxResult {
3098 net_se: dec!(100000),
3099 base: dec!(92350.00),
3100 ss: dec!(11451.40),
3101 medicare: dec!(2678.15),
3102 addl: dec!(0.00),
3103 total: dec!(14129.55),
3104 deductible_half: dec!(7064.78),
3105 }
3106 }
3107
3108 fn w2_headline() -> SeTaxResult {
3110 SeTaxResult {
3111 net_se: dec!(100000),
3112 base: dec!(92350.00),
3113 ss: dec!(3236.40),
3114 medicare: dec!(2678.15),
3115 addl: dec!(381.15),
3116 total: dec!(6295.70),
3117 deductible_half: dec!(2957.28),
3118 }
3119 }
3120
3121 fn w2_asymmetric() -> SeTaxResult {
3123 SeTaxResult {
3124 net_se: dec!(100000),
3125 base: dec!(92350.00),
3126 ss: dec!(3236.40),
3127 medicare: dec!(2678.15),
3128 addl: dec!(0.00),
3129 total: dec!(5914.55),
3130 deductible_half: dec!(2957.28),
3131 }
3132 }
3133
3134 fn expenses_headline() -> SeTaxResult {
3139 SeTaxResult {
3140 net_se: dec!(80000),
3141 base: dec!(73880.00),
3142 ss: dec!(9161.12),
3143 medicare: dec!(2142.52),
3144 addl: dec!(0.00),
3145 total: dec!(11303.64),
3146 deductible_half: dec!(5651.82),
3147 }
3148 }
3149
3150 fn expenses_w2_combined() -> SeTaxResult {
3160 SeTaxResult {
3161 net_se: dec!(80000),
3162 base: dec!(73880.00),
3163 ss: dec!(3236.40),
3164 medicare: dec!(2142.52),
3165 addl: dec!(214.92),
3166 total: dec!(5593.84),
3167 deductible_half: dec!(2689.46),
3168 }
3169 }
3170
3171 #[test]
3175 fn business_mining_year_renders_full_section() {
3176 let r = golden1();
3177 let s = render_schedule_se(
3178 2025,
3179 Some(&r),
3180 dec!(100000),
3181 true,
3182 Usd::ZERO,
3183 Usd::ZERO,
3184 Usd::ZERO,
3185 )
3186 .expect("SE section expected");
3187 assert!(s.contains("92350.00"), "net SE earnings base: {s}");
3189 assert!(s.contains("11451.40"), "SS component: {s}");
3190 assert!(s.contains("2678.15"), "Medicare component: {s}");
3191 assert!(s.contains("14129.55"), "total SE tax: {s}");
3192 assert!(s.contains("7064.78"), "§164(f) deductible half: {s}");
3193 assert!(
3194 s.contains("Additional Medicare"),
3195 "addl component labeled: {s}"
3196 );
3197 assert!(
3199 s.contains("$0 W-2 wages"),
3200 "short $0-W-2 note must appear (both W-2 = 0): {s}"
3201 );
3202 assert!(
3203 s.contains("--w2-ss-wages"),
3204 "$0 note must mention --w2-ss-wages flag: {s}"
3205 );
3206 assert!(
3207 !s.contains("OVERSTATED"),
3208 "old OVERSTATED text must be absent (Chunk A regression): {s}"
3209 );
3210 assert!(
3211 !s.contains("UNDERSTATED"),
3212 "old UNDERSTATED text must be absent (Chunk A regression): {s}"
3213 );
3214 assert!(
3216 s.contains("NOT auto-coordinated"),
3217 "§164(f) advisory must appear: {s}"
3218 );
3219 assert!(
3220 s.contains("coordinate it on your actual return"),
3221 "§164(f) advisory must include coordination instruction: {s}"
3222 );
3223 assert!(
3225 s.contains("SEPARATE federal liability"),
3226 "standalone note: {s}"
3227 );
3228 assert!(
3229 s.contains("not") && s.contains("§164(f)"),
3230 "notes §164(f) not auto-coordinated: {s}"
3231 );
3232 assert!(
3234 s.contains("no Schedule C expenses supplied"),
3235 "Chunk B $0-expenses note must appear: {s}"
3236 );
3237 assert!(
3238 s.contains("--schedule-c-expenses"),
3239 "$0 note must mention --schedule-c-expenses flag: {s}"
3240 );
3241 assert!(
3242 !s.contains("not modeled"),
3243 "old 'not modeled' caveat must be absent (replaced by Chunk B): {s}"
3244 );
3245 }
3246
3247 #[test]
3249 fn w2_set_renders_coordinated_disclosure() {
3250 let r = w2_headline();
3251 let s = render_schedule_se(
3252 2025,
3253 Some(&r),
3254 dec!(100000),
3255 true,
3256 Usd::ZERO,
3257 dec!(150000),
3258 dec!(150000),
3259 )
3260 .expect("SE section expected");
3261 assert!(
3263 s.contains("W-2 coordination applied"),
3264 "coordinated disclosure must appear: {s}"
3265 );
3266 assert!(
3267 s.contains("§1401(b)(2)(B)"),
3268 "must cite §1401(b)(2)(B): {s}"
3269 );
3270 assert!(
3271 s.contains("Form 8959 Part II"),
3272 "must cite Form 8959 Part II: {s}"
3273 );
3274 assert!(s.contains("150000"), "w2_ss_wages amount must appear: {s}");
3276 assert!(!s.contains("OVERSTATED"), "OVERSTATED must be absent: {s}");
3278 assert!(
3279 !s.contains("UNDERSTATED"),
3280 "UNDERSTATED must be absent: {s}"
3281 );
3282 assert!(s.contains("3236.40"), "reduced SS component: {s}");
3284 assert!(s.contains("381.15"), "non-zero addl: {s}");
3285 assert!(s.contains("6295.70"), "reduced total: {s}");
3286 assert!(s.contains("2957.28"), "deductible_half: {s}");
3287 }
3288
3289 #[test]
3293 fn w2_asymmetric_render_transposition_guard() {
3294 let r = w2_asymmetric();
3295 let s = render_schedule_se(
3296 2025,
3297 Some(&r),
3298 dec!(100000),
3299 true,
3300 Usd::ZERO,
3301 dec!(150000),
3302 Usd::ZERO,
3303 )
3304 .expect("SE section expected");
3305 assert!(
3307 s.contains("W-2 coordination applied"),
3308 "coordinated disclosure must appear: {s}"
3309 );
3310 assert!(s.contains("3236.40"), "ss must be 3236.40 (reduced): {s}");
3312 assert!(
3313 s.contains("0.00"),
3314 "addl must be 0.00 (threshold un-reduced): {s}"
3315 );
3316 assert!(!s.contains("OVERSTATED"), "{s}");
3318 assert!(!s.contains("UNDERSTATED"), "{s}");
3319 }
3320
3321 #[test]
3323 fn no_business_income_no_section() {
3324 assert!(
3325 render_schedule_se(2025, None, Usd::ZERO, true, Usd::ZERO, Usd::ZERO, Usd::ZERO)
3326 .is_none()
3327 );
3328 }
3329
3330 #[test]
3333 fn business_income_but_no_table_emits_note() {
3334 let s = render_schedule_se(
3335 2099,
3336 None,
3337 dec!(100000),
3338 false,
3339 Usd::ZERO,
3340 Usd::ZERO,
3341 Usd::ZERO,
3342 )
3343 .expect("wage-base-unavailable note expected");
3344 assert!(s.contains("SS wage base unavailable"), "{s}");
3345 assert!(s.contains("2099"), "names the year: {s}");
3346 assert!(s.contains("no silent drop"), "{s}");
3347 }
3348
3349 #[test]
3354 fn expenses_20k_no_w2_renders_breakout_and_advisory() {
3355 let r = expenses_headline(); let s = render_schedule_se(
3357 2025,
3358 Some(&r),
3359 dec!(100000), true,
3361 dec!(20000), Usd::ZERO,
3363 Usd::ZERO,
3364 )
3365 .expect("SE section expected");
3366 assert!(
3368 s.contains("gross business income"),
3369 "breakout line must appear: {s}"
3370 );
3371 assert!(
3372 s.contains("100000.00"),
3373 "gross ($100k) must appear in breakout: {s}"
3374 );
3375 assert!(
3376 s.contains("20000.00"),
3377 "expenses ($20k) must appear in breakout: {s}"
3378 );
3379 assert!(
3380 s.contains("80000.00"),
3381 "net_se ($80k) must appear in breakout: {s}"
3382 );
3383 assert!(
3385 s.contains("OVERSTATES"),
3386 "Schedule C advisory OVERSTATES text: {s}"
3387 );
3388 assert!(
3389 s.contains("ORDINARY taxable income"),
3390 "advisory must mention ORDINARY taxable income: {s}"
3391 );
3392 assert!(
3393 s.contains("engine-side coordination is deferred"),
3394 "advisory must mention deferred coordination: {s}"
3395 );
3396 assert!(
3398 !s.contains("reduce your ordinary_taxable_income"),
3399 "NO OTI-edit prescription allowed (spec D3): {s}"
3400 );
3401 assert!(
3402 !s.contains("set --ordinary-taxable-income"),
3403 "NO OTI-edit prescription allowed (spec D3): {s}"
3404 );
3405 assert!(s.contains("73880.00"), "base $73,880: {s}");
3407 assert!(s.contains("9161.12"), "ss $9,161.12: {s}");
3408 assert!(s.contains("2142.52"), "medicare $2,142.52: {s}");
3409 assert!(s.contains("11303.64"), "total $11,303.64: {s}");
3410 assert!(s.contains("5651.82"), "deductible_half $5,651.82: {s}");
3411 assert!(
3413 !s.contains("not modeled"),
3414 "old 'not modeled' caveat must be absent: {s}"
3415 );
3416 }
3417
3418 #[test]
3421 fn fully_expensed_shows_new_line_not_wage_base_note() {
3422 let s = render_schedule_se(
3425 2025,
3426 None,
3427 dec!(10000), true, dec!(15000), Usd::ZERO,
3431 Usd::ZERO,
3432 )
3433 .expect("fully-expensed section expected (not None)");
3434 assert!(
3436 s.contains("fully expensed"),
3437 "fully-expensed line must appear: {s}"
3438 );
3439 assert!(
3440 s.contains("10000.00"),
3441 "gross $10k must appear in fully-expensed line: {s}"
3442 );
3443 assert!(
3444 s.contains("15000.00"),
3445 "expenses $15k must appear in fully-expensed line: {s}"
3446 );
3447 assert!(
3448 s.contains("no §1401 SE tax"),
3449 "must state no SE tax owed: {s}"
3450 );
3451 assert!(s.contains("2025"), "must name the year: {s}");
3452 assert!(
3454 !s.contains("SS wage base unavailable"),
3455 "wage-base-unavailable note must be ABSENT for fully-expensed case: {s}"
3456 );
3457 }
3458
3459 #[test]
3461 fn expenses_w2_combined_renders_both() {
3462 let r = expenses_w2_combined(); let s = render_schedule_se(
3464 2025,
3465 Some(&r),
3466 dec!(100000),
3467 true,
3468 dec!(20000), dec!(150000), dec!(150000), )
3472 .expect("SE section expected");
3473 assert!(s.contains("gross business income"), "breakout line: {s}");
3475 assert!(s.contains("80000.00"), "net_se in breakout: {s}");
3476 assert!(s.contains("OVERSTATES"), "Schedule C advisory: {s}");
3478 assert!(
3480 s.contains("W-2 coordination applied"),
3481 "W-2 coordination: {s}"
3482 );
3483 assert!(s.contains("73880.00"), "base: {s}");
3485 assert!(s.contains("3236.40"), "ss (reduced by W-2 cap): {s}");
3486 assert!(s.contains("2142.52"), "medicare: {s}");
3487 assert!(s.contains("214.92"), "addl: {s}");
3488 assert!(s.contains("5593.84"), "total: {s}");
3489 assert!(s.contains("2689.46"), "deductible_half: {s}");
3490 }
3491
3492 #[test]
3494 fn schedule_se_csv_columns_and_values() {
3495 let dir = tempfile::tempdir().unwrap();
3496 let out = dir.path().join("export");
3497 let st = LedgerState::default();
3498 let r = golden1();
3499 write_csv_exports(&out, &st, Some(2025), Some(&r), &BTreeMap::new()).unwrap();
3500
3501 let path = out.join("schedule_se.csv");
3502 assert!(path.exists(), "schedule_se.csv must be written");
3503 let mut rdr = csv::Reader::from_reader(std::fs::File::open(&path).unwrap());
3504 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3505 assert_eq!(
3506 headers,
3507 vec![
3508 "net_se_earnings",
3509 "se_base_9235",
3510 "ss_component",
3511 "medicare_component",
3512 "additional_medicare_component",
3513 "total_se_tax",
3514 "deductible_half",
3515 ]
3516 );
3517 let rec = rdr
3518 .records()
3519 .next()
3520 .expect("one data row")
3521 .expect("readable");
3522 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"); }
3530
3531 #[test]
3533 fn schedule_se_csv_omitted_when_no_se_tax() {
3534 let dir = tempfile::tempdir().unwrap();
3535 let out = dir.path().join("export");
3536 let st = LedgerState::default();
3537 write_csv_exports(&out, &st, Some(2025), None, &BTreeMap::new()).unwrap();
3538 assert!(!out.join("schedule_se.csv").exists());
3539 }
3540}
3541
3542#[cfg(test)]
3543mod form8283_csv_tests {
3544 use super::*;
3547
3548 #[test]
3550 fn form8283_csv_detail_columns_present_and_empty() {
3551 use btctax_core::{
3552 BasisSource, DonationDetails, EventId, LedgerState, Removal, RemovalKind, RemovalLeg,
3553 Term,
3554 };
3555 use time::macros::date;
3556
3557 let dir = tempfile::tempdir().unwrap();
3558 let out = dir.path().join("export");
3559
3560 let event = EventId::decision(99);
3562 let leg = RemovalLeg {
3563 lot_id: btctax_core::LotId {
3564 origin_event_id: event.clone(),
3565 split_sequence: 0,
3566 },
3567 sat: 100_000_000,
3568 basis: rust_decimal::Decimal::ZERO,
3569 fmv_at_transfer: rust_decimal::Decimal::from(52000),
3570 term: Term::LongTerm,
3571 basis_source: BasisSource::ComputedFromCost,
3572 acquired_at: date!(2025 - 01 - 01),
3573 pseudo: false,
3574 };
3575 let removal = Removal {
3576 event: event.clone(),
3577 kind: RemovalKind::Donation,
3578 removed_at: date!(2025 - 03 - 01),
3579 legs: vec![leg],
3580 appraisal_required: false,
3581 donor_acquired_at: None,
3582 claimed_deduction: Some(rust_decimal::Decimal::from(52000)),
3583 donee: Some("Test Charity Two".into()),
3584 };
3585 let event2 = EventId::decision(100);
3587 let leg2 = RemovalLeg {
3588 lot_id: btctax_core::LotId {
3589 origin_event_id: event2.clone(),
3590 split_sequence: 0,
3591 },
3592 sat: 10_000_000,
3593 basis: rust_decimal::Decimal::ZERO,
3594 fmv_at_transfer: rust_decimal::Decimal::from(8000),
3595 term: Term::LongTerm,
3596 basis_source: BasisSource::ComputedFromCost,
3597 acquired_at: date!(2025 - 01 - 15),
3598 pseudo: false,
3599 };
3600 let removal2 = Removal {
3601 event: event2.clone(),
3602 kind: RemovalKind::Donation,
3603 removed_at: date!(2025 - 05 - 01),
3604 legs: vec![leg2],
3605 appraisal_required: false,
3606 donor_acquired_at: None,
3607 claimed_deduction: Some(rust_decimal::Decimal::from(8000)),
3608 donee: Some("No Details Org".into()),
3609 };
3610
3611 let st = LedgerState {
3612 removals: vec![removal, removal2],
3613 ..Default::default()
3614 };
3615
3616 let mut dmap: BTreeMap<EventId, DonationDetails> = BTreeMap::new();
3617 dmap.insert(
3618 event,
3619 DonationDetails {
3620 donee_name: "Test Charity".into(),
3621 donee_ein: Some("12-3456789".into()),
3622 donee_address: Some("123 Main".into()),
3623 appraiser_name: "Test Appraiser".into(),
3624 appraiser_tin: Some("987654321".into()),
3625 appraiser_ptin: Some("P01234567".into()),
3626 appraiser_qualifications: Some("Certified".into()),
3627 appraisal_date: Some(date!(2025 - 06 - 01)),
3628 appraiser_address: None,
3629 fmv_method_override: None,
3630 },
3631 );
3632 write_csv_exports(&out, &st, Some(2025), None, &dmap).unwrap();
3635
3636 let path = out.join("form8283.csv");
3637 assert!(path.exists(), "form8283.csv must exist");
3638
3639 let mut rdr = csv::ReaderBuilder::new()
3640 .comment(Some(b'#'))
3641 .from_path(&path)
3642 .unwrap();
3643 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3644 let idx = |name: &str| {
3645 headers
3646 .iter()
3647 .position(|h| h == name)
3648 .unwrap_or_else(|| panic!("header {name} not found"))
3649 };
3650 let all_recs: Vec<csv::StringRecord> = rdr.records().map(|r| r.unwrap()).collect();
3654 assert_eq!(
3655 all_recs.len(),
3656 2,
3657 "must have exactly two data rows (one per removal)"
3658 );
3659 let rec = &all_recs[0];
3660 let no_details_rec = &all_recs[1];
3661
3662 assert_eq!(&rec[idx("donee")], "Test Charity");
3664 assert_eq!(&rec[idx("appraiser")], "Test Appraiser");
3665 assert_eq!(&rec[idx("donee_ein")], "12-3456789");
3666 assert_eq!(&rec[idx("donee_address")], "123 Main");
3667 assert_eq!(&rec[idx("appraiser_tin")], "987654321");
3668 assert_eq!(&rec[idx("appraiser_ptin")], "P01234567");
3669 assert_eq!(&rec[idx("appraiser_qualifications")], "Certified");
3670 assert_eq!(&rec[idx("appraisal_date")], "2025-06-01");
3671 assert_eq!(&rec[idx("needs_review")], "false");
3672
3673 assert_eq!(
3675 &no_details_rec[idx("donee_ein")],
3676 "",
3677 "no-details row: donee_ein must be empty"
3678 );
3679 assert_eq!(
3680 &no_details_rec[idx("donee_address")],
3681 "",
3682 "no-details row: donee_address must be empty"
3683 );
3684 assert_eq!(
3685 &no_details_rec[idx("appraiser_tin")],
3686 "",
3687 "no-details row: appraiser_tin must be empty"
3688 );
3689 assert_eq!(
3690 &no_details_rec[idx("appraiser_ptin")],
3691 "",
3692 "no-details row: appraiser_ptin must be empty"
3693 );
3694 assert_eq!(
3695 &no_details_rec[idx("appraiser_qualifications")],
3696 "",
3697 "no-details row: appraiser_qualifications must be empty"
3698 );
3699 assert_eq!(
3700 &no_details_rec[idx("appraisal_date")],
3701 "",
3702 "no-details row: appraisal_date must be empty"
3703 );
3704 assert_eq!(
3705 &no_details_rec[idx("needs_review")],
3706 "true",
3707 "no-details carrier row: needs_review must be true"
3708 );
3709 }
3710}
3711
3712pub struct EventRow {
3715 pub reff: String,
3717 pub kind: &'static str,
3719 pub date: TaxDate,
3721 pub sat: Option<btctax_core::Sat>,
3723 pub usd: Option<Usd>,
3725 pub decision_ref: Option<String>,
3728}
3729
3730fn fmt_btc(sat: btctax_core::Sat) -> String {
3732 let whole = sat / 100_000_000;
3733 let frac = (sat % 100_000_000).unsigned_abs();
3734 format!("{whole}.{frac:08}")
3735}
3736
3737pub fn render_events_list(rows: &[EventRow]) -> String {
3740 let mut out = String::new();
3741 if rows.is_empty() {
3742 let _ = writeln!(out, "No decidable events.");
3743 return out;
3744 }
3745 let decided = rows.iter().filter(|r| r.decision_ref.is_some()).count();
3746 let _ = writeln!(
3747 out,
3748 "Decidable events — {} ({} decided, {} open):",
3749 rows.len(),
3750 decided,
3751 rows.len() - decided
3752 );
3753 for r in rows {
3754 let amount = match (r.sat, r.usd) {
3755 (Some(s), Some(u)) => format!("{} BTC (~${})", fmt_btc(s), fmt_money(u)),
3756 (Some(s), None) => format!("{} BTC", fmt_btc(s)),
3757 (None, _) => "—".to_string(),
3758 };
3759 let status = match &r.decision_ref {
3760 Some(d) => format!("[decided: {d}]"),
3761 None => "[decidable]".to_string(),
3762 };
3763 let _ = writeln!(
3764 out,
3765 " {} {} @ {} {} {}",
3766 r.reff, r.kind, r.date, amount, status
3767 );
3768 }
3769 out
3770}
3771
3772#[cfg(test)]
3773mod advisory_wrap_tests {
3774 use super::*;
3775
3776 #[test]
3780 fn advisories_wrap_to_the_house_width_with_a_hanging_indent() {
3781 use btctax_core::tax::advisories::Advisory;
3782 let out = render_advisories(&[Advisory::CtcOdcOmitted { dependents: 2 }]);
3783
3784 for line in out.lines() {
3785 assert!(
3786 line.chars().count() <= ADVISORY_WRAP_COLS,
3787 "line is {} cols, over the {}-col house width: {line:?}",
3788 line.chars().count(),
3789 ADVISORY_WRAP_COLS
3790 );
3791 }
3792 assert!(
3794 out.lines()
3795 .any(|l| l.starts_with(" ") && !l.trim().is_empty()),
3796 "a 300-char advisory must wrap onto continuation lines, got:\n{out}"
3797 );
3798 }
3799}
3800
3801#[cfg(test)]
3802mod events_list_render_tests {
3803 use super::*;
3804 use time::macros::date;
3805
3806 fn row(reff: &str, kind: &'static str, decision_ref: Option<&str>) -> EventRow {
3807 EventRow {
3808 reff: reff.to_owned(),
3809 kind,
3810 date: date!(2025 - 03 - 01),
3811 sat: Some(5_000_000),
3812 usd: Some(rust_decimal_macros::dec!(4271.78)),
3813 decision_ref: decision_ref.map(str::to_owned),
3814 }
3815 }
3816
3817 #[test]
3819 fn empty_renders_a_none_line() {
3820 assert_eq!(render_events_list(&[]), "No decidable events.\n");
3821 }
3822
3823 #[test]
3826 fn rows_are_ref_first_with_bracketed_status() {
3827 let out = render_events_list(&[
3828 row("import|coinbase|in|cb-recv", "transfer-in", None),
3829 row(
3830 "import|coinbase|out|cb-send",
3831 "transfer-out",
3832 Some("decision|1"),
3833 ),
3834 ]);
3835 let lines: Vec<&str> = out.lines().collect();
3836 assert!(
3837 lines[0].contains("2 (1 decided, 1 open)"),
3838 "header: {}",
3839 lines[0]
3840 );
3841 assert_eq!(
3843 lines[1].split_whitespace().next(),
3844 Some("import|coinbase|in|cb-recv")
3845 );
3846 assert!(lines[1].contains("[decidable]"), "open row: {}", lines[1]);
3847 assert!(
3848 lines[1].contains("0.05000000 BTC") && lines[1].contains("4271.78"),
3849 "amount: {}",
3850 lines[1]
3851 );
3852 assert!(
3853 lines[2].contains("[decided: decision|1]"),
3854 "decided row: {}",
3855 lines[2]
3856 );
3857 }
3858}
3859
3860#[cfg(test)]
3861mod holdings_pending_tests {
3862 use super::*;
3865 use btctax_core::state::PendingTransfer;
3866 use btctax_core::EventId;
3867
3868 #[test]
3869 fn holdings_pending_line_shows_in_btc_and_hides_when_reconciled() {
3870 let mut pending = LedgerState::default();
3871 pending.stats.sigma_pending = 3_000_000; pending.pending_reconciliation = vec![PendingTransfer {
3873 event: EventId::decision(1),
3874 principal_sat: 3_000_000,
3875 fee_sat: None,
3876 legs: vec![],
3877 }];
3878 let shown = render_report(&pending, None);
3879 assert!(shown.contains("Pending:"), "pending line present: {shown}");
3880 assert!(shown.contains("0.03000000 BTC"), "BTC unit: {shown}");
3881 assert!(
3882 shown.contains("1 unreconciled transfer"),
3883 "names the count (singular): {shown}"
3884 );
3885 assert!(shown.contains("verify"), "points at `verify`: {shown}");
3886
3887 let reconciled = LedgerState::default();
3889 let hidden = render_report(&reconciled, None);
3890 assert!(
3891 !hidden.contains("Pending:"),
3892 "no pending line when reconciled: {hidden}"
3893 );
3894 }
3895
3896 #[test]
3897 fn holdings_pending_line_pluralizes_multiple_transfers() {
3898 let mut pending = LedgerState::default();
3899 pending.stats.sigma_pending = 150_000_000; pending.pending_reconciliation = vec![
3901 PendingTransfer {
3902 event: EventId::decision(1),
3903 principal_sat: 100_000_000,
3904 fee_sat: None,
3905 legs: vec![],
3906 },
3907 PendingTransfer {
3908 event: EventId::decision(2),
3909 principal_sat: 50_000_000,
3910 fee_sat: None,
3911 legs: vec![],
3912 },
3913 ];
3914 let shown = render_report(&pending, None);
3915 assert!(
3916 shown.contains("2 unreconciled transfers"),
3917 "plural: {shown}"
3918 );
3919 assert!(shown.contains("1.50000000 BTC"), "{shown}");
3920 }
3921}
3922
3923#[cfg(test)]
3924mod decision_class_tests {
3925 use super::*;
3929 use btctax_core::{DisposeKind, InboundClass, IncomeKind, OutflowClass};
3930 use rust_decimal_macros::dec;
3931 use time::macros::date;
3932
3933 #[test]
3934 fn inbound_self_transfer_mine_is_human_no_debug_struct() {
3935 let s = describe_inbound_class(&InboundClass::SelfTransferMine {
3936 basis: Some(dec!(19000)),
3937 acquired_at: Some(date!(2026 - 01 - 01)),
3938 });
3939 assert!(s.contains("self-transfer"), "{s}");
3940 assert!(s.contains("$19000.00"), "names the basis in $: {s}");
3941 assert!(s.contains("2026-01-01"), "names the acquired date: {s}");
3942 assert!(!s.contains('{'), "no Debug struct braces: {s}");
3943 assert!(!s.contains("Some("), "no Debug Option wrapper: {s}");
3944 }
3945
3946 #[test]
3947 fn inbound_self_transfer_mine_defaults_are_named_not_none() {
3948 let s = describe_inbound_class(&InboundClass::SelfTransferMine {
3949 basis: None,
3950 acquired_at: None,
3951 });
3952 assert!(s.contains("self-transfer"), "{s}");
3953 assert!(
3954 s.matches("default").count() >= 2,
3955 "None basis AND date read as 'default': {s}"
3956 );
3957 assert!(!s.contains("None"), "no Debug None: {s}");
3958 }
3959
3960 #[test]
3961 fn inbound_income_names_kind_fmv_business() {
3962 let s = describe_inbound_class(&InboundClass::Income {
3963 kind: IncomeKind::Mining,
3964 fmv: Some(dec!(500)),
3965 business: true,
3966 });
3967 assert!(s.contains("income"), "{s}");
3968 assert!(s.contains("mining"), "names the income kind: {s}");
3969 assert!(s.contains("$500.00"), "names the fmv: {s}");
3970 assert!(s.contains("business"), "flags business: {s}");
3971 assert!(!s.contains('{'), "{s}");
3972 }
3973
3974 #[test]
3975 fn inbound_gift_received_names_fmv() {
3976 let s = describe_inbound_class(&InboundClass::GiftReceived {
3977 donor_basis: Some(dec!(1000)),
3978 donor_acquired_at: Some(date!(2024 - 05 - 05)),
3979 fmv_at_gift: dec!(30000),
3980 });
3981 assert!(s.contains("gift"), "{s}");
3982 assert!(s.contains("$30000.00"), "names the FMV at gift: {s}");
3983 assert!(!s.contains('{'), "{s}");
3984 }
3985
3986 #[test]
3987 fn outflow_classes_are_human() {
3988 assert_eq!(
3989 describe_outflow_class(&OutflowClass::Dispose {
3990 kind: DisposeKind::Sell
3991 }),
3992 "sell"
3993 );
3994 assert_eq!(
3995 describe_outflow_class(&OutflowClass::Dispose {
3996 kind: DisposeKind::Spend
3997 }),
3998 "spend"
3999 );
4000 assert_eq!(describe_outflow_class(&OutflowClass::GiftOut), "gift");
4001 let donate = describe_outflow_class(&OutflowClass::Donate {
4002 appraisal_required: true,
4003 });
4004 assert!(
4005 donate.contains("donate") && donate.contains("appraisal"),
4006 "{donate}"
4007 );
4008 assert_eq!(
4009 describe_outflow_class(&OutflowClass::Donate {
4010 appraisal_required: false
4011 }),
4012 "donate"
4013 );
4014 }
4015}