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