1use crate::config::CliConfig;
4use btctax_adapters::FileReport;
5use btctax_core::conventions::{tax_date, Usd, TRANSITION_DATE};
6use btctax_core::persistence::ImportReport;
7use btctax_core::DonationDetails;
8use btctax_core::{
9 conservation_report, disposal_compliance, form_8283, form_8949, schedule_d,
10 year_donation_deduction, BasisSource, Blocker, BlockerKind, ComplianceStatus,
11 ConservationReport, DisposalCompliance, DisposalLeg, DisposeKind, EventId, EventPayload,
12 Form8283HowAcquired, Form8283Section, Form8949Box, Form8949Part, GiftZone, HarvestReport,
13 HarvestStatus, HarvestTarget, IncomeKind, LedgerEvent, LedgerState, LotMethod, LtcgBracket,
14 RemovalKind, RemovalLeg, ScheduleDTotals, SeTaxResult, SellReport, SellStatus, Severity,
15 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 }
55}
56
57fn pseudo_tag(pseudo: bool) -> &'static str {
63 if pseudo {
64 " [PSEUDO]"
65 } else {
66 ""
67 }
68}
69
70fn dispose_kind_tag(dk: DisposeKind) -> &'static str {
71 match dk {
72 DisposeKind::Sell => "sell",
73 DisposeKind::Spend => "spend",
74 }
75}
76
77fn income_kind_tag(ik: IncomeKind) -> &'static str {
78 match ik {
79 IncomeKind::Mining => "mining",
80 IncomeKind::Staking => "staking",
81 IncomeKind::Interest => "interest",
82 IncomeKind::Airdrop => "airdrop",
83 IncomeKind::Reward => "reward",
84 }
85}
86
87fn gift_zone_tag(gz: GiftZone) -> &'static str {
88 match gz {
89 GiftZone::Gain => "gain",
90 GiftZone::Loss => "loss",
91 GiftZone::NoGainNoLoss => "no_gain_no_loss",
92 }
93}
94
95fn removal_kind_tag(rk: RemovalKind) -> &'static str {
96 match rk {
97 RemovalKind::Gift => "gift",
98 RemovalKind::Donation => "donation",
99 }
100}
101
102fn term_tag(t: Term) -> &'static str {
104 match t {
105 Term::ShortTerm => "short",
106 Term::LongTerm => "long",
107 }
108}
109
110fn form8949_part_tag(p: Form8949Part) -> &'static str {
112 match p {
113 Form8949Part::ShortTerm => "ST",
114 Form8949Part::LongTerm => "LT",
115 }
116}
117
118fn form8949_box_tag(b: Form8949Box) -> &'static str {
121 match b {
122 Form8949Box::C => "C",
123 Form8949Box::F => "F",
124 }
125}
126
127fn form8283_section_tag(s: Form8283Section) -> &'static str {
130 match s {
131 Form8283Section::A => "A",
132 Form8283Section::B => "B",
133 }
134}
135
136fn form8283_how_acquired_tag(h: Form8283HowAcquired) -> &'static str {
139 match h {
140 Form8283HowAcquired::Purchased => "Purchased",
141 Form8283HowAcquired::Gift => "Gift",
142 Form8283HowAcquired::Other => "Other",
143 Form8283HowAcquired::Review => "Review",
144 }
145}
146
147fn compliance_status_tag(cs: &ComplianceStatus) -> String {
156 match cs {
157 ComplianceStatus::StandingOrder { effective_from } => {
158 format!("standing_order:{effective_from}")
159 }
160 ComplianceStatus::Contemporaneous => "contemporaneous".into(),
161 ComplianceStatus::AttestedRecording => "attested_recording".into(),
162 ComplianceStatus::NonCompliant => "non_compliant".into(),
163 }
164}
165
166pub fn render_file_reports(reports: &[FileReport], import: &ImportReport) -> String {
168 let mut out = String::new();
169 let _ = writeln!(out, "Import:");
170 for r in reports {
171 let _ = writeln!(
172 out,
173 " {} [{}]: parsed {} rows -> {} BTC events ({} dropped no-BTC, {} unclassified)",
174 r.source.tag(),
175 r.label,
176 r.parsed_rows,
177 r.btc_events,
178 r.dropped_no_btc,
179 r.unclassified
180 );
181 }
182 let _ = writeln!(
183 out,
184 " appended {} | duplicates {} | NEW import-conflicts {}",
185 import.appended, import.duplicates, import.conflicts
186 );
187 if import.conflicts > 0 {
188 let _ = writeln!(
189 out,
190 " ! resolve conflicts with `reconcile accept-conflict <id>` or `reject-conflict <id>` (see `verify`)"
191 );
192 }
193 out
194}
195
196pub fn wallet_label(w: &WalletId) -> String {
198 match w {
199 WalletId::Exchange { provider, account } => format!("exchange:{provider}:{account}"),
200 WalletId::SelfCustody { label } => format!("self:{label}"),
201 }
202}
203
204fn disposal_year(d: &btctax_core::Disposal) -> i32 {
205 d.disposed_at.year()
206}
207
208pub fn render_report(state: &LedgerState, year: Option<i32>) -> String {
210 let mut out = String::new();
211 let yr = |y: i32| year.is_none_or(|f| f == y); let _ = writeln!(out, "Holdings (per wallet):");
214 if state.holdings_by_wallet.is_empty() {
215 let _ = writeln!(out, " none");
216 }
217 for (w, sat) in &state.holdings_by_wallet {
218 let _ = writeln!(out, " {}: {} sat", wallet_label(w), sat);
219 }
220
221 let _ = writeln!(out, "Lots:");
222 if state.lots.is_empty() {
223 let _ = writeln!(out, " none");
224 }
225 for l in &state.lots {
226 let _ = writeln!(
227 out,
228 " {}#{} {} remaining {} sat | basis {} ({}){}{}",
229 l.lot_id.origin_event_id.canonical(),
230 l.lot_id.split_sequence,
231 wallet_label(&l.wallet),
232 l.remaining_sat,
233 l.usd_basis,
234 basis_source_tag(l.basis_source),
235 if l.basis_pending {
236 " [basis pending]"
237 } else {
238 ""
239 },
240 pseudo_tag(l.pseudo),
241 );
242 }
243
244 let label = match year {
245 Some(y) => format!("(year {y})"),
246 None => "(all years)".to_string(),
247 };
248
249 let disposals: Vec<_> = state
250 .disposals
251 .iter()
252 .filter(|d| yr(disposal_year(d)))
253 .collect();
254 if disposals.is_empty() {
255 let _ = writeln!(out, "Disposals {}: none", label);
256 } else {
257 let _ = writeln!(out, "Disposals {}:", label);
258 for d in disposals {
259 let _ = writeln!(
260 out,
261 " {} @ {} ({})",
262 dispose_kind_tag(d.kind),
263 d.disposed_at,
264 d.event.canonical()
265 );
266 for leg in &d.legs {
267 render_disposal_leg(&mut out, leg);
268 }
269 }
270 }
271
272 let removals: Vec<_> = state
273 .removals
274 .iter()
275 .filter(|r| yr(r.removed_at.year()))
276 .collect();
277 if removals.is_empty() {
278 let _ = writeln!(out, "Removals {}: none", label);
279 } else {
280 let _ = writeln!(out, "Removals {}:", label);
281 for r in removals {
282 let deduction_tag = match r.claimed_deduction {
283 Some(d) => format!(" [claimed deduction {}]", fmt_money(d)),
284 None => String::new(),
285 };
286 let _ = writeln!(
287 out,
288 " {} @ {} ({}){}",
289 removal_kind_tag(r.kind),
290 r.removed_at,
291 r.event.canonical(),
292 deduction_tag
293 );
294 for leg in &r.legs {
295 render_removal_leg(&mut out, leg);
296 }
297 }
298 }
299
300 let income: Vec<_> = state
301 .income_recognized
302 .iter()
303 .filter(|i| yr(i.recognized_at.year()))
304 .collect();
305 if income.is_empty() {
306 let _ = writeln!(out, "Income {}: none", label);
307 } else {
308 let _ = writeln!(out, "Income {}:", label);
309 for i in income {
310 let _ = writeln!(
311 out,
312 " {} @ {} {} sat = {} USD{}{}",
313 income_kind_tag(i.kind),
314 i.recognized_at,
315 i.sat,
316 i.usd_fmv,
317 if i.business { " [business]" } else { "" },
318 pseudo_tag(i.pseudo), );
320 }
321 }
322
323 let charitable_total: btctax_core::conventions::Usd = state
326 .removals
327 .iter()
328 .filter(|r| yr(r.removed_at.year()) && r.kind == RemovalKind::Donation)
329 .filter_map(|r| r.claimed_deduction)
330 .sum();
331 let _ = writeln!(
332 out,
333 "Charitable deduction {} (Schedule A itemized) — BEFORE §170(b) AGI limits / carryover: {}",
334 label,
335 fmt_money(charitable_total)
336 );
337
338 out
339}
340
341fn render_disposal_leg(out: &mut String, leg: &DisposalLeg) {
342 let zone = leg
343 .gift_zone
344 .map(|z| format!(" gift-zone {}", gift_zone_tag(z)))
345 .unwrap_or_default();
346 let _ = writeln!(
347 out,
348 " {} sat: proceeds {} basis {} gain {} {}{}{}",
349 leg.sat,
350 leg.proceeds,
351 leg.basis,
352 leg.gain,
353 term_tag(leg.term),
354 zone,
355 pseudo_tag(leg.pseudo),
356 );
357}
358
359fn render_removal_leg(out: &mut String, leg: &RemovalLeg) {
360 let _ = writeln!(
361 out,
362 " {} sat: basis {} fmv {} {} (zero gain){}",
363 leg.sat,
364 leg.basis,
365 leg.fmv_at_transfer,
366 term_tag(leg.term),
367 pseudo_tag(leg.pseudo),
368 );
369}
370
371pub fn filing_status_tag(fs: btctax_core::FilingStatus) -> &'static str {
379 use btctax_core::FilingStatus::*;
380 match fs {
381 Single => "single",
382 Mfj => "mfj",
383 Mfs => "mfs",
384 HoH => "hoh",
385 Qss => "qss",
386 }
387}
388
389fn lot_method_display(m: LotMethod) -> &'static str {
391 match m {
392 LotMethod::Fifo => "FIFO",
393 LotMethod::Lifo => "LIFO",
394 LotMethod::Hifo => "HIFO",
395 }
396}
397
398#[derive(Debug, Clone)]
400pub struct ElectionLine {
401 pub recorded: TaxDate,
402 pub effective_from: TaxDate,
403 pub method: LotMethod,
404 pub note: &'static str,
406}
407
408#[derive(Debug, Clone)]
410pub struct VerifyReport {
411 pub conservation: ConservationReport,
412 pub hard: Vec<Blocker>,
413 pub advisory: Vec<Blocker>,
414 pub pending: usize,
415 pub unknown_basis_inbounds: usize,
416 pub safe_harbor: String,
417 pub declared_pre2025_method: LotMethod,
419 pub pre2025_method_attested: bool,
420 pub elections: Vec<ElectionLine>,
422 pub selection_count: usize,
424 pub compliance: Vec<DisposalCompliance>,
426}
427
428impl VerifyReport {
429 pub fn has_hard_blockers(&self) -> bool {
432 !self.hard.is_empty()
433 }
434}
435
436fn safe_harbor_status(state: &LedgerState, _events: &[LedgerEvent]) -> String {
447 let effective_path_b = state
450 .lots
451 .iter()
452 .any(|l| l.basis_source == BasisSource::SafeHarborAllocated)
453 || state.disposals.iter().any(|d| {
454 d.legs
455 .iter()
456 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
457 })
458 || state.removals.iter().any(|r| {
459 r.legs
460 .iter()
461 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
462 });
463 let unconservable = state
464 .blockers
465 .iter()
466 .any(|b| b.kind == BlockerKind::SafeHarborUnconservable);
467 let timebar = state
468 .blockers
469 .iter()
470 .any(|b| b.kind == BlockerKind::SafeHarborTimebar);
471 if unconservable {
475 "Path B allocation FAILS conservation/eligibility (hard, §7.4) — fix the allocation"
476 .to_string()
477 } else if effective_path_b {
478 "Path B safe-harbor allocation is effective (§7.4)".to_string()
479 } else if timebar {
480 "Path B time-barred -> using Path A (advisory); `reconcile safe-harbor attest` if timely in your books".to_string()
481 } else {
482 "Path A (actual per-wallet reconstruction; default, no election)".to_string()
483 }
484}
485
486pub fn build_verify(state: &LedgerState, events: &[LedgerEvent], cli: &CliConfig) -> VerifyReport {
487 let conservation = conservation_report(state);
488 let mut hard = Vec::new();
489 let mut advisory = Vec::new();
490 for b in &state.blockers {
491 match b.kind.severity() {
492 Severity::Hard => hard.push(b.clone()),
493 Severity::Advisory => advisory.push(b.clone()),
494 }
495 }
496 let unknown_basis_inbounds = state
497 .blockers
498 .iter()
499 .filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
500 .count();
501
502 let voided: BTreeSet<EventId> = events
504 .iter()
505 .filter_map(|e| match &e.payload {
506 EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
507 _ => None,
508 })
509 .collect();
510
511 let mut election_events: Vec<(u64, &LedgerEvent)> = events
513 .iter()
514 .filter_map(|e| {
515 if let EventId::Decision { seq } = e.id {
516 if matches!(e.payload, EventPayload::MethodElection(_)) {
517 return Some((seq, e));
518 }
519 }
520 None
521 })
522 .collect();
523 election_events.sort_by_key(|(s, _)| *s);
524
525 let elections: Vec<ElectionLine> = election_events
526 .iter()
527 .map(|(_, e)| {
528 let EventPayload::MethodElection(me) = &e.payload else {
529 unreachable!("filtered to MethodElection above")
530 };
531 let recorded = tax_date(e.utc_timestamp, e.original_tz);
532 let note = if voided.contains(&e.id) {
533 "voided"
534 } else if me.effective_from < TRANSITION_DATE || me.effective_from < recorded {
535 "backdated/ignored"
536 } else {
537 "in force"
538 };
539 ElectionLine {
540 recorded,
541 effective_from: me.effective_from,
542 method: me.method,
543 note,
544 }
545 })
546 .collect();
547
548 let selection_count = events
553 .iter()
554 .filter(|e| matches!(e.payload, EventPayload::LotSelection(_)) && !voided.contains(&e.id))
555 .count();
556
557 let compliance = disposal_compliance(events, state);
559
560 VerifyReport {
561 conservation,
562 hard,
563 advisory,
564 pending: state.pending_reconciliation.len(),
565 unknown_basis_inbounds,
566 safe_harbor: safe_harbor_status(state, events),
567 declared_pre2025_method: cli.pre2025_method,
568 pre2025_method_attested: cli.pre2025_method_attested,
569 elections,
570 selection_count,
571 compliance,
572 }
573}
574
575pub fn write_csv_exports(
587 out_dir: &Path,
588 state: &LedgerState,
589 tax_year: Option<i32>,
590 se_result: Option<&SeTaxResult>,
591 donation_details: &BTreeMap<EventId, DonationDetails>,
592) -> Result<(), crate::CliError> {
593 fsperms::mkdir_owner_only(out_dir)?;
594
595 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("lots.csv"))?);
596 w.write_record([
597 "origin_event",
598 "split",
599 "wallet",
600 "acquired_at",
601 "remaining_sat",
602 "usd_basis",
603 "basis_source",
604 "basis_pending",
605 ])?;
606 for l in &state.lots {
607 w.write_record([
608 l.lot_id.origin_event_id.canonical(),
609 l.lot_id.split_sequence.to_string(),
610 wallet_label(&l.wallet),
611 l.acquired_at.to_string(),
612 l.remaining_sat.to_string(),
613 l.usd_basis.to_string(),
614 basis_source_tag(l.basis_source).to_string(),
615 l.basis_pending.to_string(),
616 ])?;
617 }
618 w.flush()?;
619
620 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("disposals.csv"))?);
621 w.write_record([
622 "event",
623 "kind",
624 "disposed_at",
625 "lot",
626 "sat",
627 "proceeds",
628 "basis",
629 "gain",
630 "term",
631 "gift_zone",
632 "acquired_at",
633 "wallet",
634 ])?;
635 for d in &state.disposals {
636 for leg in &d.legs {
637 w.write_record([
638 d.event.canonical(),
639 dispose_kind_tag(d.kind).to_string(),
640 d.disposed_at.to_string(),
641 format!(
642 "{}#{}",
643 leg.lot_id.origin_event_id.canonical(),
644 leg.lot_id.split_sequence
645 ),
646 leg.sat.to_string(),
647 leg.proceeds.to_string(),
648 leg.basis.to_string(),
649 leg.gain.to_string(),
650 term_tag(leg.term).to_string(),
651 leg.gift_zone
652 .map(|z| gift_zone_tag(z).to_string())
653 .unwrap_or_default(),
654 leg.acquired_at.to_string(),
655 wallet_label(&leg.wallet),
656 ])?;
657 }
658 }
659 w.flush()?;
660
661 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("removals.csv"))?);
662 w.write_record([
669 "event",
670 "kind",
671 "removed_at",
672 "lot",
673 "sat",
674 "basis",
675 "fmv_at_transfer",
676 "term",
677 "acquired_at",
678 "claimed_deduction",
679 "donee",
680 ])?;
681 for r in &state.removals {
682 let deduction_first = r
683 .claimed_deduction
684 .map(|d| d.to_string())
685 .unwrap_or_default();
686 let donee_cell = r.donee.clone().unwrap_or_default();
687 for (leg_idx, leg) in r.legs.iter().enumerate() {
688 let deduction_cell: &str = if leg_idx == 0 { &deduction_first } else { "" };
691 w.write_record([
692 r.event.canonical(),
693 removal_kind_tag(r.kind).to_string(),
694 r.removed_at.to_string(),
695 format!(
696 "{}#{}",
697 leg.lot_id.origin_event_id.canonical(),
698 leg.lot_id.split_sequence
699 ),
700 leg.sat.to_string(),
701 leg.basis.to_string(),
702 leg.fmv_at_transfer.to_string(),
703 term_tag(leg.term).to_string(),
704 leg.acquired_at.to_string(),
705 deduction_cell.to_string(),
706 donee_cell.clone(),
707 ])?;
708 }
709 }
710 w.flush()?;
711
712 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("income.csv"))?);
713 w.write_record([
714 "event",
715 "kind",
716 "recognized_at",
717 "sat",
718 "usd_fmv",
719 "business",
720 ])?;
721 for i in &state.income_recognized {
722 w.write_record([
723 i.event.canonical(),
724 income_kind_tag(i.kind).to_string(),
725 i.recognized_at.to_string(),
726 i.sat.to_string(),
727 i.usd_fmv.to_string(),
728 i.business.to_string(),
729 ])?;
730 }
731 w.flush()?;
732
733 if let Some(year) = tax_year {
736 write_form8949_csv(out_dir, state, year)?;
737 write_schedule_d_csv(out_dir, state, year)?;
738 write_form8283_csv(out_dir, state, year, donation_details)?;
739 if let Some(se) = se_result {
746 write_schedule_se_csv(out_dir, se)?;
747 }
748 }
749 Ok(())
750}
751
752pub fn write_form_csvs(
767 out_dir: &Path,
768 state: &LedgerState,
769 year: i32,
770 se_result: Option<&SeTaxResult>,
771 donation_details: &BTreeMap<EventId, DonationDetails>,
772) -> Result<(), crate::CliError> {
773 fsperms::mkdir_owner_only(out_dir)?;
774 write_form8949_csv(out_dir, state, year)?;
775 write_schedule_d_csv(out_dir, state, year)?;
776 write_form8283_csv(out_dir, state, year, donation_details)?;
777 if let Some(se) = se_result {
778 write_schedule_se_csv(out_dir, se)?;
779 }
780 Ok(())
781}
782
783fn write_schedule_se_csv(out_dir: &Path, se: &SeTaxResult) -> Result<(), crate::CliError> {
787 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_se.csv"))?);
788 w.write_record([
789 "net_se_earnings",
790 "se_base_9235",
791 "ss_component",
792 "medicare_component",
793 "additional_medicare_component",
794 "total_se_tax",
795 "deductible_half",
796 ])?;
797 w.write_record([
798 se.net_se.to_string(),
799 se.base.to_string(),
800 se.ss.to_string(),
801 se.medicare.to_string(),
802 se.addl.to_string(),
803 se.total.to_string(),
804 se.deductible_half.to_string(),
805 ])?;
806 w.flush()?;
807 Ok(())
808}
809
810pub const FORM_8283_AGGREGATION_CAVEAT: &str =
816 "Section A/B reflects the \u{00a7}170(f)(11)(F) year-aggregate for similar property: all BTC \
817 donations in the year are summed (all BTC is 'similar property'); if the year-total claimed \
818 deduction exceeds $5,000 every row is Section B (qualified appraisal required), otherwise \
819 Section A. CCA 202302012: the readily-valued exception does not apply to crypto.";
820
821pub fn render_donation_appraisal_advisory(state: &LedgerState, year: i32) -> Option<String> {
839 use btctax_core::QUALIFIED_APPRAISAL_THRESHOLD;
840 let agg = year_donation_deduction(state, year);
841 if agg <= QUALIFIED_APPRAISAL_THRESHOLD {
842 return None;
843 }
844 let threshold = fmt_money(QUALIFIED_APPRAISAL_THRESHOLD);
847 Some(format!(
848 "\u{00a7}170(f)(11)(F): your {year} BTC donations aggregate ${} of claimed deduction \
849 (> ${threshold}) \u{2014} a qualified appraisal is required for the donated BTC even if \
850 no single donation exceeds ${threshold} (all BTC is 'similar property'; CCA 202302012 \
851 \u{2014} no readily-valued exception for crypto).",
852 fmt_money(agg)
853 ))
854}
855
856fn write_form8283_csv(
863 out_dir: &Path,
864 state: &LedgerState,
865 year: i32,
866 details: &BTreeMap<EventId, DonationDetails>,
867) -> Result<(), crate::CliError> {
868 use std::io::Write as _;
869 let mut file = fsperms::open_owner_only(&out_dir.join("form8283.csv"))?;
870
871 writeln!(file, "# {FORM_8283_AGGREGATION_CAVEAT}")?;
873
874 let total_deduction = year_donation_deduction(state, year);
880 if total_deduction <= rust_decimal::Decimal::from(500) {
881 writeln!(
882 file,
883 "# [R0-M1] The year's total noncash charitable deduction ({}) is <= $500; Form 8283 is \
884 NOT required at that level (rows below are informational only).",
885 fmt_money(total_deduction)
886 )?;
887 }
888
889 let mut w = Writer::from_writer(file);
890 w.write_record([
891 "section",
892 "description",
893 "how_acquired",
894 "date_acquired",
895 "date_contributed",
896 "cost_basis",
897 "fmv",
898 "claimed_deduction",
899 "fmv_method",
900 "donee",
901 "appraiser",
902 "needs_review",
903 "donee_ein",
905 "donee_address",
906 "appraiser_tin",
907 "appraiser_ptin",
908 "appraiser_qualifications",
909 "appraisal_date",
910 ])?;
911 for row in form_8283(state, year, details) {
912 let d = row.details.as_ref();
913 w.write_record([
914 row.section
915 .map(form8283_section_tag)
916 .unwrap_or("")
917 .to_string(),
918 row.description,
919 form8283_how_acquired_tag(row.how_acquired).to_string(),
920 row.date_acquired.to_string(),
921 row.date_contributed.to_string(),
922 row.cost_basis.to_string(),
923 row.fmv.to_string(),
924 row.claimed_deduction
925 .map(|d| d.to_string())
926 .unwrap_or_default(),
927 row.fmv_method,
928 row.donee,
929 row.appraiser,
930 row.needs_review.to_string(),
931 d.and_then(|d| d.donee_ein.clone()).unwrap_or_default(),
933 d.and_then(|d| d.donee_address.clone()).unwrap_or_default(),
934 d.and_then(|d| d.appraiser_tin.clone()).unwrap_or_default(),
935 d.and_then(|d| d.appraiser_ptin.clone()).unwrap_or_default(),
936 d.and_then(|d| d.appraiser_qualifications.clone())
937 .unwrap_or_default(),
938 d.and_then(|d| d.appraisal_date.map(|dt| dt.to_string()))
939 .unwrap_or_default(),
940 ])?;
941 }
942 w.flush()?;
943 Ok(())
944}
945
946fn write_form8949_csv(
949 out_dir: &Path,
950 state: &LedgerState,
951 year: i32,
952) -> Result<(), crate::CliError> {
953 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("form8949.csv"))?);
954 w.write_record([
955 "part",
956 "box",
957 "box_needs_review",
958 "description",
959 "date_acquired",
960 "date_sold",
961 "proceeds",
962 "cost_basis",
963 "adjustment_code",
964 "adjustment_amount",
965 "gain",
966 "wallet",
967 "disposition_kind",
968 ])?;
969 for r in form_8949(state, year) {
970 w.write_record([
971 form8949_part_tag(r.part).to_string(),
972 form8949_box_tag(r.box_).to_string(),
973 r.box_needs_review.to_string(),
974 r.description,
975 r.date_acquired.to_string(),
976 r.date_sold.to_string(),
977 r.proceeds.to_string(),
978 r.cost_basis.to_string(),
979 r.adjustment_code,
980 r.adjustment_amount.to_string(),
981 r.gain.to_string(),
982 wallet_label(&r.wallet),
983 dispose_kind_tag(r.disposition_kind).to_string(),
984 ])?;
985 }
986 w.flush()?;
987 Ok(())
988}
989
990fn write_schedule_d_csv(
993 out_dir: &Path,
994 state: &LedgerState,
995 year: i32,
996) -> Result<(), crate::CliError> {
997 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_d.csv"))?);
998 w.write_record(["part", "proceeds", "cost_basis", "gain"])?;
999 let totals = schedule_d(state, year);
1000 for (part, p) in [("ST", &totals.st), ("LT", &totals.lt)] {
1001 w.write_record([
1002 part.to_string(),
1003 p.proceeds.to_string(),
1004 p.cost_basis.to_string(),
1005 p.gain.to_string(),
1006 ])?;
1007 }
1008 w.flush()?;
1009 Ok(())
1010}
1011
1012pub fn render_tax_outcome(
1019 year: i32,
1020 out: &btctax_core::TaxOutcome,
1021 advisory: Option<&str>,
1022) -> String {
1023 use btctax_core::TaxOutcome::*;
1024 let mut s = String::new();
1025 let _ = writeln!(s, "Federal tax attributable to crypto — tax year {year}");
1026 match out {
1027 NotComputable(b) => {
1028 let _ = writeln!(s, " NOT COMPUTABLE [{:?}]: {}", b.kind, b.detail);
1029 }
1030 Computed(r) => {
1031 let _ = writeln!(
1032 s,
1033 " net short-term: {} net long-term: {}",
1034 fmt_money(r.st_net),
1035 fmt_money(r.lt_net)
1036 );
1037 let _ = writeln!(
1038 s,
1039 " crypto ordinary income (level): {}",
1040 fmt_money(r.ordinary_from_crypto)
1041 );
1042 let ordinary_rate_attributable = r.total_federal_tax_attributable - r.ltcg_tax - r.niit;
1045 let _ = writeln!(
1046 s,
1047 " ordinary-rate tax (attributable): {}",
1048 fmt_money(ordinary_rate_attributable)
1049 );
1050 let _ = writeln!(
1051 s,
1052 " LTCG tax (attributable): {} NIIT (attributable): {}",
1053 fmt_money(r.ltcg_tax),
1054 fmt_money(r.niit)
1055 );
1056 let _ = writeln!(
1057 s,
1058 " TOTAL federal tax attributable to crypto (delta): {} \
1059 (= ordinary-rate + LTCG + NIIT attributable)",
1060 fmt_money(r.total_federal_tax_attributable)
1061 );
1062 let _ = writeln!(
1063 s,
1064 " §1211 loss deduction (level): {} carryforward out: short {} / long {}",
1065 fmt_money(r.loss_deduction),
1066 fmt_money(r.carryforward_out.short),
1067 fmt_money(r.carryforward_out.long)
1068 );
1069 let _ = writeln!(
1070 s,
1071 " marginal rates: ordinary {} / LTCG {} / NIIT {}",
1072 r.marginal_rates.ordinary, r.marginal_rates.ltcg, r.marginal_rates.niit_applies
1073 );
1074 let _ = writeln!(
1075 s,
1076 " (incremental ceteris-paribus delta on the minimal profile; \
1077 excludes AGI-driven SS/IRMAA/AMT/QBI/phaseout effects — I5. §1411 NIIT reduces NII by the \
1078 §1211(b)-allowed net capital loss (≤ $3,000 / $1,500 MFS — Form 8960 line 5a / §1.1411-4(d)) \
1079 and is floored at $0; crypto ordinary income (mining/staking/airdrops/rewards) is correctly \
1080 excluded from NII; crypto-lending interest income (§1411(c)(1)(A)(i)) is INCLUDED in NII; \
1081 mining/staking/airdrops/rewards remain excluded (SE income per §1411(c)(6) or non-NII other income).)"
1082 );
1083 }
1084 }
1085 if let Some(msg) = advisory {
1088 let _ = writeln!(s, " ADVISORY (M4): {msg}");
1089 }
1090 s
1091}
1092
1093pub fn render_schedule_d(
1103 year: i32,
1104 totals: &ScheduleDTotals,
1105 outcome: &btctax_core::TaxOutcome,
1106) -> String {
1107 let mut s = String::new();
1108 let _ = writeln!(
1109 s,
1110 "Schedule D (raw pre-netting part totals) — tax year {year}"
1111 );
1112 let _ = writeln!(
1113 s,
1114 " Part I (short-term): proceeds {} cost basis {} gain {}",
1115 fmt_money(totals.st.proceeds),
1116 fmt_money(totals.st.cost_basis),
1117 fmt_money(totals.st.gain)
1118 );
1119 let _ = writeln!(
1120 s,
1121 " Part II (long-term): proceeds {} cost basis {} gain {}",
1122 fmt_money(totals.lt.proceeds),
1123 fmt_money(totals.lt.cost_basis),
1124 fmt_money(totals.lt.gain)
1125 );
1126 match outcome {
1127 btctax_core::TaxOutcome::NotComputable(_) => {
1128 let _ = writeln!(
1129 s,
1130 " (raw disposition totals shown above; the year's tax is NOT COMPUTABLE until \
1131 the blocker is resolved — these Form 8949/Schedule D part totals are \
1132 informational and are not netted/carried until the tax computes)."
1133 );
1134 }
1135 btctax_core::TaxOutcome::Computed(_) => {
1136 let _ = writeln!(
1137 s,
1138 " Note: §1222/§1211/§1212 netting + carryforward are applied in the tax \
1139 computation (report --tax-year); these are the raw pre-netting Form \
1140 8949/Schedule D part totals."
1141 );
1142 }
1143 }
1144 s
1145}
1146
1147pub fn render_schedule_se(
1169 year: i32,
1170 result: Option<&SeTaxResult>,
1171 gross_se: Usd,
1172 table_present: bool,
1173 schedule_c_expenses: Usd,
1174 w2_ss_wages: Usd,
1175 w2_medicare_wages: Usd,
1176) -> Option<String> {
1177 match result {
1178 Some(r) => {
1179 let mut s = String::new();
1180 let _ = writeln!(
1181 s,
1182 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1183 );
1184 if schedule_c_expenses > Usd::ZERO {
1186 let gross_display = r.net_se + schedule_c_expenses;
1189 let _ = writeln!(
1190 s,
1191 " gross business income {} \u{2212} Schedule C expenses {} = net SE earnings {}",
1192 fmt_money(gross_display),
1193 fmt_money(schedule_c_expenses),
1194 fmt_money(r.net_se)
1195 );
1196 let _ = writeln!(
1198 s,
1199 " (Schedule C advisory) Schedule C expenses also reduce your ORDINARY taxable \
1200 income, but the income-tax total above uses GROSS crypto income \u{2014} to first \
1201 order it OVERSTATES your tax by your marginal ordinary rate applied to {}. The tax \
1202 profile cannot express this (an `ordinary_taxable_income` edit would shift both \
1203 legs of the crypto-attributable delta); the engine-side coordination is deferred \
1204 \u{2014} coordinate it on your actual return.",
1205 fmt_money(schedule_c_expenses)
1206 );
1207 } else {
1208 let _ = writeln!(
1209 s,
1210 " net self-employment income (business crypto, Interest excluded): {}",
1211 fmt_money(r.net_se)
1212 );
1213 let _ = writeln!(
1214 s,
1215 " (Schedule C) no Schedule C expenses supplied (--schedule-c-expenses)"
1216 );
1217 }
1218 let _ = writeln!(
1219 s,
1220 " \u{00d7} 92.35% net-earnings factor (\u{00a7}1402(a)) = net SE earnings: {}",
1221 fmt_money(r.base)
1222 );
1223 let _ = writeln!(
1224 s,
1225 " Social Security component (12.4%, §1401(a); capped at the SS wage base): {}",
1226 fmt_money(r.ss)
1227 );
1228 let _ = writeln!(
1229 s,
1230 " Medicare component (2.9%, §1401(b); uncapped): {}",
1231 fmt_money(r.medicare)
1232 );
1233 let _ = writeln!(
1234 s,
1235 " Additional Medicare component (0.9%, §1401(b)(2)): {}",
1236 fmt_money(r.addl)
1237 );
1238 let _ = writeln!(
1239 s,
1240 " TOTAL self-employment tax (§1401): {}",
1241 fmt_money(r.total)
1242 );
1243 let _ = writeln!(
1244 s,
1245 " §164(f) one-half-SE-tax deduction (above-the-line; EXCLUDES Additional Medicare per \
1246 §164(f)(1)): {}",
1247 fmt_money(r.deductible_half)
1248 );
1249 let _ = writeln!(
1252 s,
1253 " (§164(f) advisory) The §164(f) deduction ({}) is NOT auto-coordinated into the \
1254 income-tax total above — to first order, that total overstates your combined tax by \
1255 your marginal ordinary rate applied to {}. The tax profile cannot express this deduction \
1256 directly (reducing `ordinary_taxable_income` would shift BOTH legs of the \
1257 crypto-attributable delta and only correct the bracket differential, not the level) — \
1258 coordinate it on your actual return.",
1259 fmt_money(r.deductible_half),
1260 fmt_money(r.deductible_half)
1261 );
1262 if w2_ss_wages > Usd::ZERO || w2_medicare_wages > Usd::ZERO {
1265 let _ = writeln!(
1266 s,
1267 " (W-2 coordination applied) SS cap = max(0, wage base \u{2212} {}) (Box 3+7); \
1268 Additional-Medicare threshold reduced (not below 0) by {} (Box 5, \
1269 §1401(b)(2)(B)/Form 8959 Part II).",
1270 fmt_money(w2_ss_wages),
1271 fmt_money(w2_medicare_wages)
1272 );
1273 } else {
1274 let _ = writeln!(
1275 s,
1276 " (W-2) assumes $0 W-2 wages (set --w2-ss-wages/--w2-medicare-wages on the tax \
1277 profile if you had a wage job)."
1278 );
1279 }
1280 if r.base < rust_decimal::Decimal::from(400) {
1283 let _ = writeln!(
1284 s,
1285 " (§6017 filing floor) Net earnings from self-employment ({base}) are below $400: \
1286 a Schedule SE filing is required on account of this income only when net earnings \
1287 from self-employment (the ×92.35% base, §1402(a)) are $400 or more (§6017), and \
1288 below that floor no §1401 SE tax is imposed (§1402(b)(2); church employee income \
1289 excepted — §1402(j)(2), not modeled) — the figures above are shown for \
1290 transparency (other self-employment activities, if any, combine on your actual \
1291 Schedule SE).",
1292 base = fmt_money(r.base)
1293 );
1294 }
1295 let _ = writeln!(
1297 s,
1298 " (standalone) This §1401 SE tax is a SEPARATE federal liability, NOT included in the \
1299 income-tax + NIIT total above; the §164(f) one-half-SE-tax deduction is not \
1300 auto-coordinated into that total."
1301 );
1302 Some(s)
1303 }
1304 None => {
1305 if gross_se.is_zero() {
1306 None } else if !table_present {
1308 let mut s = String::new();
1310 let _ = writeln!(
1311 s,
1312 "Schedule SE (§1401 self-employment tax) — tax year {year}"
1313 );
1314 let _ = writeln!(
1315 s,
1316 " SS wage base unavailable for {year}: business self-employment income is present \
1317 but no bundled tax table (ss_wage_base) exists for {year}; the §1401 SE tax was \
1318 NOT computed (no silent drop)."
1319 );
1320 Some(s)
1321 } else {
1322 let mut s = String::new();
1325 let _ = writeln!(
1326 s,
1327 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1328 );
1329 let _ = writeln!(
1330 s,
1331 " fully expensed: gross {} \u{2212} Schedule C expenses {} \u{2264} $0 \
1332 \u{2192} no \u{00a7}1401 SE tax for {year}.",
1333 fmt_money(gross_se),
1334 fmt_money(schedule_c_expenses)
1335 );
1336 Some(s)
1337 }
1338 }
1339 }
1340}
1341
1342pub fn render_gift_advisory(
1365 state: &LedgerState,
1366 year: i32,
1367 prior_taxable_gifts: btctax_core::conventions::Usd,
1368 tables: &impl btctax_core::TaxTables,
1369) -> Option<String> {
1370 use std::collections::BTreeMap;
1371
1372 let any_gift = state
1374 .removals
1375 .iter()
1376 .any(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year);
1377 if !any_gift {
1378 return None; }
1380
1381 let t = match tables.table_for(year) {
1383 None => {
1384 let total: btctax_core::conventions::Usd = state
1385 .removals
1386 .iter()
1387 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1388 .flat_map(|r| r.legs.iter())
1389 .map(|leg| leg.fmv_at_transfer)
1390 .sum();
1391 return Some(format!(
1392 "Form 709 gift advisory ({year}): gift annual-exclusion table unavailable for \
1393 {year}; Form 709 exposure not evaluated — ${} in gifts recorded.",
1394 fmt_money(total)
1395 ));
1396 }
1397 Some(t) => t,
1398 };
1399 let excl = t.gift_annual_exclusion;
1400
1401 let mut labeled: BTreeMap<String, btctax_core::conventions::Usd> = BTreeMap::new();
1403 let mut unlabeled_count: usize = 0;
1404 let mut unlabeled_total: btctax_core::conventions::Usd = Default::default();
1405
1406 for r in state
1407 .removals
1408 .iter()
1409 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1410 {
1411 let fmv: btctax_core::conventions::Usd = r.legs.iter().map(|l| l.fmv_at_transfer).sum();
1412 match &r.donee {
1413 Some(label) => {
1414 *labeled.entry(label.clone()).or_default() += fmv;
1415 }
1416 None => {
1417 unlabeled_count += 1;
1418 unlabeled_total += fmv;
1419 }
1420 }
1421 }
1422
1423 let mut filing_required_donees: Vec<String> = Vec::new();
1425 let mut total_taxable: btctax_core::conventions::Usd = Default::default();
1426 let mut s = format!("Form 709 gift advisory ({year}):");
1427
1428 if !labeled.is_empty() {
1429 s.push_str(&format!(
1430 "\n§2503(b) per-donee annual exclusion analysis (TY{year}, exclusion ${}):",
1431 fmt_money(excl)
1432 ));
1433 for (donee, &total) in &labeled {
1434 let applied = if total < excl { total } else { excl };
1435 let taxable: btctax_core::conventions::Usd = if total > excl {
1436 total - excl
1437 } else {
1438 Default::default()
1439 };
1440 s.push_str(&format!(
1441 "\n {donee}: total ${}, exclusion applied ${}, taxable ${}",
1442 fmt_money(total),
1443 fmt_money(applied),
1444 fmt_money(taxable)
1445 ));
1446 if total > excl {
1447 filing_required_donees.push(donee.clone());
1448 total_taxable += taxable;
1449 }
1450 }
1451 if !filing_required_donees.is_empty() {
1452 s.push_str(&format!(
1453 "\nForm 709 filing required (donee(s): {}). Total taxable gifts: ${}.",
1454 filing_required_donees.join(", "),
1455 fmt_money(total_taxable)
1456 ));
1457 } else {
1458 s.push_str(&format!(
1459 "\nNo Form 709 filing required based on per-donee totals \
1460 (each ≤ ${} exclusion). Total taxable gifts: $0.00.",
1461 fmt_money(excl)
1462 ));
1463 }
1464 }
1465
1466 if unlabeled_count > 0 {
1467 s.push_str(&format!(
1468 "\nNOTE: {unlabeled_count} gift(s) totalling ${} have no donee label — the §2503(b) \
1469 annual exclusion is PER DONEE and cannot be applied without one; label them via \
1470 `reconcile reclassify-outflow --donee`. Shown as a single conservative aggregate.",
1471 fmt_money(unlabeled_total)
1472 ));
1473 if unlabeled_total > excl {
1476 s.push_str(&format!(
1477 "\n Conservative aggregate ${} > ${} (one exclusion); \
1478 verify per-donee totals after labelling.",
1479 fmt_money(unlabeled_total),
1480 fmt_money(excl)
1481 ));
1482 } else {
1483 s.push_str(&format!(
1484 "\n Conservative aggregate ${} \u{2264} ${} (one exclusion); \
1485 per-donee exposure unverifiable without labels.",
1486 fmt_money(unlabeled_total),
1487 fmt_money(excl)
1488 ));
1489 }
1490 }
1491
1492 let lifetime_excl = t.gift_lifetime_exclusion;
1496 let cumulative_taxable = prior_taxable_gifts + total_taxable;
1497
1498 if cumulative_taxable > btctax_core::conventions::Usd::ZERO {
1500 let remaining = if cumulative_taxable >= lifetime_excl {
1501 btctax_core::conventions::Usd::ZERO
1502 } else {
1503 lifetime_excl - cumulative_taxable
1504 };
1505 s.push_str(&format!(
1506 "\n§2505 lifetime (basic) exclusion: you have used ${} of your ${} ({year}) lifetime \
1507 exclusion (${} remaining). No gift tax is DUE until cumulative taxable gifts exceed \
1508 the lifetime exclusion.",
1509 fmt_money(cumulative_taxable),
1510 fmt_money(lifetime_excl),
1511 fmt_money(remaining),
1512 ));
1513 if cumulative_taxable > lifetime_excl {
1515 let excess = cumulative_taxable - lifetime_excl;
1516 s.push_str(&format!(
1517 "\nlifetime exclusion EXCEEDED — gift tax may be due on ${} \
1518 (the excess base past the unified credit, not a computed tax); \
1519 consult a professional.",
1520 fmt_money(excess),
1521 ));
1522 }
1523 if unlabeled_count > 0 {
1526 s.push_str(&format!(
1527 "\n§2505 consumption reflects LABELED-donee taxable gifts only; \
1528 {unlabeled_count} unlabeled gift(s) totalling ${} are NOT included — \
1529 label them via `--donee` for a complete figure; consumption may be \
1530 understated / remaining overstated.",
1531 fmt_money(unlabeled_total),
1532 ));
1533 }
1534 }
1535
1536 s.push_str(
1539 "\nCaveats: §2513 gift-splitting (MFJ) not modeled (single-filer advisory only); \
1540 future-interest gifts (which require Form 709 filing regardless of amount) not \
1541 detectable; §2505 figures are advisory only — no portability/DSUE (§2010(c)(4)) \
1542 applied; prior cumulative taxable gifts are user-supplied (default $0 if \
1543 --prior-taxable-gifts not given).",
1544 );
1545
1546 Some(s)
1547}
1548
1549fn picks_str(picks: &[btctax_core::LotPick]) -> String {
1555 if picks.is_empty() {
1556 return "(none)".to_string();
1557 }
1558 picks
1559 .iter()
1560 .map(|p| {
1561 format!(
1562 "{}#{}:{}",
1563 p.lot.origin_event_id.canonical(),
1564 p.lot.split_sequence,
1565 p.sat
1566 )
1567 })
1568 .collect::<Vec<_>>()
1569 .join(", ")
1570}
1571
1572pub fn render_optimize_proposal(p: &btctax_core::OptimizeProposal) -> String {
1585 use btctax_core::{ApproxReason, Persistability};
1586 let mut s = String::new();
1587 let _ = writeln!(
1588 s,
1589 "Optimize (what-if) — tax year {} — NOTHING is filed or bound by running this.",
1590 p.year
1591 );
1592 if p.approximate {
1594 let why = match p.approx_reason {
1595 Some(ApproxReason::ComboCapExceeded { combos, cap }) => format!(
1596 "input exceeded the exhaustive bound ({combos} combos > {cap}); \
1597 a coordinate-descent fallback ran"
1598 ),
1599 Some(ApproxReason::ContentionUnenumerated { contended, .. }) => format!(
1600 "{contended} contended same-wallet disposal(s) could not be fully joint-enumerated"
1601 ),
1602 Some(ApproxReason::PoolHeuristic { lots, bound }) => format!(
1603 "a pool of {lots} lots exceeds the {bound}-lot exhaustive-enumeration bound; \
1604 only a deterministic heuristic SUBSET of that pool's identifications was searched"
1605 ),
1606 None => "approximate".to_string(),
1607 };
1608 let _ = writeln!(
1609 s,
1610 " \u{26a0} APPROXIMATE \u{2014} NOT a guaranteed global minimum: {why}."
1611 );
1612 let _ = writeln!(
1613 s,
1614 " The true least-tax assignment may be lower; this is a disclosed improvement over your"
1615 );
1616 let _ = writeln!(
1617 s,
1618 " current filing position (delta \u{2264} 0), NOT \u{2018}the least tax.\u{2019}"
1619 );
1620 }
1621 let _ = writeln!(
1622 s,
1623 " current federal tax (attributable): {}",
1624 p.baseline_tax
1625 );
1626 let _ = writeln!(
1627 s,
1628 " optimized federal tax (attributable): {}",
1629 p.optimized_tax
1630 );
1631 let _ = writeln!(
1632 s,
1633 " delta (optimized \u{2212} current): {} (negative = saving; always \u{2264} 0)",
1634 p.delta
1635 );
1636 for d in &p.per_disposal {
1637 let _ = writeln!(
1638 s,
1639 " {} @ {} [{}] :: {}",
1640 d.disposal.canonical(),
1641 d.date,
1642 wallet_label(&d.wallet),
1643 compliance_status_tag(&d.status)
1644 );
1645 if d.proposed_selection == d.current_selection {
1650 let _ = writeln!(
1651 s,
1652 " proposed: {} [no change \u{2014} already optimal under current identification]",
1653 picks_str(&d.proposed_selection)
1654 );
1655 continue;
1656 }
1657 let persist = match d.persistable {
1658 Persistability::ContemporaneousNow => {
1659 "persistable now (made \u{2264} sale \u{2192} Contemporaneous)"
1660 }
1661 Persistability::NeedsAttestation => {
1662 "already executed \u{2014} needs `optimize accept --disposal <ref> \
1663 --attest \"\u{2026}\"` (genuine contemporaneous ID only)"
1664 }
1665 Persistability::ForbiddenBroker2027 => {
1666 "2027+ broker-held \u{2014} CANNOT be persisted (own-books insufficient); \
1667 FIFO is the defensible position"
1668 }
1669 };
1670 let _ = writeln!(
1671 s,
1672 " proposed: {} [{}]",
1673 picks_str(&d.proposed_selection),
1674 persist
1675 );
1676 }
1677 let _ = writeln!(
1679 s,
1680 " (vertex-granularity identification: a multi-partial split landing exactly on a \
1681 tax-bracket kink is out of scope.)"
1682 );
1683 let _ = writeln!(
1684 s,
1685 " (this is the tax IF you had identified thus; adequate ID must exist by the time \
1686 of sale \u{2014} \u{a7}1.1012-1(j))"
1687 );
1688 let _ = writeln!(
1690 s,
1691 " (scope: global over taxable-disposal lot selections; self-transfer lot routing is \
1692 held at its baseline position and is not re-optimized.)"
1693 );
1694 s
1695}
1696
1697pub fn render_accept_outcome(o: &crate::cmd::optimize::AcceptOutcome) -> String {
1702 let mut s = String::new();
1703 let _ = writeln!(
1704 s,
1705 "Optimize accept \u{2014} {} persisted, {} skipped.",
1706 o.persisted.len(),
1707 o.skipped.len()
1708 );
1709 for (disposal, decision, basis) in &o.persisted {
1710 let _ = writeln!(
1711 s,
1712 " PERSISTED {} \u{2192} LotSelection {} [{}]{}",
1713 disposal.canonical(),
1714 decision.canonical(),
1715 basis,
1716 if *basis == "AttestedRecording" {
1717 " (+ attestation recorded; revoke with `reconcile void`)"
1718 } else {
1719 " (revoke with `reconcile void`)"
1720 }
1721 );
1722 }
1723 for (disposal, reason) in &o.skipped {
1724 let _ = writeln!(s, " skipped {}: {}", disposal.canonical(), reason);
1725 }
1726 if o.persisted.is_empty() && o.skipped.is_empty() {
1727 let _ = writeln!(s, " (no disposals matched)");
1728 }
1729 s
1730}
1731
1732pub fn render_consult(r: &btctax_core::ConsultReport) -> String {
1742 let mut s = String::new();
1743 let _ = writeln!(
1744 s,
1745 "Consult (read-only what-if): sell {} sat from {} on {}",
1746 r.req.sell_sat,
1747 wallet_label(&r.req.wallet),
1748 r.req.at
1749 );
1750 if r.approximate {
1752 let _ = writeln!(
1753 s,
1754 " \u{26a0} heuristic \u{2014} searched a subset of a large (>12-lot) pool; \
1755 the proposed selection may not be the exact minimum."
1756 );
1757 }
1758 let _ = writeln!(
1759 s,
1760 " proposed selection: {}",
1761 picks_str(&r.proposed_selection)
1762 );
1763 let _ = writeln!(
1764 s,
1765 " short-term gain: {} long-term gain: {}",
1766 r.st_gain, r.lt_gain
1767 );
1768 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
1771 let _ = writeln!(
1772 s,
1773 " whole-year federal tax attributable (with this sale): {}",
1774 r.total_federal_tax_attributable
1775 );
1776 if let Some(t) = &r.timing {
1777 let _ = writeln!(
1778 s,
1779 " timing: {} sat of the best selection is short-term until {}; \
1780 selling on/after then would be taxed long-term, a \u{2248} {} difference.",
1781 t.st_sat_in_selection, t.latest_crossover, t.saving_if_waited
1782 );
1783 }
1784 let _ = writeln!(
1785 s,
1786 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
1787 not investment advice (no buy/sell/hold recommendation)."
1788 );
1789 s
1790}
1791
1792fn ltcg_bracket_label(b: LtcgBracket) -> &'static str {
1794 match b {
1795 LtcgBracket::Zero => "0%",
1796 LtcgBracket::Fifteen => "15%",
1797 LtcgBracket::Twenty => "20%",
1798 }
1799}
1800
1801pub fn render_whatif_sell(r: &SellReport, magi_caveat: bool) -> String {
1807 let mut s = String::new();
1808 let _ = writeln!(
1809 s,
1810 "What-if (read-only): sell {} sat from {} on {}",
1811 r.req.sell_sat,
1812 wallet_label(&r.req.wallet),
1813 r.req.at
1814 );
1815 if magi_caveat {
1816 let _ = writeln!(
1817 s,
1818 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
1819 );
1820 }
1821 let _ = writeln!(s, " proceeds: {}", r.proceeds);
1822 let _ = writeln!(s, " lots consumed:");
1823 for leg in &r.lots {
1824 let term = match leg.term {
1825 Term::ShortTerm => "ST",
1826 Term::LongTerm => "LT",
1827 };
1828 let _ = writeln!(
1829 s,
1830 " {}#{} {} sat basis {} {} \u{2192} {} {} gain {}",
1831 leg.lot_id.origin_event_id.canonical(),
1832 leg.lot_id.split_sequence,
1833 leg.sat,
1834 leg.basis,
1835 leg.acquired_at,
1836 leg.sold_at,
1837 term,
1838 leg.gain
1839 );
1840 }
1841 let _ = writeln!(
1842 s,
1843 " short-term gain: {} long-term gain: {}",
1844 r.st_gain, r.lt_gain
1845 );
1846 match r.bracket_room {
1848 Some(room) => {
1849 let _ = writeln!(
1850 s,
1851 " \u{00a7}1(h) LTCG bracket: {} (room {} before the next breakpoint)",
1852 ltcg_bracket_label(r.bracket),
1853 room
1854 );
1855 }
1856 None => {
1857 let _ = writeln!(
1858 s,
1859 " \u{00a7}1(h) LTCG bracket: {} (top bracket \u{2014} no headroom)",
1860 ltcg_bracket_label(r.bracket)
1861 );
1862 }
1863 }
1864 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
1866 match r.effective_rate {
1867 Some(rate) => {
1868 let _ = writeln!(s, " effective rate: {rate}");
1869 }
1870 None => {
1871 let _ = writeln!(
1872 s,
1873 " effective rate: n/a (a loss/zero-gain sale \u{2014} its value is the carryforward, \
1874 not this-year tax)"
1875 );
1876 }
1877 }
1878 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
1881 if carried != Usd::ZERO || r.ordinary_offset_delta != Usd::ZERO {
1882 let _ = writeln!(
1883 s,
1884 " \u{00a7}1212: {} offsets ordinary income this year, {} carried to next year \
1885 (short {} / long {})",
1886 r.ordinary_offset_delta, carried, r.carryforward_delta.short, r.carryforward_delta.long
1887 );
1888 }
1889 if r.niit_applies {
1891 let dir = if r.niit_incremental < Usd::ZERO {
1892 "decrease"
1893 } else {
1894 "increase"
1895 };
1896 let _ = writeln!(
1897 s,
1898 " \u{00a7}1411 NIIT: {} ({dir}) attributable to this sale",
1899 r.niit_incremental
1900 );
1901 }
1902 let status = match r.status {
1903 SellStatus::Gain => "net gain",
1904 SellStatus::Loss => "net loss (the carryforward is the value \u{2014} not this-year tax)",
1905 };
1906 let _ = writeln!(s, " status: {status}");
1907 let _ = writeln!(
1908 s,
1909 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
1910 not investment advice (no buy/sell/hold recommendation)."
1911 );
1912 s
1913}
1914
1915fn harvest_target_label(t: &HarvestTarget) -> String {
1917 match t {
1918 HarvestTarget::ZeroLtcg => {
1919 "zero-ltcg (all gain in the \u{00a7}1(h) 0% bracket)".to_string()
1920 }
1921 HarvestTarget::FifteenLtcg => "fifteen-ltcg (stay at/under 15%)".to_string(),
1922 HarvestTarget::Gain(x) => format!("gain \u{2264} {x}"),
1923 HarvestTarget::Tax(x) => format!("marginal tax \u{2264} {x}"),
1924 }
1925}
1926
1927pub fn render_whatif_harvest(r: &HarvestReport, magi_caveat: bool) -> String {
1933 let mut s = String::new();
1934 let _ = writeln!(
1935 s,
1936 "What-if HARVEST (read-only): {} from {} on {}",
1937 harvest_target_label(&r.req.target),
1938 wallet_label(&r.req.wallet),
1939 r.req.at
1940 );
1941 if magi_caveat {
1942 let _ = writeln!(
1943 s,
1944 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
1945 );
1946 }
1947 let status = match &r.status {
1948 HarvestStatus::Found => "FOUND (the target binds)",
1949 HarvestStatus::NotBinding => "NOT BINDING (the whole position fits)",
1950 HarvestStatus::AlreadyBreached => "ALREADY BREACHED at N=0 (nothing can be harvested)",
1951 HarvestStatus::NoLots => "NO LOTS (nothing to harvest from that wallet)",
1952 HarvestStatus::ProceedsRequired => "PROCEEDS REQUIRED",
1953 HarvestStatus::PreTransitionYear => "PRE-2025 (a closed year, not a plan)",
1954 HarvestStatus::YearNotComputable(_) => "YEAR NOT COMPUTABLE",
1955 };
1956 let _ = writeln!(s, " status: {status}");
1957 let _ = writeln!(s, " \u{2192} sell up to {} BTC ({} sat)", r.n_btc, r.n_sat);
1958 let _ = writeln!(s, " bound by: {}", r.binding_constraint);
1959 let _ = writeln!(
1960 s,
1961 " realized gain at N*: short-term {} long-term {}",
1962 r.st_gain, r.lt_gain
1963 );
1964 let ps = &r.with_result.pref_split;
1966 let bracket = if ps.at_20 > Usd::ZERO {
1967 "20%"
1968 } else if ps.at_15 > Usd::ZERO {
1969 "15%"
1970 } else {
1971 "0%"
1972 };
1973 let _ = writeln!(
1974 s,
1975 " \u{00a7}1(h) preferential dollars at N*: {} in 0% / {} in 15% / {} in 20% (top bracket: {})",
1976 ps.at_0, ps.at_15, ps.at_20, bracket
1977 );
1978 let _ = writeln!(s, " marginal federal tax at N*: {}", r.marginal_tax);
1979 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
1981 if carried != Usd::ZERO {
1982 let dir = if carried < Usd::ZERO {
1983 "burned (spent)"
1984 } else {
1985 "carried to next year"
1986 };
1987 let _ = writeln!(
1988 s,
1989 " \u{00a7}1212 carryforward {}: {} (short {} / long {})",
1990 dir, carried, r.carryforward_delta.short, r.carryforward_delta.long
1991 );
1992 }
1993 if r.niit_applies {
1995 let dir = if r.niit_incremental < Usd::ZERO {
1996 "decrease"
1997 } else {
1998 "increase"
1999 };
2000 let _ = writeln!(
2001 s,
2002 " \u{00a7}1411 NIIT: {} ({dir}) at N* \u{2014} the +3.8% kink applies even inside a 0%/15% bracket answer",
2003 r.niit_incremental
2004 );
2005 }
2006 if let Some(note) = &r.plateau_note {
2007 let _ = writeln!(s, " \u{2139} {note}");
2008 }
2009 let _ = writeln!(
2010 s,
2011 "Tax decision-support only \u{2014} the engine-verified consequences of a contemplated harvest; \
2012 not investment advice (no buy/sell/hold recommendation)."
2013 );
2014 s
2015}
2016
2017pub fn render_verify(r: &VerifyReport) -> String {
2018 let mut out = String::new();
2019 let c = &r.conservation;
2020 let _ = writeln!(
2021 out,
2022 "Conservation (FR9): {}",
2023 if c.balanced { "BALANCED" } else { "DRIFT" }
2024 );
2025 let _ = writeln!(
2026 out,
2027 " in {} = disposed {} + removed {} + held {} + fee-sats {} + pending {}{}",
2028 c.sigma_in,
2029 c.sigma_disposed,
2030 c.sigma_removed,
2031 c.sigma_held,
2032 c.sigma_fee_sats,
2033 c.sigma_pending,
2034 if c.has_uncovered {
2035 " [identity undefined: uncovered disposal open]"
2036 } else {
2037 ""
2038 }
2039 );
2040 let _ = writeln!(out, "2025 transition: {}", r.safe_harbor);
2041 let _ = writeln!(
2042 out,
2043 "Pending reconciliation: {} transfer(s); unknown-basis inbounds: {}",
2044 r.pending, r.unknown_basis_inbounds
2045 );
2046
2047 let _ = writeln!(
2048 out,
2049 "Hard blockers (gate tax computation): {}",
2050 r.hard.len()
2051 );
2052 for b in &r.hard {
2053 let evt = b
2054 .event
2055 .as_ref()
2056 .map(|e| e.canonical())
2057 .unwrap_or_else(|| "-".to_string());
2058 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2059 if b.kind == BlockerKind::FmvMissing {
2062 let _ = writeln!(out, " ↳ {}", crate::price_cache::UPDATE_PRICES_HINT);
2063 }
2064 }
2065 let _ = writeln!(out, "Advisory blockers: {}", r.advisory.len());
2066 for b in &r.advisory {
2067 let evt = b
2068 .event
2069 .as_ref()
2070 .map(|e| e.canonical())
2071 .unwrap_or_else(|| "-".to_string());
2072 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2073 }
2074 let _ = writeln!(
2075 out,
2076 "Pre-2025 method (attested historical fact): {} (attested: {})",
2077 lot_method_display(r.declared_pre2025_method),
2078 r.pre2025_method_attested
2079 );
2080 let _ = writeln!(
2081 out,
2082 "Standing orders (MethodElection): {}",
2083 r.elections.len()
2084 );
2085 for e in &r.elections {
2086 let _ = writeln!(
2087 out,
2088 " recorded {} effective {} -> {} [{}]",
2089 e.recorded,
2090 e.effective_from,
2091 lot_method_display(e.method),
2092 e.note
2093 );
2094 }
2095 let _ = writeln!(out, "Lot selections recorded: {}", r.selection_count);
2096 let _ = writeln!(
2097 out,
2098 "Per-disposal compliance (post-2025): {}",
2099 r.compliance.len()
2100 );
2101 for c in &r.compliance {
2102 let _ = writeln!(
2103 out,
2104 " {} @ {} :: {}",
2105 c.disposal.canonical(),
2106 c.date,
2107 compliance_status_tag(&c.status)
2108 );
2109 }
2110 out
2111}
2112
2113#[cfg(test)]
2114mod gift_advisory_tests {
2115 use super::*;
2121 use btctax_core::conventions::Usd;
2122 use btctax_core::{EventId, LotId, Removal, RemovalLeg, TaxTable};
2123 use rust_decimal_macros::dec;
2124 use std::collections::BTreeMap;
2125 use time::macros::date;
2126
2127 fn gift_removal(seq: u64, removed_at: TaxDate, fmv: Usd) -> Removal {
2129 Removal {
2130 event: EventId::decision(seq),
2131 kind: RemovalKind::Gift,
2132 removed_at,
2133 legs: vec![RemovalLeg {
2134 lot_id: LotId {
2135 origin_event_id: EventId::decision(seq),
2136 split_sequence: 0,
2137 },
2138 sat: 100,
2139 basis: dec!(0),
2140 fmv_at_transfer: fmv,
2141 term: Term::LongTerm,
2142 basis_source: BasisSource::ComputedFromCost,
2143 acquired_at: date!(2024 - 01 - 01),
2144 pseudo: false,
2145 }],
2146 appraisal_required: false,
2147 donor_acquired_at: None,
2148 claimed_deduction: None,
2149 donee: None,
2150 }
2151 }
2152 fn gift_removal_labeled(seq: u64, removed_at: TaxDate, fmv: Usd, label: &str) -> Removal {
2154 Removal {
2155 donee: Some(label.to_string()),
2156 ..gift_removal(seq, removed_at, fmv)
2157 }
2158 }
2159 fn state_with(removals: Vec<Removal>) -> LedgerState {
2160 LedgerState {
2161 removals,
2162 ..Default::default()
2163 }
2164 }
2165 fn tables_with(year: i32, excl: Usd) -> BTreeMap<i32, TaxTable> {
2169 tables_with_lifetime(year, excl, dec!(13_990_000))
2170 }
2171
2172 fn tables_with_lifetime(year: i32, excl: Usd, lifetime_excl: Usd) -> BTreeMap<i32, TaxTable> {
2174 let mut m = BTreeMap::new();
2175 m.insert(
2176 year,
2177 TaxTable {
2178 year,
2179 source: "TEST",
2180 ordinary: BTreeMap::new(),
2181 ltcg: BTreeMap::new(),
2182 gift_annual_exclusion: excl,
2183 ss_wage_base: dec!(176100),
2184 gift_lifetime_exclusion: lifetime_excl,
2185 },
2186 );
2187 m
2188 }
2189
2190 #[test]
2194 fn no_gifts_is_none() {
2195 let st = state_with(vec![]);
2196 let tables = tables_with(2025, dec!(19000));
2197 assert!(render_gift_advisory(&st, 2025, dec!(0), &tables).is_none());
2198 }
2199
2200 #[test]
2203 fn gifts_present_but_no_table_emits_note_not_none() {
2204 let st = state_with(vec![gift_removal(1, date!(2026 - 06 - 01), dec!(50000))]);
2206 let tables = tables_with(2025, dec!(19000));
2208 let msg =
2209 render_gift_advisory(&st, 2026, dec!(0), &tables).expect("note expected, not None");
2210 assert!(msg.contains("unavailable"), "{msg}");
2211 assert!(msg.contains("Form 709 exposure not evaluated"), "{msg}");
2212 assert!(
2213 msg.contains("50000.00"),
2214 "must record the gift total: {msg}"
2215 );
2216 }
2217
2218 #[test]
2224 fn labeled_donee_over_exclusion_emits_advisory() {
2225 let st = state_with(vec![gift_removal_labeled(
2226 1,
2227 date!(2025 - 06 - 01),
2228 dec!(20000),
2229 "Alice",
2230 )]);
2231 let tables = tables_with(2025, dec!(19000));
2232 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2233 assert!(msg.contains("20000.00"), "must show Alice's total: {msg}");
2234 assert!(msg.contains("19000.00"), "must show the exclusion: {msg}");
2235 assert!(
2236 msg.contains("Form 709 filing required"),
2237 "must flag filing required: {msg}"
2238 );
2239 assert!(msg.contains("Alice"), "must name Alice: {msg}");
2240 assert!(msg.contains("1000.00"), "taxable must be $1000.00: {msg}");
2242 assert!(
2244 !msg.contains("donee identity is not modeled"),
2245 "stale aggregate caveat must not appear: {msg}"
2246 );
2247 }
2248
2249 #[test]
2252 fn labeled_donee_under_exclusion_no_filing_required() {
2253 let st = state_with(vec![gift_removal_labeled(
2254 1,
2255 date!(2025 - 06 - 01),
2256 dec!(10000),
2257 "Alice",
2258 )]);
2259 let tables = tables_with(2025, dec!(19000));
2260 let msg =
2262 render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected, not None");
2263 assert!(
2264 msg.contains("No Form 709 filing required"),
2265 "must say no filing required: {msg}"
2266 );
2267 assert!(msg.contains("Alice"), "must mention Alice: {msg}");
2268 assert!(msg.contains("10000.00"), "must show Alice's total: {msg}");
2269 }
2270
2271 #[test]
2277 fn per_donee_under_exclusion_two_donees_no_filing_required() {
2278 let st = state_with(vec![
2279 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(15000), "Alice"),
2280 gift_removal_labeled(2, date!(2025 - 06 - 01), dec!(15000), "Bob"),
2281 ]);
2282 let tables = tables_with(2025, dec!(19000));
2283 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2284 assert!(
2286 msg.contains("No Form 709 filing required"),
2287 "neither donee exceeds exclusion → no filing required: {msg}"
2288 );
2289 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2291 assert!(msg.contains("Bob"), "Bob must appear: {msg}");
2292 assert!(msg.contains("15000.00"), "donee total must appear: {msg}");
2294 assert!(
2296 !msg.contains("Form 709 filing required (donee(s):"),
2297 "filing trigger must NOT fire: {msg}"
2298 );
2299 assert!(
2301 msg.contains("Total taxable gifts: $0.00"),
2302 "total taxable must be $0.00: {msg}"
2303 );
2304 }
2305
2306 #[test]
2309 fn one_donee_over_exclusion_filing_required() {
2310 let st = state_with(vec![gift_removal_labeled(
2311 1,
2312 date!(2025 - 06 - 01),
2313 dec!(25000),
2314 "Alice",
2315 )]);
2316 let tables = tables_with(2025, dec!(19000));
2317 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2318 assert!(
2319 msg.contains("Form 709 filing required (donee(s): Alice)"),
2320 "must trigger filing required for Alice: {msg}"
2321 );
2322 assert!(msg.contains("25000.00"), "Alice total must appear: {msg}");
2323 assert!(
2324 msg.contains("19000.00"),
2325 "exclusion applied must appear: {msg}"
2326 );
2327 assert!(
2329 msg.contains("6000.00"),
2330 "taxable $6,000.00 must appear: {msg}"
2331 );
2332 }
2333
2334 #[test]
2337 fn unlabeled_bucket_caveat_with_conservative_aggregate() {
2338 let st = state_with(vec![gift_removal(1, date!(2025 - 06 - 01), dec!(30000))]);
2339 let tables = tables_with(2025, dec!(19000));
2340 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2341 assert!(
2343 msg.contains("no donee label"),
2344 "unlabeled caveat must appear: {msg}"
2345 );
2346 assert!(
2347 msg.contains("30000.00"),
2348 "unlabeled total must appear: {msg}"
2349 );
2350 assert!(
2352 msg.contains("Conservative aggregate"),
2353 "conservative aggregate signal must appear: {msg}"
2354 );
2355 assert!(
2356 msg.contains("19000.00"),
2357 "one-exclusion comparison must appear: {msg}"
2358 );
2359 assert!(
2361 !msg.contains("Form 709 filing required (donee(s):"),
2362 "labeled filing trigger must NOT fire for unlabeled gifts: {msg}"
2363 );
2364 }
2365
2366 #[test]
2369 fn mixed_labeled_over_and_unlabeled_shows_both() {
2370 let st = state_with(vec![
2371 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(25000), "Alice"),
2372 gift_removal(2, date!(2025 - 06 - 01), dec!(5000)), ]);
2374 let tables = tables_with(2025, dec!(19000));
2375 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2376 assert!(
2378 msg.contains("Form 709 filing required"),
2379 "filing required for Alice: {msg}"
2380 );
2381 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2382 assert!(
2384 msg.contains("no donee label"),
2385 "unlabeled caveat must appear: {msg}"
2386 );
2387 assert!(
2388 msg.contains("5000.00"),
2389 "unlabeled total must appear: {msg}"
2390 );
2391 }
2392
2393 #[test]
2396 fn donations_excluded_from_form709_advisory() {
2397 let donation_removal = Removal {
2398 event: EventId::decision(1),
2399 kind: RemovalKind::Donation,
2400 removed_at: date!(2025 - 06 - 01),
2401 legs: vec![RemovalLeg {
2402 lot_id: LotId {
2403 origin_event_id: EventId::decision(1),
2404 split_sequence: 0,
2405 },
2406 sat: 100,
2407 basis: dec!(0),
2408 fmv_at_transfer: dec!(50000), term: Term::LongTerm,
2410 basis_source: BasisSource::ComputedFromCost,
2411 acquired_at: date!(2024 - 01 - 01),
2412 pseudo: false,
2413 }],
2414 appraisal_required: false,
2415 donor_acquired_at: None,
2416 claimed_deduction: Some(dec!(50000)),
2417 donee: Some("Charity X".to_string()),
2418 };
2419 let st = state_with(vec![donation_removal]);
2420 let tables = tables_with(2025, dec!(19000));
2421 assert!(
2423 render_gift_advisory(&st, 2025, dec!(0), &tables).is_none(),
2424 "Donation must be excluded from the Form 709 advisory"
2425 );
2426 }
2427
2428 #[test]
2434 fn section_2505_under_lifetime_shows_used_and_remaining() {
2435 let st = state_with(vec![gift_removal_labeled(
2436 1,
2437 date!(2025 - 06 - 01),
2438 dec!(100000),
2439 "Alice",
2440 )]);
2441 let tables = tables_with(2025, dec!(19000)); let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2443 assert!(
2445 msg.contains("81000.00"),
2446 "taxable $81,000 must appear: {msg}"
2447 );
2448 assert!(
2450 msg.contains("§2505 lifetime (basic) exclusion"),
2451 "§2505 block must appear: {msg}"
2452 );
2453 assert!(
2454 msg.contains("13990000.00"),
2455 "lifetime exclusion $13,990,000 must appear: {msg}"
2456 );
2457 assert!(
2459 msg.contains("13909000.00"),
2460 "remaining $13,909,000 must appear: {msg}"
2461 );
2462 assert!(
2464 !msg.contains("EXCEEDED"),
2465 "must NOT say EXCEEDED when under limit: {msg}"
2466 );
2467 assert!(
2469 !msg.contains("later chunk (Chunk 3)"),
2470 "stale Chunk-3 caveat must be absent: {msg}"
2471 );
2472 }
2473
2474 #[test]
2477 fn section_2505_prior_gifts_accumulate() {
2478 let st = state_with(vec![gift_removal_labeled(
2479 1,
2480 date!(2025 - 06 - 01),
2481 dec!(100000),
2482 "Alice",
2483 )]);
2484 let tables = tables_with(2025, dec!(19000));
2485 let msg =
2486 render_gift_advisory(&st, 2025, dec!(13_900_000), &tables).expect("advisory expected");
2487 assert!(
2489 msg.contains("13981000.00"),
2490 "cumulative $13,981,000 must appear: {msg}"
2491 );
2492 assert!(
2494 msg.contains("9000.00"),
2495 "remaining $9,000 must appear: {msg}"
2496 );
2497 assert!(
2498 !msg.contains("EXCEEDED"),
2499 "must NOT say EXCEEDED when under limit: {msg}"
2500 );
2501 }
2502
2503 #[test]
2507 fn section_2505_exceeds_lifetime_shows_exceeded_and_excess() {
2508 let st = state_with(vec![gift_removal_labeled(
2509 1,
2510 date!(2025 - 06 - 01),
2511 dec!(100000),
2512 "Alice",
2513 )]);
2514 let tables = tables_with(2025, dec!(19000));
2515 let msg =
2516 render_gift_advisory(&st, 2025, dec!(13_950_000), &tables).expect("advisory expected");
2517 assert!(
2518 msg.contains("14031000.00"),
2519 "cumulative $14,031,000 must appear: {msg}"
2520 );
2521 assert!(
2522 msg.contains("EXCEEDED"),
2523 "must say EXCEEDED when over lifetime limit: {msg}"
2524 );
2525 assert!(
2527 msg.contains("41000.00"),
2528 "excess $41,000 must appear: {msg}"
2529 );
2530 }
2531
2532 #[test]
2536 fn section_2505_exact_boundary_remaining_zero_not_exceeded() {
2537 let st = state_with(vec![gift_removal_labeled(
2538 1,
2539 date!(2025 - 06 - 01),
2540 dec!(100000),
2541 "Alice",
2542 )]);
2543 let tables = tables_with(2025, dec!(19000));
2544 let msg =
2546 render_gift_advisory(&st, 2025, dec!(13_909_000), &tables).expect("advisory expected");
2547 assert!(
2548 msg.contains("13990000.00"),
2549 "cumulative $13,990,000 must appear: {msg}"
2550 );
2551 assert!(
2553 msg.contains("($0.00 remaining)"),
2554 "remaining $0.00 in exact phrasing '($0.00 remaining)' must appear: {msg}"
2555 );
2556 assert!(
2558 !msg.contains("EXCEEDED"),
2559 "must NOT say EXCEEDED at exactly the limit: {msg}"
2560 );
2561 }
2562
2563 #[test]
2567 fn section_2505_prior_only_block_shows_even_when_current_taxable_zero() {
2568 let st = state_with(vec![gift_removal_labeled(
2569 1,
2570 date!(2025 - 06 - 01),
2571 dec!(10000), "Alice",
2573 )]);
2574 let tables = tables_with(2025, dec!(19000));
2575 let msg =
2576 render_gift_advisory(&st, 2025, dec!(5_000_000), &tables).expect("advisory expected");
2577 assert!(
2579 msg.contains("5000000.00"),
2580 "cumulative $5,000,000 must appear: {msg}"
2581 );
2582 assert!(
2583 msg.contains("§2505 lifetime (basic) exclusion"),
2584 "§2505 block must appear for prior-only case: {msg}"
2585 );
2586 assert!(!msg.contains("EXCEEDED"), "must NOT say EXCEEDED: {msg}");
2587 }
2588
2589 #[test]
2592 fn section_2505_no_block_when_cumulative_zero() {
2593 let st = state_with(vec![gift_removal_labeled(
2594 1,
2595 date!(2025 - 06 - 01),
2596 dec!(10000), "Alice",
2598 )]);
2599 let tables = tables_with(2025, dec!(19000));
2600 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2601 assert!(
2602 !msg.contains("§2505 lifetime"),
2603 "§2505 block must NOT appear when cumulative = 0: {msg}"
2604 );
2605 }
2606
2607 #[test]
2609 fn section_2505_default_zero_prior_shows_caveats() {
2610 let st = state_with(vec![gift_removal_labeled(
2611 1,
2612 date!(2025 - 06 - 01),
2613 dec!(100000), "Alice",
2615 )]);
2616 let tables = tables_with(2025, dec!(19000));
2617 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2618 assert!(
2620 !msg.contains("later chunk (Chunk 3)"),
2621 "stale 'later chunk (Chunk 3)' must be absent: {msg}"
2622 );
2623 assert!(
2625 msg.contains("§2513 gift-splitting"),
2626 "§2513 caveat must be present: {msg}"
2627 );
2628 assert!(
2629 msg.contains("portability/DSUE"),
2630 "portability/DSUE caveat must be present: {msg}"
2631 );
2632 assert!(
2633 msg.contains("prior cumulative taxable gifts are user-supplied"),
2634 "prior-cumulative disclosure caveat must be present: {msg}"
2635 );
2636 }
2637
2638 #[test]
2641 fn section_2505_mixed_shows_omission_disclosure_for_unlabeled() {
2642 let st = state_with(vec![
2643 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(100000), "Alice"),
2644 gift_removal(2, date!(2025 - 06 - 01), dec!(50000)), ]);
2646 let tables = tables_with(2025, dec!(19000));
2647 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2648 assert!(
2650 msg.contains("§2505 lifetime (basic) exclusion"),
2651 "§2505 block must appear: {msg}"
2652 );
2653 assert!(
2654 msg.contains("81000.00"),
2655 "used $81,000 (labeled only) must appear: {msg}"
2656 );
2657 assert!(
2659 msg.contains("§2505 consumption reflects LABELED-donee taxable gifts only"),
2660 "omission disclosure must appear: {msg}"
2661 );
2662 assert!(
2663 msg.contains("50000.00"),
2664 "unlabeled total $50,000 must appear in omission disclosure: {msg}"
2665 );
2666 assert!(
2667 msg.contains("consumption may be understated"),
2668 "under-stated warning must appear: {msg}"
2669 );
2670 }
2671
2672 #[test]
2674 fn section_2505_stale_chunk3_caveat_is_absent() {
2675 let st = state_with(vec![gift_removal_labeled(
2676 1,
2677 date!(2025 - 06 - 01),
2678 dec!(20000),
2679 "Alice",
2680 )]);
2681 let tables = tables_with(2025, dec!(19000));
2682 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2683 assert!(
2684 !msg.contains("later chunk (Chunk 3)"),
2685 "stale Chunk-3 caveat must be absent: {msg}"
2686 );
2687 assert!(
2688 !msg.contains("§2505 lifetime exemption is a later chunk"),
2689 "stale §2505 future-chunk phrase must be absent: {msg}"
2690 );
2691 }
2692}
2693
2694#[cfg(test)]
2695mod schedule_se_tests {
2696 use super::*;
2700 use rust_decimal_macros::dec;
2701
2702 fn golden1() -> SeTaxResult {
2704 SeTaxResult {
2705 net_se: dec!(100000),
2706 base: dec!(92350.00),
2707 ss: dec!(11451.40),
2708 medicare: dec!(2678.15),
2709 addl: dec!(0.00),
2710 total: dec!(14129.55),
2711 deductible_half: dec!(7064.78),
2712 }
2713 }
2714
2715 fn w2_headline() -> SeTaxResult {
2717 SeTaxResult {
2718 net_se: dec!(100000),
2719 base: dec!(92350.00),
2720 ss: dec!(3236.40),
2721 medicare: dec!(2678.15),
2722 addl: dec!(381.15),
2723 total: dec!(6295.70),
2724 deductible_half: dec!(2957.28),
2725 }
2726 }
2727
2728 fn w2_asymmetric() -> SeTaxResult {
2730 SeTaxResult {
2731 net_se: dec!(100000),
2732 base: dec!(92350.00),
2733 ss: dec!(3236.40),
2734 medicare: dec!(2678.15),
2735 addl: dec!(0.00),
2736 total: dec!(5914.55),
2737 deductible_half: dec!(2957.28),
2738 }
2739 }
2740
2741 fn expenses_headline() -> SeTaxResult {
2746 SeTaxResult {
2747 net_se: dec!(80000),
2748 base: dec!(73880.00),
2749 ss: dec!(9161.12),
2750 medicare: dec!(2142.52),
2751 addl: dec!(0.00),
2752 total: dec!(11303.64),
2753 deductible_half: dec!(5651.82),
2754 }
2755 }
2756
2757 fn expenses_w2_combined() -> SeTaxResult {
2767 SeTaxResult {
2768 net_se: dec!(80000),
2769 base: dec!(73880.00),
2770 ss: dec!(3236.40),
2771 medicare: dec!(2142.52),
2772 addl: dec!(214.92),
2773 total: dec!(5593.84),
2774 deductible_half: dec!(2689.46),
2775 }
2776 }
2777
2778 #[test]
2782 fn business_mining_year_renders_full_section() {
2783 let r = golden1();
2784 let s = render_schedule_se(
2785 2025,
2786 Some(&r),
2787 dec!(100000),
2788 true,
2789 Usd::ZERO,
2790 Usd::ZERO,
2791 Usd::ZERO,
2792 )
2793 .expect("SE section expected");
2794 assert!(s.contains("92350.00"), "net SE earnings base: {s}");
2796 assert!(s.contains("11451.40"), "SS component: {s}");
2797 assert!(s.contains("2678.15"), "Medicare component: {s}");
2798 assert!(s.contains("14129.55"), "total SE tax: {s}");
2799 assert!(s.contains("7064.78"), "§164(f) deductible half: {s}");
2800 assert!(
2801 s.contains("Additional Medicare"),
2802 "addl component labeled: {s}"
2803 );
2804 assert!(
2806 s.contains("$0 W-2 wages"),
2807 "short $0-W-2 note must appear (both W-2 = 0): {s}"
2808 );
2809 assert!(
2810 s.contains("--w2-ss-wages"),
2811 "$0 note must mention --w2-ss-wages flag: {s}"
2812 );
2813 assert!(
2814 !s.contains("OVERSTATED"),
2815 "old OVERSTATED text must be absent (Chunk A regression): {s}"
2816 );
2817 assert!(
2818 !s.contains("UNDERSTATED"),
2819 "old UNDERSTATED text must be absent (Chunk A regression): {s}"
2820 );
2821 assert!(
2823 s.contains("NOT auto-coordinated"),
2824 "§164(f) advisory must appear: {s}"
2825 );
2826 assert!(
2827 s.contains("coordinate it on your actual return"),
2828 "§164(f) advisory must include coordination instruction: {s}"
2829 );
2830 assert!(
2832 s.contains("SEPARATE federal liability"),
2833 "standalone note: {s}"
2834 );
2835 assert!(
2836 s.contains("not") && s.contains("§164(f)"),
2837 "notes §164(f) not auto-coordinated: {s}"
2838 );
2839 assert!(
2841 s.contains("no Schedule C expenses supplied"),
2842 "Chunk B $0-expenses note must appear: {s}"
2843 );
2844 assert!(
2845 s.contains("--schedule-c-expenses"),
2846 "$0 note must mention --schedule-c-expenses flag: {s}"
2847 );
2848 assert!(
2849 !s.contains("not modeled"),
2850 "old 'not modeled' caveat must be absent (replaced by Chunk B): {s}"
2851 );
2852 }
2853
2854 #[test]
2856 fn w2_set_renders_coordinated_disclosure() {
2857 let r = w2_headline();
2858 let s = render_schedule_se(
2859 2025,
2860 Some(&r),
2861 dec!(100000),
2862 true,
2863 Usd::ZERO,
2864 dec!(150000),
2865 dec!(150000),
2866 )
2867 .expect("SE section expected");
2868 assert!(
2870 s.contains("W-2 coordination applied"),
2871 "coordinated disclosure must appear: {s}"
2872 );
2873 assert!(
2874 s.contains("§1401(b)(2)(B)"),
2875 "must cite §1401(b)(2)(B): {s}"
2876 );
2877 assert!(
2878 s.contains("Form 8959 Part II"),
2879 "must cite Form 8959 Part II: {s}"
2880 );
2881 assert!(s.contains("150000"), "w2_ss_wages amount must appear: {s}");
2883 assert!(!s.contains("OVERSTATED"), "OVERSTATED must be absent: {s}");
2885 assert!(
2886 !s.contains("UNDERSTATED"),
2887 "UNDERSTATED must be absent: {s}"
2888 );
2889 assert!(s.contains("3236.40"), "reduced SS component: {s}");
2891 assert!(s.contains("381.15"), "non-zero addl: {s}");
2892 assert!(s.contains("6295.70"), "reduced total: {s}");
2893 assert!(s.contains("2957.28"), "deductible_half: {s}");
2894 }
2895
2896 #[test]
2900 fn w2_asymmetric_render_transposition_guard() {
2901 let r = w2_asymmetric();
2902 let s = render_schedule_se(
2903 2025,
2904 Some(&r),
2905 dec!(100000),
2906 true,
2907 Usd::ZERO,
2908 dec!(150000),
2909 Usd::ZERO,
2910 )
2911 .expect("SE section expected");
2912 assert!(
2914 s.contains("W-2 coordination applied"),
2915 "coordinated disclosure must appear: {s}"
2916 );
2917 assert!(s.contains("3236.40"), "ss must be 3236.40 (reduced): {s}");
2919 assert!(
2920 s.contains("0.00"),
2921 "addl must be 0.00 (threshold un-reduced): {s}"
2922 );
2923 assert!(!s.contains("OVERSTATED"), "{s}");
2925 assert!(!s.contains("UNDERSTATED"), "{s}");
2926 }
2927
2928 #[test]
2930 fn no_business_income_no_section() {
2931 assert!(
2932 render_schedule_se(2025, None, Usd::ZERO, true, Usd::ZERO, Usd::ZERO, Usd::ZERO)
2933 .is_none()
2934 );
2935 }
2936
2937 #[test]
2940 fn business_income_but_no_table_emits_note() {
2941 let s = render_schedule_se(
2942 2099,
2943 None,
2944 dec!(100000),
2945 false,
2946 Usd::ZERO,
2947 Usd::ZERO,
2948 Usd::ZERO,
2949 )
2950 .expect("wage-base-unavailable note expected");
2951 assert!(s.contains("SS wage base unavailable"), "{s}");
2952 assert!(s.contains("2099"), "names the year: {s}");
2953 assert!(s.contains("no silent drop"), "{s}");
2954 }
2955
2956 #[test]
2961 fn expenses_20k_no_w2_renders_breakout_and_advisory() {
2962 let r = expenses_headline(); let s = render_schedule_se(
2964 2025,
2965 Some(&r),
2966 dec!(100000), true,
2968 dec!(20000), Usd::ZERO,
2970 Usd::ZERO,
2971 )
2972 .expect("SE section expected");
2973 assert!(
2975 s.contains("gross business income"),
2976 "breakout line must appear: {s}"
2977 );
2978 assert!(
2979 s.contains("100000.00"),
2980 "gross ($100k) must appear in breakout: {s}"
2981 );
2982 assert!(
2983 s.contains("20000.00"),
2984 "expenses ($20k) must appear in breakout: {s}"
2985 );
2986 assert!(
2987 s.contains("80000.00"),
2988 "net_se ($80k) must appear in breakout: {s}"
2989 );
2990 assert!(
2992 s.contains("OVERSTATES"),
2993 "Schedule C advisory OVERSTATES text: {s}"
2994 );
2995 assert!(
2996 s.contains("ORDINARY taxable income"),
2997 "advisory must mention ORDINARY taxable income: {s}"
2998 );
2999 assert!(
3000 s.contains("engine-side coordination is deferred"),
3001 "advisory must mention deferred coordination: {s}"
3002 );
3003 assert!(
3005 !s.contains("reduce your ordinary_taxable_income"),
3006 "NO OTI-edit prescription allowed (spec D3): {s}"
3007 );
3008 assert!(
3009 !s.contains("set --ordinary-taxable-income"),
3010 "NO OTI-edit prescription allowed (spec D3): {s}"
3011 );
3012 assert!(s.contains("73880.00"), "base $73,880: {s}");
3014 assert!(s.contains("9161.12"), "ss $9,161.12: {s}");
3015 assert!(s.contains("2142.52"), "medicare $2,142.52: {s}");
3016 assert!(s.contains("11303.64"), "total $11,303.64: {s}");
3017 assert!(s.contains("5651.82"), "deductible_half $5,651.82: {s}");
3018 assert!(
3020 !s.contains("not modeled"),
3021 "old 'not modeled' caveat must be absent: {s}"
3022 );
3023 }
3024
3025 #[test]
3028 fn fully_expensed_shows_new_line_not_wage_base_note() {
3029 let s = render_schedule_se(
3032 2025,
3033 None,
3034 dec!(10000), true, dec!(15000), Usd::ZERO,
3038 Usd::ZERO,
3039 )
3040 .expect("fully-expensed section expected (not None)");
3041 assert!(
3043 s.contains("fully expensed"),
3044 "fully-expensed line must appear: {s}"
3045 );
3046 assert!(
3047 s.contains("10000.00"),
3048 "gross $10k must appear in fully-expensed line: {s}"
3049 );
3050 assert!(
3051 s.contains("15000.00"),
3052 "expenses $15k must appear in fully-expensed line: {s}"
3053 );
3054 assert!(
3055 s.contains("no §1401 SE tax"),
3056 "must state no SE tax owed: {s}"
3057 );
3058 assert!(s.contains("2025"), "must name the year: {s}");
3059 assert!(
3061 !s.contains("SS wage base unavailable"),
3062 "wage-base-unavailable note must be ABSENT for fully-expensed case: {s}"
3063 );
3064 }
3065
3066 #[test]
3068 fn expenses_w2_combined_renders_both() {
3069 let r = expenses_w2_combined(); let s = render_schedule_se(
3071 2025,
3072 Some(&r),
3073 dec!(100000),
3074 true,
3075 dec!(20000), dec!(150000), dec!(150000), )
3079 .expect("SE section expected");
3080 assert!(s.contains("gross business income"), "breakout line: {s}");
3082 assert!(s.contains("80000.00"), "net_se in breakout: {s}");
3083 assert!(s.contains("OVERSTATES"), "Schedule C advisory: {s}");
3085 assert!(
3087 s.contains("W-2 coordination applied"),
3088 "W-2 coordination: {s}"
3089 );
3090 assert!(s.contains("73880.00"), "base: {s}");
3092 assert!(s.contains("3236.40"), "ss (reduced by W-2 cap): {s}");
3093 assert!(s.contains("2142.52"), "medicare: {s}");
3094 assert!(s.contains("214.92"), "addl: {s}");
3095 assert!(s.contains("5593.84"), "total: {s}");
3096 assert!(s.contains("2689.46"), "deductible_half: {s}");
3097 }
3098
3099 #[test]
3101 fn schedule_se_csv_columns_and_values() {
3102 let dir = tempfile::tempdir().unwrap();
3103 let out = dir.path().join("export");
3104 let st = LedgerState::default();
3105 let r = golden1();
3106 write_csv_exports(&out, &st, Some(2025), Some(&r), &BTreeMap::new()).unwrap();
3107
3108 let path = out.join("schedule_se.csv");
3109 assert!(path.exists(), "schedule_se.csv must be written");
3110 let mut rdr = csv::Reader::from_reader(std::fs::File::open(&path).unwrap());
3111 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3112 assert_eq!(
3113 headers,
3114 vec![
3115 "net_se_earnings",
3116 "se_base_9235",
3117 "ss_component",
3118 "medicare_component",
3119 "additional_medicare_component",
3120 "total_se_tax",
3121 "deductible_half",
3122 ]
3123 );
3124 let rec = rdr
3125 .records()
3126 .next()
3127 .expect("one data row")
3128 .expect("readable");
3129 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"); }
3137
3138 #[test]
3140 fn schedule_se_csv_omitted_when_no_se_tax() {
3141 let dir = tempfile::tempdir().unwrap();
3142 let out = dir.path().join("export");
3143 let st = LedgerState::default();
3144 write_csv_exports(&out, &st, Some(2025), None, &BTreeMap::new()).unwrap();
3145 assert!(!out.join("schedule_se.csv").exists());
3146 }
3147}
3148
3149#[cfg(test)]
3150mod form8283_csv_tests {
3151 use super::*;
3154
3155 #[test]
3157 fn form8283_csv_detail_columns_present_and_empty() {
3158 use btctax_core::{
3159 BasisSource, DonationDetails, EventId, LedgerState, Removal, RemovalKind, RemovalLeg,
3160 Term,
3161 };
3162 use time::macros::date;
3163
3164 let dir = tempfile::tempdir().unwrap();
3165 let out = dir.path().join("export");
3166
3167 let event = EventId::decision(99);
3169 let leg = RemovalLeg {
3170 lot_id: btctax_core::LotId {
3171 origin_event_id: event.clone(),
3172 split_sequence: 0,
3173 },
3174 sat: 100_000_000,
3175 basis: rust_decimal::Decimal::ZERO,
3176 fmv_at_transfer: rust_decimal::Decimal::from(52000),
3177 term: Term::LongTerm,
3178 basis_source: BasisSource::ComputedFromCost,
3179 acquired_at: date!(2025 - 01 - 01),
3180 pseudo: false,
3181 };
3182 let removal = Removal {
3183 event: event.clone(),
3184 kind: RemovalKind::Donation,
3185 removed_at: date!(2025 - 03 - 01),
3186 legs: vec![leg],
3187 appraisal_required: false,
3188 donor_acquired_at: None,
3189 claimed_deduction: Some(rust_decimal::Decimal::from(52000)),
3190 donee: Some("Test Charity Two".into()),
3191 };
3192 let event2 = EventId::decision(100);
3194 let leg2 = RemovalLeg {
3195 lot_id: btctax_core::LotId {
3196 origin_event_id: event2.clone(),
3197 split_sequence: 0,
3198 },
3199 sat: 10_000_000,
3200 basis: rust_decimal::Decimal::ZERO,
3201 fmv_at_transfer: rust_decimal::Decimal::from(8000),
3202 term: Term::LongTerm,
3203 basis_source: BasisSource::ComputedFromCost,
3204 acquired_at: date!(2025 - 01 - 15),
3205 pseudo: false,
3206 };
3207 let removal2 = Removal {
3208 event: event2.clone(),
3209 kind: RemovalKind::Donation,
3210 removed_at: date!(2025 - 05 - 01),
3211 legs: vec![leg2],
3212 appraisal_required: false,
3213 donor_acquired_at: None,
3214 claimed_deduction: Some(rust_decimal::Decimal::from(8000)),
3215 donee: Some("No Details Org".into()),
3216 };
3217
3218 let st = LedgerState {
3219 removals: vec![removal, removal2],
3220 ..Default::default()
3221 };
3222
3223 let mut dmap: BTreeMap<EventId, DonationDetails> = BTreeMap::new();
3224 dmap.insert(
3225 event,
3226 DonationDetails {
3227 donee_name: "Test Charity".into(),
3228 donee_ein: Some("12-3456789".into()),
3229 donee_address: Some("123 Main".into()),
3230 appraiser_name: "Test Appraiser".into(),
3231 appraiser_tin: Some("987654321".into()),
3232 appraiser_ptin: Some("P01234567".into()),
3233 appraiser_qualifications: Some("Certified".into()),
3234 appraisal_date: Some(date!(2025 - 06 - 01)),
3235 appraiser_address: None,
3236 fmv_method_override: None,
3237 },
3238 );
3239 write_csv_exports(&out, &st, Some(2025), None, &dmap).unwrap();
3242
3243 let path = out.join("form8283.csv");
3244 assert!(path.exists(), "form8283.csv must exist");
3245
3246 let mut rdr = csv::ReaderBuilder::new()
3247 .comment(Some(b'#'))
3248 .from_path(&path)
3249 .unwrap();
3250 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3251 let idx = |name: &str| {
3252 headers
3253 .iter()
3254 .position(|h| h == name)
3255 .unwrap_or_else(|| panic!("header {name} not found"))
3256 };
3257 let all_recs: Vec<csv::StringRecord> = rdr.records().map(|r| r.unwrap()).collect();
3261 assert_eq!(
3262 all_recs.len(),
3263 2,
3264 "must have exactly two data rows (one per removal)"
3265 );
3266 let rec = &all_recs[0];
3267 let no_details_rec = &all_recs[1];
3268
3269 assert_eq!(&rec[idx("donee")], "Test Charity");
3271 assert_eq!(&rec[idx("appraiser")], "Test Appraiser");
3272 assert_eq!(&rec[idx("donee_ein")], "12-3456789");
3273 assert_eq!(&rec[idx("donee_address")], "123 Main");
3274 assert_eq!(&rec[idx("appraiser_tin")], "987654321");
3275 assert_eq!(&rec[idx("appraiser_ptin")], "P01234567");
3276 assert_eq!(&rec[idx("appraiser_qualifications")], "Certified");
3277 assert_eq!(&rec[idx("appraisal_date")], "2025-06-01");
3278 assert_eq!(&rec[idx("needs_review")], "false");
3279
3280 assert_eq!(
3282 &no_details_rec[idx("donee_ein")],
3283 "",
3284 "no-details row: donee_ein must be empty"
3285 );
3286 assert_eq!(
3287 &no_details_rec[idx("donee_address")],
3288 "",
3289 "no-details row: donee_address must be empty"
3290 );
3291 assert_eq!(
3292 &no_details_rec[idx("appraiser_tin")],
3293 "",
3294 "no-details row: appraiser_tin must be empty"
3295 );
3296 assert_eq!(
3297 &no_details_rec[idx("appraiser_ptin")],
3298 "",
3299 "no-details row: appraiser_ptin must be empty"
3300 );
3301 assert_eq!(
3302 &no_details_rec[idx("appraiser_qualifications")],
3303 "",
3304 "no-details row: appraiser_qualifications must be empty"
3305 );
3306 assert_eq!(
3307 &no_details_rec[idx("appraisal_date")],
3308 "",
3309 "no-details row: appraisal_date must be empty"
3310 );
3311 assert_eq!(
3312 &no_details_rec[idx("needs_review")],
3313 "true",
3314 "no-details carrier row: needs_review must be true"
3315 );
3316 }
3317}