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(crate) const ADVISORY_WRAP_COLS: usize = 92;
1095
1096pub(crate) fn wrap_bulleted(text: &str) -> String {
1101 const BULLET: &str = " \u{2022} ";
1102 const HANG: &str = " ";
1103 let mut out = String::new();
1104 let mut line = String::from(BULLET);
1105 let mut have_word = false;
1106
1107 for word in text.split_whitespace() {
1108 let prospective = line.chars().count() + usize::from(have_word) + word.chars().count();
1109 if have_word && prospective > ADVISORY_WRAP_COLS {
1110 out.push_str(line.trim_end());
1111 out.push('\n');
1112 line = String::from(HANG);
1113 have_word = false;
1114 }
1115 if have_word {
1116 line.push(' ');
1117 }
1118 line.push_str(word);
1119 have_word = true;
1120 }
1121 out.push_str(line.trim_end());
1122 out
1123}
1124
1125pub fn render_advisories(advisories: &[btctax_core::tax::advisories::Advisory]) -> String {
1129 let mut s = String::new();
1130 if advisories.is_empty() {
1131 return s;
1132 }
1133 let _ = writeln!(s, "\n ── ADVISORIES ({}) ──", advisories.len());
1134 for a in advisories {
1135 let _ = writeln!(s, "{}", wrap_bulleted(&a.message()));
1139 }
1140 let _ = writeln!(
1141 s,
1142 " (Advisories never change a number and never fail the command. See `btctax limitations`.)"
1143 );
1144 s
1145}
1146
1147pub fn provenance_label(p: crate::resolve::Provenance) -> &'static str {
1150 use crate::resolve::Provenance::*;
1151 match p {
1152 ReturnInputs => "ReturnInputs (derived from line items)",
1153 StoredProfile => "stored TaxProfile (raw override)",
1154 PseudoPlaceholder => "pseudo-reconcile placeholder ($0)",
1155 Missing => "none (TaxProfileMissing)",
1156 }
1157}
1158
1159pub fn render_dual_report(
1174 year: i32,
1175 ar: &btctax_core::AbsoluteReturn,
1176 printed: &btctax_core::tax::packet::PrintedForms,
1177 delta: &btctax_core::TaxOutcome,
1178 provenance: crate::resolve::Provenance,
1179) -> String {
1180 let f = &printed.f1040;
1181 let mut s = String::new();
1182 let _ = writeln!(
1183 s,
1184 "\n═══ Absolute filed return (Form 1040) — tax year {year} ═══"
1185 );
1186 let _ = writeln!(s, " Profile source: {}", provenance_label(provenance));
1187 let _ = writeln!(s, " Total income (1040 L9): {}", fmt_money(f.line9));
1188 let _ = writeln!(s, " Adjustments (L10): {}", fmt_money(f.line10));
1189 let _ = writeln!(s, " AGI (L11): {}", fmt_money(f.line11));
1190 let ded_kind = if ar.deduction_is_itemized {
1191 "itemized"
1192 } else {
1193 "standard"
1194 };
1195 let _ = writeln!(s, " Deduction (L12, {ded_kind}): {}", fmt_money(f.line12));
1196 if ar.qbi_deduction > Usd::ZERO {
1197 let _ = writeln!(s, " QBI deduction (L13): {}", fmt_money(f.line13));
1198 }
1199 let _ = writeln!(s, " Taxable income (L15): {}", fmt_money(f.line15));
1200 let _ = writeln!(s, " Tax (L16): {}", fmt_money(f.line16));
1201 if ar.foreign_tax_credit > Usd::ZERO {
1202 let _ = writeln!(
1203 s,
1204 " Foreign tax credit (Sch 3 L1): {}",
1205 fmt_money(printed.sch_3.map_or(Usd::ZERO, |s3| s3.line1))
1206 );
1207 }
1208 if ar.se_tax_sch2_l4 > Usd::ZERO {
1209 let _ = writeln!(
1210 s,
1211 " Self-employment tax (Sch 2 L4): {}",
1212 fmt_money(printed.sch_2.map_or(Usd::ZERO, |s2| s2.line4))
1213 );
1214 }
1215 if ar.additional_medicare.additional_medicare_tax > Usd::ZERO {
1216 let _ = writeln!(
1217 s,
1218 " Additional Medicare (Form 8959 → Sch 2 L11): {}",
1219 fmt_money(printed.f8959.line18)
1220 );
1221 }
1222 if ar.niit.tax > Usd::ZERO {
1223 let _ = writeln!(
1224 s,
1225 " Net Investment Income Tax (Form 8960 → Sch 2 L12): {}",
1226 fmt_money(printed.f8960.map_or(Usd::ZERO, |f| f.line17))
1227 );
1228 }
1229 let _ = writeln!(s, " TOTAL TAX (L24): {}", fmt_money(f.line24));
1230 let _ = writeln!(s, " Total payments (L33): {}", fmt_money(f.line33));
1231 if f.line34 > Usd::ZERO {
1232 let _ = writeln!(s, " → REFUND (L35a): {}", fmt_money(f.line34));
1233 } else {
1234 let _ = writeln!(s, " → AMOUNT OWED (L37): {}", fmt_money(f.line37));
1235 }
1236 let delta_str = match delta {
1238 btctax_core::TaxOutcome::Computed(r) => fmt_money(r.total_federal_tax_attributable),
1239 btctax_core::TaxOutcome::NotComputable(_) => "not computable".to_string(),
1240 };
1241 let _ = writeln!(
1242 s,
1243 "\n ── Two DIFFERENT questions — NOT reconciled (SPEC §6) ──"
1244 );
1245 let _ = writeln!(
1246 s,
1247 " • Absolute TOTAL TAX (this filed return, WITH crypto): {}",
1248 fmt_money(f.line24)
1249 );
1250 let _ = writeln!(
1251 s,
1252 " • Crypto-attributable tax (DELTA, shown above): {delta_str}"
1253 );
1254 let _ = writeln!(
1255 s,
1256 " The delta's implied deduction is fixed at derivation time (non-crypto AGI), so it is \
1257 APPROXIMATE where a\n deduction is AGI-sensitive (e.g. the 7.5% medical floor); the two do NOT \
1258 reconcile to the dollar."
1259 );
1260 s
1261}
1262
1263pub fn render_schedule_d(
1273 year: i32,
1274 totals: &ScheduleDTotals,
1275 outcome: &btctax_core::TaxOutcome,
1276) -> String {
1277 let mut s = String::new();
1278 let _ = writeln!(
1279 s,
1280 "Schedule D (raw pre-netting part totals) — tax year {year}"
1281 );
1282 let _ = writeln!(
1283 s,
1284 " Part I (short-term): proceeds {} cost basis {} gain {}",
1285 fmt_money(totals.st.proceeds),
1286 fmt_money(totals.st.cost_basis),
1287 fmt_money(totals.st.gain)
1288 );
1289 let _ = writeln!(
1290 s,
1291 " Part II (long-term): proceeds {} cost basis {} gain {}",
1292 fmt_money(totals.lt.proceeds),
1293 fmt_money(totals.lt.cost_basis),
1294 fmt_money(totals.lt.gain)
1295 );
1296 match outcome {
1297 btctax_core::TaxOutcome::NotComputable(_) => {
1298 let _ = writeln!(
1299 s,
1300 " (raw disposition totals shown above; the year's tax is NOT COMPUTABLE until \
1301 the blocker is resolved — these Form 8949/Schedule D part totals are \
1302 informational and are not netted/carried until the tax computes)."
1303 );
1304 }
1305 btctax_core::TaxOutcome::Computed(_) => {
1306 let _ = writeln!(
1307 s,
1308 " Note: §1222/§1211/§1212 netting + carryforward are applied in the tax \
1309 computation (report --tax-year); these are the raw pre-netting Form \
1310 8949/Schedule D part totals."
1311 );
1312 }
1313 }
1314 s
1315}
1316
1317pub fn render_schedule_se(
1339 year: i32,
1340 result: Option<&SeTaxResult>,
1341 gross_se: Usd,
1342 table_present: bool,
1343 schedule_c_expenses: Usd,
1344 w2_ss_wages: Usd,
1345 w2_medicare_wages: Usd,
1346) -> Option<String> {
1347 match result {
1348 Some(r) => {
1349 let mut s = String::new();
1350 let _ = writeln!(
1351 s,
1352 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1353 );
1354 if schedule_c_expenses > Usd::ZERO {
1356 let gross_display = r.net_se + schedule_c_expenses;
1359 let _ = writeln!(
1360 s,
1361 " gross business income {} \u{2212} Schedule C expenses {} = net SE earnings {}",
1362 fmt_money(gross_display),
1363 fmt_money(schedule_c_expenses),
1364 fmt_money(r.net_se)
1365 );
1366 let _ = writeln!(
1368 s,
1369 " (Schedule C advisory) Schedule C expenses also reduce your ORDINARY taxable \
1370 income, but the income-tax total above uses GROSS crypto income \u{2014} to first \
1371 order it OVERSTATES your tax by your marginal ordinary rate applied to {}. The tax \
1372 profile cannot express this (an `ordinary_taxable_income` edit would shift both \
1373 legs of the crypto-attributable delta); the engine-side coordination is deferred \
1374 \u{2014} coordinate it on your actual return.",
1375 fmt_money(schedule_c_expenses)
1376 );
1377 } else {
1378 let _ = writeln!(
1379 s,
1380 " net self-employment income (business crypto, Interest excluded): {}",
1381 fmt_money(r.net_se)
1382 );
1383 let _ = writeln!(
1384 s,
1385 " (Schedule C) no Schedule C expenses supplied (--schedule-c-expenses)"
1386 );
1387 }
1388 let _ = writeln!(
1389 s,
1390 " \u{00d7} 92.35% net-earnings factor (\u{00a7}1402(a)) = net SE earnings: {}",
1391 fmt_money(r.base)
1392 );
1393 let _ = writeln!(
1394 s,
1395 " Social Security component (12.4%, §1401(a); capped at the SS wage base): {}",
1396 fmt_money(r.ss)
1397 );
1398 let _ = writeln!(
1399 s,
1400 " Medicare component (2.9%, §1401(b); uncapped): {}",
1401 fmt_money(r.medicare)
1402 );
1403 let _ = writeln!(
1404 s,
1405 " Additional Medicare component (0.9%, §1401(b)(2)): {}",
1406 fmt_money(r.addl)
1407 );
1408 let _ = writeln!(
1409 s,
1410 " TOTAL self-employment tax (§1401): {}",
1411 fmt_money(r.total)
1412 );
1413 let _ = writeln!(
1414 s,
1415 " §164(f) one-half-SE-tax deduction (above-the-line; EXCLUDES Additional Medicare per \
1416 §164(f)(1)): {}",
1417 fmt_money(r.deductible_half)
1418 );
1419 let _ = writeln!(
1422 s,
1423 " (§164(f) advisory) The §164(f) deduction ({}) is NOT auto-coordinated into the \
1424 income-tax total above — to first order, that total overstates your combined tax by \
1425 your marginal ordinary rate applied to {}. The tax profile cannot express this deduction \
1426 directly (reducing `ordinary_taxable_income` would shift BOTH legs of the \
1427 crypto-attributable delta and only correct the bracket differential, not the level) — \
1428 coordinate it on your actual return.",
1429 fmt_money(r.deductible_half),
1430 fmt_money(r.deductible_half)
1431 );
1432 if w2_ss_wages > Usd::ZERO || w2_medicare_wages > Usd::ZERO {
1435 let _ = writeln!(
1436 s,
1437 " (W-2 coordination applied) SS cap = max(0, wage base \u{2212} {}) (Box 3+7); \
1438 Additional-Medicare threshold reduced (not below 0) by {} (Box 5, \
1439 §1401(b)(2)(B)/Form 8959 Part II).",
1440 fmt_money(w2_ss_wages),
1441 fmt_money(w2_medicare_wages)
1442 );
1443 } else {
1444 let _ = writeln!(
1445 s,
1446 " (W-2) assumes $0 W-2 wages (set --w2-ss-wages/--w2-medicare-wages on the tax \
1447 profile if you had a wage job)."
1448 );
1449 }
1450 if r.base < rust_decimal::Decimal::from(400) {
1453 let _ = writeln!(
1454 s,
1455 " (§6017 filing floor) Net earnings from self-employment ({base}) are below $400: \
1456 a Schedule SE filing is required on account of this income only when net earnings \
1457 from self-employment (the ×92.35% base, §1402(a)) are $400 or more (§6017), and \
1458 below that floor no §1401 SE tax is imposed (§1402(b)(2); church employee income \
1459 excepted — §1402(j)(2), not modeled) — the figures above are shown for \
1460 transparency (other self-employment activities, if any, combine on your actual \
1461 Schedule SE).",
1462 base = fmt_money(r.base)
1463 );
1464 }
1465 let _ = writeln!(
1467 s,
1468 " (standalone) This §1401 SE tax is a SEPARATE federal liability, NOT included in the \
1469 income-tax + NIIT total above; the §164(f) one-half-SE-tax deduction is not \
1470 auto-coordinated into that total."
1471 );
1472 Some(s)
1473 }
1474 None => {
1475 if gross_se.is_zero() {
1476 None } else if !table_present {
1478 let mut s = String::new();
1480 let _ = writeln!(
1481 s,
1482 "Schedule SE (§1401 self-employment tax) — tax year {year}"
1483 );
1484 let _ = writeln!(
1485 s,
1486 " SS wage base unavailable for {year}: business self-employment income is present \
1487 but no bundled tax table (ss_wage_base) exists for {year}; the §1401 SE tax was \
1488 NOT computed (no silent drop)."
1489 );
1490 Some(s)
1491 } else {
1492 let mut s = String::new();
1495 let _ = writeln!(
1496 s,
1497 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1498 );
1499 let _ = writeln!(
1500 s,
1501 " fully expensed: gross {} \u{2212} Schedule C expenses {} \u{2264} $0 \
1502 \u{2192} no \u{00a7}1401 SE tax for {year}.",
1503 fmt_money(gross_se),
1504 fmt_money(schedule_c_expenses)
1505 );
1506 Some(s)
1507 }
1508 }
1509 }
1510}
1511
1512pub fn render_gift_advisory(
1535 state: &LedgerState,
1536 year: i32,
1537 prior_taxable_gifts: btctax_core::conventions::Usd,
1538 tables: &impl btctax_core::TaxTables,
1539) -> Option<String> {
1540 use std::collections::BTreeMap;
1541
1542 let any_gift = state
1544 .removals
1545 .iter()
1546 .any(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year);
1547 if !any_gift {
1548 return None; }
1550
1551 let t = match tables.table_for(year) {
1553 None => {
1554 let total: btctax_core::conventions::Usd = state
1555 .removals
1556 .iter()
1557 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1558 .flat_map(|r| r.legs.iter())
1559 .map(|leg| leg.fmv_at_transfer)
1560 .sum();
1561 return Some(format!(
1562 "Form 709 gift advisory ({year}): gift annual-exclusion table unavailable for \
1563 {year}; Form 709 exposure not evaluated — ${} in gifts recorded.",
1564 fmt_money(total)
1565 ));
1566 }
1567 Some(t) => t,
1568 };
1569 let excl = t.gift_annual_exclusion;
1570
1571 let mut labeled: BTreeMap<String, btctax_core::conventions::Usd> = BTreeMap::new();
1573 let mut unlabeled_count: usize = 0;
1574 let mut unlabeled_total: btctax_core::conventions::Usd = Default::default();
1575
1576 for r in state
1577 .removals
1578 .iter()
1579 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1580 {
1581 let fmv: btctax_core::conventions::Usd = r.legs.iter().map(|l| l.fmv_at_transfer).sum();
1582 match &r.donee {
1583 Some(label) => {
1584 *labeled.entry(label.clone()).or_default() += fmv;
1585 }
1586 None => {
1587 unlabeled_count += 1;
1588 unlabeled_total += fmv;
1589 }
1590 }
1591 }
1592
1593 let mut filing_required_donees: Vec<String> = Vec::new();
1595 let mut total_taxable: btctax_core::conventions::Usd = Default::default();
1596 let mut s = format!("Form 709 gift advisory ({year}):");
1597
1598 if !labeled.is_empty() {
1599 s.push_str(&format!(
1600 "\n§2503(b) per-donee annual exclusion analysis (TY{year}, exclusion ${}):",
1601 fmt_money(excl)
1602 ));
1603 for (donee, &total) in &labeled {
1604 let applied = if total < excl { total } else { excl };
1605 let taxable: btctax_core::conventions::Usd = if total > excl {
1606 total - excl
1607 } else {
1608 Default::default()
1609 };
1610 s.push_str(&format!(
1611 "\n {donee}: total ${}, exclusion applied ${}, taxable ${}",
1612 fmt_money(total),
1613 fmt_money(applied),
1614 fmt_money(taxable)
1615 ));
1616 if total > excl {
1617 filing_required_donees.push(donee.clone());
1618 total_taxable += taxable;
1619 }
1620 }
1621 if !filing_required_donees.is_empty() {
1622 s.push_str(&format!(
1623 "\nForm 709 filing required (donee(s): {}). Total taxable gifts: ${}.",
1624 filing_required_donees.join(", "),
1625 fmt_money(total_taxable)
1626 ));
1627 } else {
1628 s.push_str(&format!(
1629 "\nNo Form 709 filing required based on per-donee totals \
1630 (each ≤ ${} exclusion). Total taxable gifts: $0.00.",
1631 fmt_money(excl)
1632 ));
1633 }
1634 }
1635
1636 if unlabeled_count > 0 {
1637 s.push_str(&format!(
1638 "\nNOTE: {unlabeled_count} gift(s) totalling ${} have no donee label — the §2503(b) \
1639 annual exclusion is PER DONEE and cannot be applied without one; label them via \
1640 `reconcile reclassify-outflow --donee`. Shown as a single conservative aggregate.",
1641 fmt_money(unlabeled_total)
1642 ));
1643 if unlabeled_total > excl {
1646 s.push_str(&format!(
1647 "\n Conservative aggregate ${} > ${} (one exclusion); \
1648 verify per-donee totals after labelling.",
1649 fmt_money(unlabeled_total),
1650 fmt_money(excl)
1651 ));
1652 } else {
1653 s.push_str(&format!(
1654 "\n Conservative aggregate ${} \u{2264} ${} (one exclusion); \
1655 per-donee exposure unverifiable without labels.",
1656 fmt_money(unlabeled_total),
1657 fmt_money(excl)
1658 ));
1659 }
1660 }
1661
1662 let lifetime_excl = t.gift_lifetime_exclusion;
1666 let cumulative_taxable = prior_taxable_gifts + total_taxable;
1667
1668 if cumulative_taxable > btctax_core::conventions::Usd::ZERO {
1670 let remaining = if cumulative_taxable >= lifetime_excl {
1671 btctax_core::conventions::Usd::ZERO
1672 } else {
1673 lifetime_excl - cumulative_taxable
1674 };
1675 s.push_str(&format!(
1676 "\n§2505 lifetime (basic) exclusion: you have used ${} of your ${} ({year}) lifetime \
1677 exclusion (${} remaining). No gift tax is DUE until cumulative taxable gifts exceed \
1678 the lifetime exclusion.",
1679 fmt_money(cumulative_taxable),
1680 fmt_money(lifetime_excl),
1681 fmt_money(remaining),
1682 ));
1683 if cumulative_taxable > lifetime_excl {
1685 let excess = cumulative_taxable - lifetime_excl;
1686 s.push_str(&format!(
1687 "\nlifetime exclusion EXCEEDED — gift tax may be due on ${} \
1688 (the excess base past the unified credit, not a computed tax); \
1689 consult a professional.",
1690 fmt_money(excess),
1691 ));
1692 }
1693 if unlabeled_count > 0 {
1696 s.push_str(&format!(
1697 "\n§2505 consumption reflects LABELED-donee taxable gifts only; \
1698 {unlabeled_count} unlabeled gift(s) totalling ${} are NOT included — \
1699 label them via `--donee` for a complete figure; consumption may be \
1700 understated / remaining overstated.",
1701 fmt_money(unlabeled_total),
1702 ));
1703 }
1704 }
1705
1706 s.push_str(
1709 "\nCaveats: §2513 gift-splitting (MFJ) not modeled (single-filer advisory only); \
1710 future-interest gifts (which require Form 709 filing regardless of amount) not \
1711 detectable; §2505 figures are advisory only — no portability/DSUE (§2010(c)(4)) \
1712 applied; prior cumulative taxable gifts are user-supplied (default $0 if \
1713 --prior-taxable-gifts not given).",
1714 );
1715
1716 Some(s)
1717}
1718
1719fn picks_str(picks: &[btctax_core::LotPick]) -> String {
1725 if picks.is_empty() {
1726 return "(none)".to_string();
1727 }
1728 picks
1729 .iter()
1730 .map(|p| {
1731 format!(
1732 "{}#{}:{}",
1733 p.lot.origin_event_id.canonical(),
1734 p.lot.split_sequence,
1735 p.sat
1736 )
1737 })
1738 .collect::<Vec<_>>()
1739 .join(", ")
1740}
1741
1742pub fn render_optimize_proposal(p: &btctax_core::OptimizeProposal) -> String {
1755 use btctax_core::{ApproxReason, Persistability};
1756 let mut s = String::new();
1757 let _ = writeln!(
1758 s,
1759 "Optimize (what-if) — tax year {} — NOTHING is filed or bound by running this.",
1760 p.year
1761 );
1762 if p.approximate {
1764 let why = match p.approx_reason {
1765 Some(ApproxReason::ComboCapExceeded { combos, cap }) => format!(
1766 "input exceeded the exhaustive bound ({combos} combos > {cap}); \
1767 a coordinate-descent fallback ran"
1768 ),
1769 Some(ApproxReason::ContentionUnenumerated { contended, .. }) => format!(
1770 "{contended} contended same-wallet disposal(s) could not be fully joint-enumerated"
1771 ),
1772 Some(ApproxReason::PoolHeuristic { lots, bound }) => format!(
1773 "a pool of {lots} lots exceeds the {bound}-lot exhaustive-enumeration bound; \
1774 only a deterministic heuristic SUBSET of that pool's identifications was searched"
1775 ),
1776 None => "approximate".to_string(),
1777 };
1778 let _ = writeln!(
1779 s,
1780 " \u{26a0} APPROXIMATE \u{2014} NOT a guaranteed global minimum: {why}."
1781 );
1782 let _ = writeln!(
1783 s,
1784 " The true least-tax assignment may be lower; this is a disclosed improvement over your"
1785 );
1786 let _ = writeln!(
1787 s,
1788 " current filing position (delta \u{2264} 0), NOT \u{2018}the least tax.\u{2019}"
1789 );
1790 }
1791 let _ = writeln!(
1792 s,
1793 " current federal tax (attributable): {}",
1794 p.baseline_tax
1795 );
1796 let _ = writeln!(
1797 s,
1798 " optimized federal tax (attributable): {}",
1799 p.optimized_tax
1800 );
1801 let _ = writeln!(
1802 s,
1803 " delta (optimized \u{2212} current): {} (negative = saving; always \u{2264} 0)",
1804 p.delta
1805 );
1806 for d in &p.per_disposal {
1807 let _ = writeln!(
1808 s,
1809 " {} @ {} [{}] :: {}",
1810 d.disposal.canonical(),
1811 d.date,
1812 wallet_label(&d.wallet),
1813 compliance_status_tag(&d.status)
1814 );
1815 if d.proposed_selection == d.current_selection {
1820 let _ = writeln!(
1821 s,
1822 " proposed: {} [no change \u{2014} already optimal under current identification]",
1823 picks_str(&d.proposed_selection)
1824 );
1825 continue;
1826 }
1827 let persist = match d.persistable {
1828 Persistability::ContemporaneousNow => {
1829 "persistable now (made \u{2264} sale \u{2192} Contemporaneous)"
1830 }
1831 Persistability::NeedsAttestation => {
1832 "already executed \u{2014} needs `optimize accept --disposal <ref> \
1833 --attest \"\u{2026}\"` (genuine contemporaneous ID only)"
1834 }
1835 Persistability::ForbiddenBroker2027 => {
1836 "2027+ broker-held \u{2014} CANNOT be persisted (own-books insufficient); \
1837 FIFO is the defensible position"
1838 }
1839 };
1840 let _ = writeln!(
1841 s,
1842 " proposed: {} [{}]",
1843 picks_str(&d.proposed_selection),
1844 persist
1845 );
1846 }
1847 let _ = writeln!(
1849 s,
1850 " (vertex-granularity identification: a multi-partial split landing exactly on a \
1851 tax-bracket kink is out of scope.)"
1852 );
1853 let _ = writeln!(
1854 s,
1855 " (this is the tax IF you had identified thus; adequate ID must exist by the time \
1856 of sale \u{2014} \u{a7}1.1012-1(j))"
1857 );
1858 let _ = writeln!(
1860 s,
1861 " (scope: global over taxable-disposal lot selections; self-transfer lot routing is \
1862 held at its baseline position and is not re-optimized.)"
1863 );
1864 s
1865}
1866
1867pub fn render_accept_outcome(o: &crate::cmd::optimize::AcceptOutcome) -> String {
1872 let mut s = String::new();
1873 let _ = writeln!(
1874 s,
1875 "Optimize accept \u{2014} {} persisted, {} skipped.",
1876 o.persisted.len(),
1877 o.skipped.len()
1878 );
1879 for (disposal, decision, basis) in &o.persisted {
1880 let _ = writeln!(
1881 s,
1882 " PERSISTED {} \u{2192} LotSelection {} [{}]{}",
1883 disposal.canonical(),
1884 decision.canonical(),
1885 basis,
1886 if *basis == "AttestedRecording" {
1887 " (+ attestation recorded; revoke with `reconcile void`)"
1888 } else {
1889 " (revoke with `reconcile void`)"
1890 }
1891 );
1892 }
1893 for (disposal, reason) in &o.skipped {
1894 let _ = writeln!(s, " skipped {}: {}", disposal.canonical(), reason);
1895 }
1896 if o.persisted.is_empty() && o.skipped.is_empty() {
1897 let _ = writeln!(s, " (no disposals matched)");
1898 }
1899 s
1900}
1901
1902pub fn render_consult(r: &btctax_core::ConsultReport) -> String {
1912 let mut s = String::new();
1913 let _ = writeln!(
1914 s,
1915 "Consult (read-only what-if): sell {} sat from {} on {}",
1916 r.req.sell_sat,
1917 wallet_label(&r.req.wallet),
1918 r.req.at
1919 );
1920 if r.approximate {
1922 let _ = writeln!(
1923 s,
1924 " \u{26a0} heuristic \u{2014} searched a subset of a large (>12-lot) pool; \
1925 the proposed selection may not be the exact minimum."
1926 );
1927 }
1928 let _ = writeln!(
1929 s,
1930 " proposed selection: {}",
1931 picks_str(&r.proposed_selection)
1932 );
1933 let _ = writeln!(
1934 s,
1935 " short-term gain: {} long-term gain: {}",
1936 r.st_gain, r.lt_gain
1937 );
1938 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
1941 let _ = writeln!(
1942 s,
1943 " whole-year federal tax attributable (with this sale): {}",
1944 r.total_federal_tax_attributable
1945 );
1946 if let Some(t) = &r.timing {
1947 let _ = writeln!(
1948 s,
1949 " timing: {} sat of the best selection is short-term until {}; \
1950 selling on/after then would be taxed long-term, a \u{2248} {} difference.",
1951 t.st_sat_in_selection, t.latest_crossover, t.saving_if_waited
1952 );
1953 }
1954 let _ = writeln!(
1955 s,
1956 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
1957 not investment advice (no buy/sell/hold recommendation)."
1958 );
1959 s
1960}
1961
1962fn ltcg_bracket_label(b: LtcgBracket) -> &'static str {
1964 match b {
1965 LtcgBracket::Zero => "0%",
1966 LtcgBracket::Fifteen => "15%",
1967 LtcgBracket::Twenty => "20%",
1968 }
1969}
1970
1971pub fn render_whatif_sell(r: &SellReport, magi_caveat: bool) -> String {
1977 let mut s = String::new();
1978 let _ = writeln!(
1979 s,
1980 "What-if (read-only): sell {} sat from {} on {}",
1981 r.req.sell_sat,
1982 wallet_label(&r.req.wallet),
1983 r.req.at
1984 );
1985 if magi_caveat {
1986 let _ = writeln!(
1987 s,
1988 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
1989 );
1990 }
1991 let _ = writeln!(s, " proceeds: {}", r.proceeds);
1992 let _ = writeln!(s, " lots consumed:");
1993 for leg in &r.lots {
1994 let term = match leg.term {
1995 Term::ShortTerm => "ST",
1996 Term::LongTerm => "LT",
1997 };
1998 let _ = writeln!(
1999 s,
2000 " {}#{} {} sat basis {} {} \u{2192} {} {} gain {}",
2001 leg.lot_id.origin_event_id.canonical(),
2002 leg.lot_id.split_sequence,
2003 leg.sat,
2004 leg.basis,
2005 leg.acquired_at,
2006 leg.sold_at,
2007 term,
2008 leg.gain
2009 );
2010 }
2011 let _ = writeln!(
2012 s,
2013 " short-term gain: {} long-term gain: {}",
2014 r.st_gain, r.lt_gain
2015 );
2016 match r.bracket_room {
2018 Some(room) => {
2019 let _ = writeln!(
2020 s,
2021 " \u{00a7}1(h) LTCG bracket: {} (room {} before the next breakpoint)",
2022 ltcg_bracket_label(r.bracket),
2023 room
2024 );
2025 }
2026 None => {
2027 let _ = writeln!(
2028 s,
2029 " \u{00a7}1(h) LTCG bracket: {} (top bracket \u{2014} no headroom)",
2030 ltcg_bracket_label(r.bracket)
2031 );
2032 }
2033 }
2034 let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
2036 match r.effective_rate {
2037 Some(rate) => {
2038 let _ = writeln!(s, " effective rate: {rate}");
2039 }
2040 None => {
2041 let _ = writeln!(
2042 s,
2043 " effective rate: n/a (a loss/zero-gain sale \u{2014} its value is the carryforward, \
2044 not this-year tax)"
2045 );
2046 }
2047 }
2048 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2051 if carried != Usd::ZERO || r.ordinary_offset_delta != Usd::ZERO {
2052 let _ = writeln!(
2053 s,
2054 " \u{00a7}1212: {} offsets ordinary income this year, {} carried to next year \
2055 (short {} / long {})",
2056 r.ordinary_offset_delta, carried, r.carryforward_delta.short, r.carryforward_delta.long
2057 );
2058 }
2059 if r.niit_applies {
2061 let dir = if r.niit_incremental < Usd::ZERO {
2062 "decrease"
2063 } else {
2064 "increase"
2065 };
2066 let _ = writeln!(
2067 s,
2068 " \u{00a7}1411 NIIT: {} ({dir}) attributable to this sale",
2069 r.niit_incremental
2070 );
2071 }
2072 let status = match r.status {
2073 SellStatus::Gain => "net gain",
2074 SellStatus::Loss => "net loss (the carryforward is the value \u{2014} not this-year tax)",
2075 };
2076 let _ = writeln!(s, " status: {status}");
2077 let _ = writeln!(
2078 s,
2079 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
2080 not investment advice (no buy/sell/hold recommendation)."
2081 );
2082 s
2083}
2084
2085fn harvest_target_label(t: &HarvestTarget) -> String {
2087 match t {
2088 HarvestTarget::ZeroLtcg => {
2089 "zero-ltcg (all gain in the \u{00a7}1(h) 0% bracket)".to_string()
2090 }
2091 HarvestTarget::FifteenLtcg => "fifteen-ltcg (stay at/under 15%)".to_string(),
2092 HarvestTarget::Gain(x) => format!("gain \u{2264} {x}"),
2093 HarvestTarget::Tax(x) => format!("marginal tax \u{2264} {x}"),
2094 }
2095}
2096
2097pub fn render_whatif_harvest(r: &HarvestReport, magi_caveat: bool) -> String {
2103 let mut s = String::new();
2104 let _ = writeln!(
2105 s,
2106 "What-if HARVEST (read-only): {} from {} on {}",
2107 harvest_target_label(&r.req.target),
2108 wallet_label(&r.req.wallet),
2109 r.req.at
2110 );
2111 if magi_caveat {
2112 let _ = writeln!(
2113 s,
2114 " \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
2115 );
2116 }
2117 let status = match &r.status {
2118 HarvestStatus::Found => "FOUND (the target binds)",
2119 HarvestStatus::NotBinding => "NOT BINDING (the whole position fits)",
2120 HarvestStatus::AlreadyBreached => "ALREADY BREACHED at N=0 (nothing can be harvested)",
2121 HarvestStatus::NoLots => "NO LOTS (nothing to harvest from that wallet)",
2122 HarvestStatus::ProceedsRequired => "PROCEEDS REQUIRED",
2123 HarvestStatus::PreTransitionYear => "PRE-2025 (a closed year, not a plan)",
2124 HarvestStatus::YearNotComputable(_) => "YEAR NOT COMPUTABLE",
2125 };
2126 let _ = writeln!(s, " status: {status}");
2127 let _ = writeln!(s, " \u{2192} sell up to {} BTC ({} sat)", r.n_btc, r.n_sat);
2128 let _ = writeln!(s, " bound by: {}", r.binding_constraint);
2129 let _ = writeln!(
2130 s,
2131 " realized gain at N*: short-term {} long-term {}",
2132 r.st_gain, r.lt_gain
2133 );
2134 let ps = &r.with_result.pref_split;
2136 let bracket = if ps.at_20 > Usd::ZERO {
2137 "20%"
2138 } else if ps.at_15 > Usd::ZERO {
2139 "15%"
2140 } else {
2141 "0%"
2142 };
2143 let _ = writeln!(
2144 s,
2145 " \u{00a7}1(h) preferential dollars at N*: {} in 0% / {} in 15% / {} in 20% (top bracket: {})",
2146 ps.at_0, ps.at_15, ps.at_20, bracket
2147 );
2148 let _ = writeln!(s, " marginal federal tax at N*: {}", r.marginal_tax);
2149 let carried = r.carryforward_delta.short + r.carryforward_delta.long;
2151 if carried != Usd::ZERO {
2152 let dir = if carried < Usd::ZERO {
2153 "burned (spent)"
2154 } else {
2155 "carried to next year"
2156 };
2157 let _ = writeln!(
2158 s,
2159 " \u{00a7}1212 carryforward {}: {} (short {} / long {})",
2160 dir, carried, r.carryforward_delta.short, r.carryforward_delta.long
2161 );
2162 }
2163 if r.niit_applies {
2165 let dir = if r.niit_incremental < Usd::ZERO {
2166 "decrease"
2167 } else {
2168 "increase"
2169 };
2170 let _ = writeln!(
2171 s,
2172 " \u{00a7}1411 NIIT: {} ({dir}) at N* \u{2014} the +3.8% kink applies even inside a 0%/15% bracket answer",
2173 r.niit_incremental
2174 );
2175 }
2176 if let Some(note) = &r.plateau_note {
2177 let _ = writeln!(s, " \u{2139} {note}");
2178 }
2179 let _ = writeln!(
2180 s,
2181 "Tax decision-support only \u{2014} the engine-verified consequences of a contemplated harvest; \
2182 not investment advice (no buy/sell/hold recommendation)."
2183 );
2184 s
2185}
2186
2187pub fn render_verify(r: &VerifyReport) -> String {
2188 let mut out = String::new();
2189 let c = &r.conservation;
2190 let _ = writeln!(
2191 out,
2192 "Conservation (FR9): {}",
2193 if c.balanced { "BALANCED" } else { "DRIFT" }
2194 );
2195 let _ = writeln!(
2196 out,
2197 " in {} = disposed {} + removed {} + held {} + fee-sats {} + pending {}{}",
2198 c.sigma_in,
2199 c.sigma_disposed,
2200 c.sigma_removed,
2201 c.sigma_held,
2202 c.sigma_fee_sats,
2203 c.sigma_pending,
2204 if c.has_uncovered {
2205 " [identity undefined: uncovered disposal open]"
2206 } else {
2207 ""
2208 }
2209 );
2210 let _ = writeln!(out, "2025 transition: {}", r.safe_harbor);
2211 let _ = writeln!(
2212 out,
2213 "Pending reconciliation: {} transfer(s); unknown-basis inbounds: {}",
2214 r.pending, r.unknown_basis_inbounds
2215 );
2216
2217 let _ = writeln!(
2218 out,
2219 "Hard blockers (gate tax computation): {}",
2220 r.hard.len()
2221 );
2222 for b in &r.hard {
2223 let evt = b
2224 .event
2225 .as_ref()
2226 .map(|e| e.canonical())
2227 .unwrap_or_else(|| "-".to_string());
2228 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2229 if b.kind == BlockerKind::FmvMissing {
2232 let _ = writeln!(out, " ↳ {}", crate::price_cache::UPDATE_PRICES_HINT);
2233 }
2234 }
2235 let _ = writeln!(out, "Advisory blockers: {}", r.advisory.len());
2236 for b in &r.advisory {
2237 let evt = b
2238 .event
2239 .as_ref()
2240 .map(|e| e.canonical())
2241 .unwrap_or_else(|| "-".to_string());
2242 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
2243 }
2244 let _ = writeln!(
2245 out,
2246 "Pre-2025 method (attested historical fact): {} (attested: {})",
2247 lot_method_display(r.declared_pre2025_method),
2248 r.pre2025_method_attested
2249 );
2250 let _ = writeln!(
2251 out,
2252 "Standing orders (MethodElection): {}",
2253 r.elections.len()
2254 );
2255 for e in &r.elections {
2256 let _ = writeln!(
2257 out,
2258 " recorded {} effective {} -> {} [{}]",
2259 e.recorded,
2260 e.effective_from,
2261 lot_method_display(e.method),
2262 e.note
2263 );
2264 }
2265 let _ = writeln!(out, "Lot selections recorded: {}", r.selection_count);
2266 let _ = writeln!(
2267 out,
2268 "Per-disposal compliance (post-2025): {}",
2269 r.compliance.len()
2270 );
2271 for c in &r.compliance {
2272 let _ = writeln!(
2273 out,
2274 " {} @ {} :: {}",
2275 c.disposal.canonical(),
2276 c.date,
2277 compliance_status_tag(&c.status)
2278 );
2279 }
2280 out
2281}
2282
2283#[cfg(test)]
2284mod gift_advisory_tests {
2285 use super::*;
2291 use btctax_core::conventions::Usd;
2292 use btctax_core::{EventId, LotId, Removal, RemovalLeg, TaxTable};
2293 use rust_decimal_macros::dec;
2294 use std::collections::BTreeMap;
2295 use time::macros::date;
2296
2297 fn gift_removal(seq: u64, removed_at: TaxDate, fmv: Usd) -> Removal {
2299 Removal {
2300 event: EventId::decision(seq),
2301 kind: RemovalKind::Gift,
2302 removed_at,
2303 legs: vec![RemovalLeg {
2304 lot_id: LotId {
2305 origin_event_id: EventId::decision(seq),
2306 split_sequence: 0,
2307 },
2308 sat: 100,
2309 basis: dec!(0),
2310 fmv_at_transfer: fmv,
2311 term: Term::LongTerm,
2312 basis_source: BasisSource::ComputedFromCost,
2313 acquired_at: date!(2024 - 01 - 01),
2314 pseudo: false,
2315 }],
2316 appraisal_required: false,
2317 donor_acquired_at: None,
2318 claimed_deduction: None,
2319 donee: None,
2320 }
2321 }
2322 fn gift_removal_labeled(seq: u64, removed_at: TaxDate, fmv: Usd, label: &str) -> Removal {
2324 Removal {
2325 donee: Some(label.to_string()),
2326 ..gift_removal(seq, removed_at, fmv)
2327 }
2328 }
2329 fn state_with(removals: Vec<Removal>) -> LedgerState {
2330 LedgerState {
2331 removals,
2332 ..Default::default()
2333 }
2334 }
2335 fn tables_with(year: i32, excl: Usd) -> BTreeMap<i32, TaxTable> {
2339 tables_with_lifetime(year, excl, dec!(13_990_000))
2340 }
2341
2342 fn tables_with_lifetime(year: i32, excl: Usd, lifetime_excl: Usd) -> BTreeMap<i32, TaxTable> {
2344 let mut m = BTreeMap::new();
2345 m.insert(
2346 year,
2347 TaxTable {
2348 year,
2349 source: "TEST",
2350 ordinary: BTreeMap::new(),
2351 ltcg: BTreeMap::new(),
2352 gift_annual_exclusion: excl,
2353 ss_wage_base: dec!(176100),
2354 gift_lifetime_exclusion: lifetime_excl,
2355 },
2356 );
2357 m
2358 }
2359
2360 #[test]
2364 fn no_gifts_is_none() {
2365 let st = state_with(vec![]);
2366 let tables = tables_with(2025, dec!(19000));
2367 assert!(render_gift_advisory(&st, 2025, dec!(0), &tables).is_none());
2368 }
2369
2370 #[test]
2373 fn gifts_present_but_no_table_emits_note_not_none() {
2374 let st = state_with(vec![gift_removal(1, date!(2026 - 06 - 01), dec!(50000))]);
2376 let tables = tables_with(2025, dec!(19000));
2378 let msg =
2379 render_gift_advisory(&st, 2026, dec!(0), &tables).expect("note expected, not None");
2380 assert!(msg.contains("unavailable"), "{msg}");
2381 assert!(msg.contains("Form 709 exposure not evaluated"), "{msg}");
2382 assert!(
2383 msg.contains("50000.00"),
2384 "must record the gift total: {msg}"
2385 );
2386 }
2387
2388 #[test]
2394 fn labeled_donee_over_exclusion_emits_advisory() {
2395 let st = state_with(vec![gift_removal_labeled(
2396 1,
2397 date!(2025 - 06 - 01),
2398 dec!(20000),
2399 "Alice",
2400 )]);
2401 let tables = tables_with(2025, dec!(19000));
2402 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2403 assert!(msg.contains("20000.00"), "must show Alice's total: {msg}");
2404 assert!(msg.contains("19000.00"), "must show the exclusion: {msg}");
2405 assert!(
2406 msg.contains("Form 709 filing required"),
2407 "must flag filing required: {msg}"
2408 );
2409 assert!(msg.contains("Alice"), "must name Alice: {msg}");
2410 assert!(msg.contains("1000.00"), "taxable must be $1000.00: {msg}");
2412 assert!(
2414 !msg.contains("donee identity is not modeled"),
2415 "stale aggregate caveat must not appear: {msg}"
2416 );
2417 }
2418
2419 #[test]
2422 fn labeled_donee_under_exclusion_no_filing_required() {
2423 let st = state_with(vec![gift_removal_labeled(
2424 1,
2425 date!(2025 - 06 - 01),
2426 dec!(10000),
2427 "Alice",
2428 )]);
2429 let tables = tables_with(2025, dec!(19000));
2430 let msg =
2432 render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected, not None");
2433 assert!(
2434 msg.contains("No Form 709 filing required"),
2435 "must say no filing required: {msg}"
2436 );
2437 assert!(msg.contains("Alice"), "must mention Alice: {msg}");
2438 assert!(msg.contains("10000.00"), "must show Alice's total: {msg}");
2439 }
2440
2441 #[test]
2447 fn per_donee_under_exclusion_two_donees_no_filing_required() {
2448 let st = state_with(vec![
2449 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(15000), "Alice"),
2450 gift_removal_labeled(2, date!(2025 - 06 - 01), dec!(15000), "Bob"),
2451 ]);
2452 let tables = tables_with(2025, dec!(19000));
2453 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2454 assert!(
2456 msg.contains("No Form 709 filing required"),
2457 "neither donee exceeds exclusion → no filing required: {msg}"
2458 );
2459 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2461 assert!(msg.contains("Bob"), "Bob must appear: {msg}");
2462 assert!(msg.contains("15000.00"), "donee total must appear: {msg}");
2464 assert!(
2466 !msg.contains("Form 709 filing required (donee(s):"),
2467 "filing trigger must NOT fire: {msg}"
2468 );
2469 assert!(
2471 msg.contains("Total taxable gifts: $0.00"),
2472 "total taxable must be $0.00: {msg}"
2473 );
2474 }
2475
2476 #[test]
2479 fn one_donee_over_exclusion_filing_required() {
2480 let st = state_with(vec![gift_removal_labeled(
2481 1,
2482 date!(2025 - 06 - 01),
2483 dec!(25000),
2484 "Alice",
2485 )]);
2486 let tables = tables_with(2025, dec!(19000));
2487 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2488 assert!(
2489 msg.contains("Form 709 filing required (donee(s): Alice)"),
2490 "must trigger filing required for Alice: {msg}"
2491 );
2492 assert!(msg.contains("25000.00"), "Alice total must appear: {msg}");
2493 assert!(
2494 msg.contains("19000.00"),
2495 "exclusion applied must appear: {msg}"
2496 );
2497 assert!(
2499 msg.contains("6000.00"),
2500 "taxable $6,000.00 must appear: {msg}"
2501 );
2502 }
2503
2504 #[test]
2507 fn unlabeled_bucket_caveat_with_conservative_aggregate() {
2508 let st = state_with(vec![gift_removal(1, date!(2025 - 06 - 01), dec!(30000))]);
2509 let tables = tables_with(2025, dec!(19000));
2510 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2511 assert!(
2513 msg.contains("no donee label"),
2514 "unlabeled caveat must appear: {msg}"
2515 );
2516 assert!(
2517 msg.contains("30000.00"),
2518 "unlabeled total must appear: {msg}"
2519 );
2520 assert!(
2522 msg.contains("Conservative aggregate"),
2523 "conservative aggregate signal must appear: {msg}"
2524 );
2525 assert!(
2526 msg.contains("19000.00"),
2527 "one-exclusion comparison must appear: {msg}"
2528 );
2529 assert!(
2531 !msg.contains("Form 709 filing required (donee(s):"),
2532 "labeled filing trigger must NOT fire for unlabeled gifts: {msg}"
2533 );
2534 }
2535
2536 #[test]
2539 fn mixed_labeled_over_and_unlabeled_shows_both() {
2540 let st = state_with(vec![
2541 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(25000), "Alice"),
2542 gift_removal(2, date!(2025 - 06 - 01), dec!(5000)), ]);
2544 let tables = tables_with(2025, dec!(19000));
2545 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2546 assert!(
2548 msg.contains("Form 709 filing required"),
2549 "filing required for Alice: {msg}"
2550 );
2551 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2552 assert!(
2554 msg.contains("no donee label"),
2555 "unlabeled caveat must appear: {msg}"
2556 );
2557 assert!(
2558 msg.contains("5000.00"),
2559 "unlabeled total must appear: {msg}"
2560 );
2561 }
2562
2563 #[test]
2566 fn donations_excluded_from_form709_advisory() {
2567 let donation_removal = Removal {
2568 event: EventId::decision(1),
2569 kind: RemovalKind::Donation,
2570 removed_at: date!(2025 - 06 - 01),
2571 legs: vec![RemovalLeg {
2572 lot_id: LotId {
2573 origin_event_id: EventId::decision(1),
2574 split_sequence: 0,
2575 },
2576 sat: 100,
2577 basis: dec!(0),
2578 fmv_at_transfer: dec!(50000), term: Term::LongTerm,
2580 basis_source: BasisSource::ComputedFromCost,
2581 acquired_at: date!(2024 - 01 - 01),
2582 pseudo: false,
2583 }],
2584 appraisal_required: false,
2585 donor_acquired_at: None,
2586 claimed_deduction: Some(dec!(50000)),
2587 donee: Some("Charity X".to_string()),
2588 };
2589 let st = state_with(vec![donation_removal]);
2590 let tables = tables_with(2025, dec!(19000));
2591 assert!(
2593 render_gift_advisory(&st, 2025, dec!(0), &tables).is_none(),
2594 "Donation must be excluded from the Form 709 advisory"
2595 );
2596 }
2597
2598 #[test]
2604 fn section_2505_under_lifetime_shows_used_and_remaining() {
2605 let st = state_with(vec![gift_removal_labeled(
2606 1,
2607 date!(2025 - 06 - 01),
2608 dec!(100000),
2609 "Alice",
2610 )]);
2611 let tables = tables_with(2025, dec!(19000)); let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2613 assert!(
2615 msg.contains("81000.00"),
2616 "taxable $81,000 must appear: {msg}"
2617 );
2618 assert!(
2620 msg.contains("§2505 lifetime (basic) exclusion"),
2621 "§2505 block must appear: {msg}"
2622 );
2623 assert!(
2624 msg.contains("13990000.00"),
2625 "lifetime exclusion $13,990,000 must appear: {msg}"
2626 );
2627 assert!(
2629 msg.contains("13909000.00"),
2630 "remaining $13,909,000 must appear: {msg}"
2631 );
2632 assert!(
2634 !msg.contains("EXCEEDED"),
2635 "must NOT say EXCEEDED when under limit: {msg}"
2636 );
2637 assert!(
2639 !msg.contains("later chunk (Chunk 3)"),
2640 "stale Chunk-3 caveat must be absent: {msg}"
2641 );
2642 }
2643
2644 #[test]
2647 fn section_2505_prior_gifts_accumulate() {
2648 let st = state_with(vec![gift_removal_labeled(
2649 1,
2650 date!(2025 - 06 - 01),
2651 dec!(100000),
2652 "Alice",
2653 )]);
2654 let tables = tables_with(2025, dec!(19000));
2655 let msg =
2656 render_gift_advisory(&st, 2025, dec!(13_900_000), &tables).expect("advisory expected");
2657 assert!(
2659 msg.contains("13981000.00"),
2660 "cumulative $13,981,000 must appear: {msg}"
2661 );
2662 assert!(
2664 msg.contains("9000.00"),
2665 "remaining $9,000 must appear: {msg}"
2666 );
2667 assert!(
2668 !msg.contains("EXCEEDED"),
2669 "must NOT say EXCEEDED when under limit: {msg}"
2670 );
2671 }
2672
2673 #[test]
2677 fn section_2505_exceeds_lifetime_shows_exceeded_and_excess() {
2678 let st = state_with(vec![gift_removal_labeled(
2679 1,
2680 date!(2025 - 06 - 01),
2681 dec!(100000),
2682 "Alice",
2683 )]);
2684 let tables = tables_with(2025, dec!(19000));
2685 let msg =
2686 render_gift_advisory(&st, 2025, dec!(13_950_000), &tables).expect("advisory expected");
2687 assert!(
2688 msg.contains("14031000.00"),
2689 "cumulative $14,031,000 must appear: {msg}"
2690 );
2691 assert!(
2692 msg.contains("EXCEEDED"),
2693 "must say EXCEEDED when over lifetime limit: {msg}"
2694 );
2695 assert!(
2697 msg.contains("41000.00"),
2698 "excess $41,000 must appear: {msg}"
2699 );
2700 }
2701
2702 #[test]
2706 fn section_2505_exact_boundary_remaining_zero_not_exceeded() {
2707 let st = state_with(vec![gift_removal_labeled(
2708 1,
2709 date!(2025 - 06 - 01),
2710 dec!(100000),
2711 "Alice",
2712 )]);
2713 let tables = tables_with(2025, dec!(19000));
2714 let msg =
2716 render_gift_advisory(&st, 2025, dec!(13_909_000), &tables).expect("advisory expected");
2717 assert!(
2718 msg.contains("13990000.00"),
2719 "cumulative $13,990,000 must appear: {msg}"
2720 );
2721 assert!(
2723 msg.contains("($0.00 remaining)"),
2724 "remaining $0.00 in exact phrasing '($0.00 remaining)' must appear: {msg}"
2725 );
2726 assert!(
2728 !msg.contains("EXCEEDED"),
2729 "must NOT say EXCEEDED at exactly the limit: {msg}"
2730 );
2731 }
2732
2733 #[test]
2737 fn section_2505_prior_only_block_shows_even_when_current_taxable_zero() {
2738 let st = state_with(vec![gift_removal_labeled(
2739 1,
2740 date!(2025 - 06 - 01),
2741 dec!(10000), "Alice",
2743 )]);
2744 let tables = tables_with(2025, dec!(19000));
2745 let msg =
2746 render_gift_advisory(&st, 2025, dec!(5_000_000), &tables).expect("advisory expected");
2747 assert!(
2749 msg.contains("5000000.00"),
2750 "cumulative $5,000,000 must appear: {msg}"
2751 );
2752 assert!(
2753 msg.contains("§2505 lifetime (basic) exclusion"),
2754 "§2505 block must appear for prior-only case: {msg}"
2755 );
2756 assert!(!msg.contains("EXCEEDED"), "must NOT say EXCEEDED: {msg}");
2757 }
2758
2759 #[test]
2762 fn section_2505_no_block_when_cumulative_zero() {
2763 let st = state_with(vec![gift_removal_labeled(
2764 1,
2765 date!(2025 - 06 - 01),
2766 dec!(10000), "Alice",
2768 )]);
2769 let tables = tables_with(2025, dec!(19000));
2770 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2771 assert!(
2772 !msg.contains("§2505 lifetime"),
2773 "§2505 block must NOT appear when cumulative = 0: {msg}"
2774 );
2775 }
2776
2777 #[test]
2779 fn section_2505_default_zero_prior_shows_caveats() {
2780 let st = state_with(vec![gift_removal_labeled(
2781 1,
2782 date!(2025 - 06 - 01),
2783 dec!(100000), "Alice",
2785 )]);
2786 let tables = tables_with(2025, dec!(19000));
2787 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2788 assert!(
2790 !msg.contains("later chunk (Chunk 3)"),
2791 "stale 'later chunk (Chunk 3)' must be absent: {msg}"
2792 );
2793 assert!(
2795 msg.contains("§2513 gift-splitting"),
2796 "§2513 caveat must be present: {msg}"
2797 );
2798 assert!(
2799 msg.contains("portability/DSUE"),
2800 "portability/DSUE caveat must be present: {msg}"
2801 );
2802 assert!(
2803 msg.contains("prior cumulative taxable gifts are user-supplied"),
2804 "prior-cumulative disclosure caveat must be present: {msg}"
2805 );
2806 }
2807
2808 #[test]
2811 fn section_2505_mixed_shows_omission_disclosure_for_unlabeled() {
2812 let st = state_with(vec![
2813 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(100000), "Alice"),
2814 gift_removal(2, date!(2025 - 06 - 01), dec!(50000)), ]);
2816 let tables = tables_with(2025, dec!(19000));
2817 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2818 assert!(
2820 msg.contains("§2505 lifetime (basic) exclusion"),
2821 "§2505 block must appear: {msg}"
2822 );
2823 assert!(
2824 msg.contains("81000.00"),
2825 "used $81,000 (labeled only) must appear: {msg}"
2826 );
2827 assert!(
2829 msg.contains("§2505 consumption reflects LABELED-donee taxable gifts only"),
2830 "omission disclosure must appear: {msg}"
2831 );
2832 assert!(
2833 msg.contains("50000.00"),
2834 "unlabeled total $50,000 must appear in omission disclosure: {msg}"
2835 );
2836 assert!(
2837 msg.contains("consumption may be understated"),
2838 "under-stated warning must appear: {msg}"
2839 );
2840 }
2841
2842 #[test]
2844 fn section_2505_stale_chunk3_caveat_is_absent() {
2845 let st = state_with(vec![gift_removal_labeled(
2846 1,
2847 date!(2025 - 06 - 01),
2848 dec!(20000),
2849 "Alice",
2850 )]);
2851 let tables = tables_with(2025, dec!(19000));
2852 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2853 assert!(
2854 !msg.contains("later chunk (Chunk 3)"),
2855 "stale Chunk-3 caveat must be absent: {msg}"
2856 );
2857 assert!(
2858 !msg.contains("§2505 lifetime exemption is a later chunk"),
2859 "stale §2505 future-chunk phrase must be absent: {msg}"
2860 );
2861 }
2862}
2863
2864#[cfg(test)]
2865mod schedule_se_tests {
2866 use super::*;
2870 use rust_decimal_macros::dec;
2871
2872 fn golden1() -> SeTaxResult {
2874 SeTaxResult {
2875 net_se: dec!(100000),
2876 base: dec!(92350.00),
2877 ss: dec!(11451.40),
2878 medicare: dec!(2678.15),
2879 addl: dec!(0.00),
2880 total: dec!(14129.55),
2881 deductible_half: dec!(7064.78),
2882 }
2883 }
2884
2885 fn w2_headline() -> SeTaxResult {
2887 SeTaxResult {
2888 net_se: dec!(100000),
2889 base: dec!(92350.00),
2890 ss: dec!(3236.40),
2891 medicare: dec!(2678.15),
2892 addl: dec!(381.15),
2893 total: dec!(6295.70),
2894 deductible_half: dec!(2957.28),
2895 }
2896 }
2897
2898 fn w2_asymmetric() -> SeTaxResult {
2900 SeTaxResult {
2901 net_se: dec!(100000),
2902 base: dec!(92350.00),
2903 ss: dec!(3236.40),
2904 medicare: dec!(2678.15),
2905 addl: dec!(0.00),
2906 total: dec!(5914.55),
2907 deductible_half: dec!(2957.28),
2908 }
2909 }
2910
2911 fn expenses_headline() -> SeTaxResult {
2916 SeTaxResult {
2917 net_se: dec!(80000),
2918 base: dec!(73880.00),
2919 ss: dec!(9161.12),
2920 medicare: dec!(2142.52),
2921 addl: dec!(0.00),
2922 total: dec!(11303.64),
2923 deductible_half: dec!(5651.82),
2924 }
2925 }
2926
2927 fn expenses_w2_combined() -> SeTaxResult {
2937 SeTaxResult {
2938 net_se: dec!(80000),
2939 base: dec!(73880.00),
2940 ss: dec!(3236.40),
2941 medicare: dec!(2142.52),
2942 addl: dec!(214.92),
2943 total: dec!(5593.84),
2944 deductible_half: dec!(2689.46),
2945 }
2946 }
2947
2948 #[test]
2952 fn business_mining_year_renders_full_section() {
2953 let r = golden1();
2954 let s = render_schedule_se(
2955 2025,
2956 Some(&r),
2957 dec!(100000),
2958 true,
2959 Usd::ZERO,
2960 Usd::ZERO,
2961 Usd::ZERO,
2962 )
2963 .expect("SE section expected");
2964 assert!(s.contains("92350.00"), "net SE earnings base: {s}");
2966 assert!(s.contains("11451.40"), "SS component: {s}");
2967 assert!(s.contains("2678.15"), "Medicare component: {s}");
2968 assert!(s.contains("14129.55"), "total SE tax: {s}");
2969 assert!(s.contains("7064.78"), "§164(f) deductible half: {s}");
2970 assert!(
2971 s.contains("Additional Medicare"),
2972 "addl component labeled: {s}"
2973 );
2974 assert!(
2976 s.contains("$0 W-2 wages"),
2977 "short $0-W-2 note must appear (both W-2 = 0): {s}"
2978 );
2979 assert!(
2980 s.contains("--w2-ss-wages"),
2981 "$0 note must mention --w2-ss-wages flag: {s}"
2982 );
2983 assert!(
2984 !s.contains("OVERSTATED"),
2985 "old OVERSTATED text must be absent (Chunk A regression): {s}"
2986 );
2987 assert!(
2988 !s.contains("UNDERSTATED"),
2989 "old UNDERSTATED text must be absent (Chunk A regression): {s}"
2990 );
2991 assert!(
2993 s.contains("NOT auto-coordinated"),
2994 "§164(f) advisory must appear: {s}"
2995 );
2996 assert!(
2997 s.contains("coordinate it on your actual return"),
2998 "§164(f) advisory must include coordination instruction: {s}"
2999 );
3000 assert!(
3002 s.contains("SEPARATE federal liability"),
3003 "standalone note: {s}"
3004 );
3005 assert!(
3006 s.contains("not") && s.contains("§164(f)"),
3007 "notes §164(f) not auto-coordinated: {s}"
3008 );
3009 assert!(
3011 s.contains("no Schedule C expenses supplied"),
3012 "Chunk B $0-expenses note must appear: {s}"
3013 );
3014 assert!(
3015 s.contains("--schedule-c-expenses"),
3016 "$0 note must mention --schedule-c-expenses flag: {s}"
3017 );
3018 assert!(
3019 !s.contains("not modeled"),
3020 "old 'not modeled' caveat must be absent (replaced by Chunk B): {s}"
3021 );
3022 }
3023
3024 #[test]
3026 fn w2_set_renders_coordinated_disclosure() {
3027 let r = w2_headline();
3028 let s = render_schedule_se(
3029 2025,
3030 Some(&r),
3031 dec!(100000),
3032 true,
3033 Usd::ZERO,
3034 dec!(150000),
3035 dec!(150000),
3036 )
3037 .expect("SE section expected");
3038 assert!(
3040 s.contains("W-2 coordination applied"),
3041 "coordinated disclosure must appear: {s}"
3042 );
3043 assert!(
3044 s.contains("§1401(b)(2)(B)"),
3045 "must cite §1401(b)(2)(B): {s}"
3046 );
3047 assert!(
3048 s.contains("Form 8959 Part II"),
3049 "must cite Form 8959 Part II: {s}"
3050 );
3051 assert!(s.contains("150000"), "w2_ss_wages amount must appear: {s}");
3053 assert!(!s.contains("OVERSTATED"), "OVERSTATED must be absent: {s}");
3055 assert!(
3056 !s.contains("UNDERSTATED"),
3057 "UNDERSTATED must be absent: {s}"
3058 );
3059 assert!(s.contains("3236.40"), "reduced SS component: {s}");
3061 assert!(s.contains("381.15"), "non-zero addl: {s}");
3062 assert!(s.contains("6295.70"), "reduced total: {s}");
3063 assert!(s.contains("2957.28"), "deductible_half: {s}");
3064 }
3065
3066 #[test]
3070 fn w2_asymmetric_render_transposition_guard() {
3071 let r = w2_asymmetric();
3072 let s = render_schedule_se(
3073 2025,
3074 Some(&r),
3075 dec!(100000),
3076 true,
3077 Usd::ZERO,
3078 dec!(150000),
3079 Usd::ZERO,
3080 )
3081 .expect("SE section expected");
3082 assert!(
3084 s.contains("W-2 coordination applied"),
3085 "coordinated disclosure must appear: {s}"
3086 );
3087 assert!(s.contains("3236.40"), "ss must be 3236.40 (reduced): {s}");
3089 assert!(
3090 s.contains("0.00"),
3091 "addl must be 0.00 (threshold un-reduced): {s}"
3092 );
3093 assert!(!s.contains("OVERSTATED"), "{s}");
3095 assert!(!s.contains("UNDERSTATED"), "{s}");
3096 }
3097
3098 #[test]
3100 fn no_business_income_no_section() {
3101 assert!(
3102 render_schedule_se(2025, None, Usd::ZERO, true, Usd::ZERO, Usd::ZERO, Usd::ZERO)
3103 .is_none()
3104 );
3105 }
3106
3107 #[test]
3110 fn business_income_but_no_table_emits_note() {
3111 let s = render_schedule_se(
3112 2099,
3113 None,
3114 dec!(100000),
3115 false,
3116 Usd::ZERO,
3117 Usd::ZERO,
3118 Usd::ZERO,
3119 )
3120 .expect("wage-base-unavailable note expected");
3121 assert!(s.contains("SS wage base unavailable"), "{s}");
3122 assert!(s.contains("2099"), "names the year: {s}");
3123 assert!(s.contains("no silent drop"), "{s}");
3124 }
3125
3126 #[test]
3131 fn expenses_20k_no_w2_renders_breakout_and_advisory() {
3132 let r = expenses_headline(); let s = render_schedule_se(
3134 2025,
3135 Some(&r),
3136 dec!(100000), true,
3138 dec!(20000), Usd::ZERO,
3140 Usd::ZERO,
3141 )
3142 .expect("SE section expected");
3143 assert!(
3145 s.contains("gross business income"),
3146 "breakout line must appear: {s}"
3147 );
3148 assert!(
3149 s.contains("100000.00"),
3150 "gross ($100k) must appear in breakout: {s}"
3151 );
3152 assert!(
3153 s.contains("20000.00"),
3154 "expenses ($20k) must appear in breakout: {s}"
3155 );
3156 assert!(
3157 s.contains("80000.00"),
3158 "net_se ($80k) must appear in breakout: {s}"
3159 );
3160 assert!(
3162 s.contains("OVERSTATES"),
3163 "Schedule C advisory OVERSTATES text: {s}"
3164 );
3165 assert!(
3166 s.contains("ORDINARY taxable income"),
3167 "advisory must mention ORDINARY taxable income: {s}"
3168 );
3169 assert!(
3170 s.contains("engine-side coordination is deferred"),
3171 "advisory must mention deferred coordination: {s}"
3172 );
3173 assert!(
3175 !s.contains("reduce your ordinary_taxable_income"),
3176 "NO OTI-edit prescription allowed (spec D3): {s}"
3177 );
3178 assert!(
3179 !s.contains("set --ordinary-taxable-income"),
3180 "NO OTI-edit prescription allowed (spec D3): {s}"
3181 );
3182 assert!(s.contains("73880.00"), "base $73,880: {s}");
3184 assert!(s.contains("9161.12"), "ss $9,161.12: {s}");
3185 assert!(s.contains("2142.52"), "medicare $2,142.52: {s}");
3186 assert!(s.contains("11303.64"), "total $11,303.64: {s}");
3187 assert!(s.contains("5651.82"), "deductible_half $5,651.82: {s}");
3188 assert!(
3190 !s.contains("not modeled"),
3191 "old 'not modeled' caveat must be absent: {s}"
3192 );
3193 }
3194
3195 #[test]
3198 fn fully_expensed_shows_new_line_not_wage_base_note() {
3199 let s = render_schedule_se(
3202 2025,
3203 None,
3204 dec!(10000), true, dec!(15000), Usd::ZERO,
3208 Usd::ZERO,
3209 )
3210 .expect("fully-expensed section expected (not None)");
3211 assert!(
3213 s.contains("fully expensed"),
3214 "fully-expensed line must appear: {s}"
3215 );
3216 assert!(
3217 s.contains("10000.00"),
3218 "gross $10k must appear in fully-expensed line: {s}"
3219 );
3220 assert!(
3221 s.contains("15000.00"),
3222 "expenses $15k must appear in fully-expensed line: {s}"
3223 );
3224 assert!(
3225 s.contains("no §1401 SE tax"),
3226 "must state no SE tax owed: {s}"
3227 );
3228 assert!(s.contains("2025"), "must name the year: {s}");
3229 assert!(
3231 !s.contains("SS wage base unavailable"),
3232 "wage-base-unavailable note must be ABSENT for fully-expensed case: {s}"
3233 );
3234 }
3235
3236 #[test]
3238 fn expenses_w2_combined_renders_both() {
3239 let r = expenses_w2_combined(); let s = render_schedule_se(
3241 2025,
3242 Some(&r),
3243 dec!(100000),
3244 true,
3245 dec!(20000), dec!(150000), dec!(150000), )
3249 .expect("SE section expected");
3250 assert!(s.contains("gross business income"), "breakout line: {s}");
3252 assert!(s.contains("80000.00"), "net_se in breakout: {s}");
3253 assert!(s.contains("OVERSTATES"), "Schedule C advisory: {s}");
3255 assert!(
3257 s.contains("W-2 coordination applied"),
3258 "W-2 coordination: {s}"
3259 );
3260 assert!(s.contains("73880.00"), "base: {s}");
3262 assert!(s.contains("3236.40"), "ss (reduced by W-2 cap): {s}");
3263 assert!(s.contains("2142.52"), "medicare: {s}");
3264 assert!(s.contains("214.92"), "addl: {s}");
3265 assert!(s.contains("5593.84"), "total: {s}");
3266 assert!(s.contains("2689.46"), "deductible_half: {s}");
3267 }
3268
3269 #[test]
3271 fn schedule_se_csv_columns_and_values() {
3272 let dir = tempfile::tempdir().unwrap();
3273 let out = dir.path().join("export");
3274 let st = LedgerState::default();
3275 let r = golden1();
3276 write_csv_exports(&out, &st, Some(2025), Some(&r), &BTreeMap::new()).unwrap();
3277
3278 let path = out.join("schedule_se.csv");
3279 assert!(path.exists(), "schedule_se.csv must be written");
3280 let mut rdr = csv::Reader::from_reader(std::fs::File::open(&path).unwrap());
3281 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3282 assert_eq!(
3283 headers,
3284 vec![
3285 "net_se_earnings",
3286 "se_base_9235",
3287 "ss_component",
3288 "medicare_component",
3289 "additional_medicare_component",
3290 "total_se_tax",
3291 "deductible_half",
3292 ]
3293 );
3294 let rec = rdr
3295 .records()
3296 .next()
3297 .expect("one data row")
3298 .expect("readable");
3299 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"); }
3307
3308 #[test]
3310 fn schedule_se_csv_omitted_when_no_se_tax() {
3311 let dir = tempfile::tempdir().unwrap();
3312 let out = dir.path().join("export");
3313 let st = LedgerState::default();
3314 write_csv_exports(&out, &st, Some(2025), None, &BTreeMap::new()).unwrap();
3315 assert!(!out.join("schedule_se.csv").exists());
3316 }
3317}
3318
3319#[cfg(test)]
3320mod form8283_csv_tests {
3321 use super::*;
3324
3325 #[test]
3327 fn form8283_csv_detail_columns_present_and_empty() {
3328 use btctax_core::{
3329 BasisSource, DonationDetails, EventId, LedgerState, Removal, RemovalKind, RemovalLeg,
3330 Term,
3331 };
3332 use time::macros::date;
3333
3334 let dir = tempfile::tempdir().unwrap();
3335 let out = dir.path().join("export");
3336
3337 let event = EventId::decision(99);
3339 let leg = RemovalLeg {
3340 lot_id: btctax_core::LotId {
3341 origin_event_id: event.clone(),
3342 split_sequence: 0,
3343 },
3344 sat: 100_000_000,
3345 basis: rust_decimal::Decimal::ZERO,
3346 fmv_at_transfer: rust_decimal::Decimal::from(52000),
3347 term: Term::LongTerm,
3348 basis_source: BasisSource::ComputedFromCost,
3349 acquired_at: date!(2025 - 01 - 01),
3350 pseudo: false,
3351 };
3352 let removal = Removal {
3353 event: event.clone(),
3354 kind: RemovalKind::Donation,
3355 removed_at: date!(2025 - 03 - 01),
3356 legs: vec![leg],
3357 appraisal_required: false,
3358 donor_acquired_at: None,
3359 claimed_deduction: Some(rust_decimal::Decimal::from(52000)),
3360 donee: Some("Test Charity Two".into()),
3361 };
3362 let event2 = EventId::decision(100);
3364 let leg2 = RemovalLeg {
3365 lot_id: btctax_core::LotId {
3366 origin_event_id: event2.clone(),
3367 split_sequence: 0,
3368 },
3369 sat: 10_000_000,
3370 basis: rust_decimal::Decimal::ZERO,
3371 fmv_at_transfer: rust_decimal::Decimal::from(8000),
3372 term: Term::LongTerm,
3373 basis_source: BasisSource::ComputedFromCost,
3374 acquired_at: date!(2025 - 01 - 15),
3375 pseudo: false,
3376 };
3377 let removal2 = Removal {
3378 event: event2.clone(),
3379 kind: RemovalKind::Donation,
3380 removed_at: date!(2025 - 05 - 01),
3381 legs: vec![leg2],
3382 appraisal_required: false,
3383 donor_acquired_at: None,
3384 claimed_deduction: Some(rust_decimal::Decimal::from(8000)),
3385 donee: Some("No Details Org".into()),
3386 };
3387
3388 let st = LedgerState {
3389 removals: vec![removal, removal2],
3390 ..Default::default()
3391 };
3392
3393 let mut dmap: BTreeMap<EventId, DonationDetails> = BTreeMap::new();
3394 dmap.insert(
3395 event,
3396 DonationDetails {
3397 donee_name: "Test Charity".into(),
3398 donee_ein: Some("12-3456789".into()),
3399 donee_address: Some("123 Main".into()),
3400 appraiser_name: "Test Appraiser".into(),
3401 appraiser_tin: Some("987654321".into()),
3402 appraiser_ptin: Some("P01234567".into()),
3403 appraiser_qualifications: Some("Certified".into()),
3404 appraisal_date: Some(date!(2025 - 06 - 01)),
3405 appraiser_address: None,
3406 fmv_method_override: None,
3407 },
3408 );
3409 write_csv_exports(&out, &st, Some(2025), None, &dmap).unwrap();
3412
3413 let path = out.join("form8283.csv");
3414 assert!(path.exists(), "form8283.csv must exist");
3415
3416 let mut rdr = csv::ReaderBuilder::new()
3417 .comment(Some(b'#'))
3418 .from_path(&path)
3419 .unwrap();
3420 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
3421 let idx = |name: &str| {
3422 headers
3423 .iter()
3424 .position(|h| h == name)
3425 .unwrap_or_else(|| panic!("header {name} not found"))
3426 };
3427 let all_recs: Vec<csv::StringRecord> = rdr.records().map(|r| r.unwrap()).collect();
3431 assert_eq!(
3432 all_recs.len(),
3433 2,
3434 "must have exactly two data rows (one per removal)"
3435 );
3436 let rec = &all_recs[0];
3437 let no_details_rec = &all_recs[1];
3438
3439 assert_eq!(&rec[idx("donee")], "Test Charity");
3441 assert_eq!(&rec[idx("appraiser")], "Test Appraiser");
3442 assert_eq!(&rec[idx("donee_ein")], "12-3456789");
3443 assert_eq!(&rec[idx("donee_address")], "123 Main");
3444 assert_eq!(&rec[idx("appraiser_tin")], "987654321");
3445 assert_eq!(&rec[idx("appraiser_ptin")], "P01234567");
3446 assert_eq!(&rec[idx("appraiser_qualifications")], "Certified");
3447 assert_eq!(&rec[idx("appraisal_date")], "2025-06-01");
3448 assert_eq!(&rec[idx("needs_review")], "false");
3449
3450 assert_eq!(
3452 &no_details_rec[idx("donee_ein")],
3453 "",
3454 "no-details row: donee_ein must be empty"
3455 );
3456 assert_eq!(
3457 &no_details_rec[idx("donee_address")],
3458 "",
3459 "no-details row: donee_address must be empty"
3460 );
3461 assert_eq!(
3462 &no_details_rec[idx("appraiser_tin")],
3463 "",
3464 "no-details row: appraiser_tin must be empty"
3465 );
3466 assert_eq!(
3467 &no_details_rec[idx("appraiser_ptin")],
3468 "",
3469 "no-details row: appraiser_ptin must be empty"
3470 );
3471 assert_eq!(
3472 &no_details_rec[idx("appraiser_qualifications")],
3473 "",
3474 "no-details row: appraiser_qualifications must be empty"
3475 );
3476 assert_eq!(
3477 &no_details_rec[idx("appraisal_date")],
3478 "",
3479 "no-details row: appraisal_date must be empty"
3480 );
3481 assert_eq!(
3482 &no_details_rec[idx("needs_review")],
3483 "true",
3484 "no-details carrier row: needs_review must be true"
3485 );
3486 }
3487}
3488
3489#[cfg(test)]
3490mod advisory_wrap_tests {
3491 use super::*;
3492
3493 #[test]
3497 fn advisories_wrap_to_the_house_width_with_a_hanging_indent() {
3498 use btctax_core::tax::advisories::Advisory;
3499 let out = render_advisories(&[Advisory::CtcOdcOmitted { dependents: 2 }]);
3500
3501 for line in out.lines() {
3502 assert!(
3503 line.chars().count() <= ADVISORY_WRAP_COLS,
3504 "line is {} cols, over the {}-col house width: {line:?}",
3505 line.chars().count(),
3506 ADVISORY_WRAP_COLS
3507 );
3508 }
3509 assert!(
3511 out.lines()
3512 .any(|l| l.starts_with(" ") && !l.trim().is_empty()),
3513 "a 300-char advisory must wrap onto continuation lines, got:\n{out}"
3514 );
3515 }
3516}