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