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 dispose_kind_tag(dk: DisposeKind) -> &'static str {
57 match dk {
58 DisposeKind::Sell => "sell",
59 DisposeKind::Spend => "spend",
60 }
61}
62
63fn income_kind_tag(ik: IncomeKind) -> &'static str {
64 match ik {
65 IncomeKind::Mining => "mining",
66 IncomeKind::Staking => "staking",
67 IncomeKind::Interest => "interest",
68 IncomeKind::Airdrop => "airdrop",
69 IncomeKind::Reward => "reward",
70 }
71}
72
73fn gift_zone_tag(gz: GiftZone) -> &'static str {
74 match gz {
75 GiftZone::Gain => "gain",
76 GiftZone::Loss => "loss",
77 GiftZone::NoGainNoLoss => "no_gain_no_loss",
78 }
79}
80
81fn removal_kind_tag(rk: RemovalKind) -> &'static str {
82 match rk {
83 RemovalKind::Gift => "gift",
84 RemovalKind::Donation => "donation",
85 }
86}
87
88fn term_tag(t: Term) -> &'static str {
90 match t {
91 Term::ShortTerm => "short",
92 Term::LongTerm => "long",
93 }
94}
95
96fn form8949_part_tag(p: Form8949Part) -> &'static str {
98 match p {
99 Form8949Part::ShortTerm => "ST",
100 Form8949Part::LongTerm => "LT",
101 }
102}
103
104fn form8949_box_tag(b: Form8949Box) -> &'static str {
107 match b {
108 Form8949Box::C => "C",
109 Form8949Box::F => "F",
110 }
111}
112
113fn form8283_section_tag(s: Form8283Section) -> &'static str {
116 match s {
117 Form8283Section::A => "A",
118 Form8283Section::B => "B",
119 }
120}
121
122fn form8283_how_acquired_tag(h: Form8283HowAcquired) -> &'static str {
125 match h {
126 Form8283HowAcquired::Purchased => "Purchased",
127 Form8283HowAcquired::Gift => "Gift",
128 Form8283HowAcquired::Other => "Other",
129 Form8283HowAcquired::Review => "Review",
130 }
131}
132
133fn compliance_status_tag(cs: &ComplianceStatus) -> String {
142 match cs {
143 ComplianceStatus::StandingOrder { effective_from } => {
144 format!("standing_order:{effective_from}")
145 }
146 ComplianceStatus::Contemporaneous => "contemporaneous".into(),
147 ComplianceStatus::AttestedRecording => "attested_recording".into(),
148 ComplianceStatus::NonCompliant => "non_compliant".into(),
149 }
150}
151
152pub fn render_file_reports(reports: &[FileReport], import: &ImportReport) -> String {
154 let mut out = String::new();
155 let _ = writeln!(out, "Import:");
156 for r in reports {
157 let _ = writeln!(
158 out,
159 " {} [{}]: parsed {} rows -> {} BTC events ({} dropped no-BTC, {} unclassified)",
160 r.source.tag(),
161 r.label,
162 r.parsed_rows,
163 r.btc_events,
164 r.dropped_no_btc,
165 r.unclassified
166 );
167 }
168 let _ = writeln!(
169 out,
170 " appended {} | duplicates {} | NEW import-conflicts {}",
171 import.appended, import.duplicates, import.conflicts
172 );
173 if import.conflicts > 0 {
174 let _ = writeln!(
175 out,
176 " ! resolve conflicts with `reconcile accept-conflict <id>` or `reject-conflict <id>` (see `verify`)"
177 );
178 }
179 out
180}
181
182pub fn wallet_label(w: &WalletId) -> String {
184 match w {
185 WalletId::Exchange { provider, account } => format!("exchange:{provider}:{account}"),
186 WalletId::SelfCustody { label } => format!("self:{label}"),
187 }
188}
189
190fn disposal_year(d: &btctax_core::Disposal) -> i32 {
191 d.disposed_at.year()
192}
193
194pub fn render_report(state: &LedgerState, year: Option<i32>) -> String {
196 let mut out = String::new();
197 let yr = |y: i32| year.is_none_or(|f| f == y); let _ = writeln!(out, "Holdings (per wallet):");
200 if state.holdings_by_wallet.is_empty() {
201 let _ = writeln!(out, " none");
202 }
203 for (w, sat) in &state.holdings_by_wallet {
204 let _ = writeln!(out, " {}: {} sat", wallet_label(w), sat);
205 }
206
207 let _ = writeln!(out, "Lots:");
208 if state.lots.is_empty() {
209 let _ = writeln!(out, " none");
210 }
211 for l in &state.lots {
212 let _ = writeln!(
213 out,
214 " {}#{} {} remaining {} sat | basis {} ({}){}",
215 l.lot_id.origin_event_id.canonical(),
216 l.lot_id.split_sequence,
217 wallet_label(&l.wallet),
218 l.remaining_sat,
219 l.usd_basis,
220 basis_source_tag(l.basis_source),
221 if l.basis_pending {
222 " [basis pending]"
223 } else {
224 ""
225 }
226 );
227 }
228
229 let label = match year {
230 Some(y) => format!("(year {y})"),
231 None => "(all years)".to_string(),
232 };
233
234 let disposals: Vec<_> = state
235 .disposals
236 .iter()
237 .filter(|d| yr(disposal_year(d)))
238 .collect();
239 if disposals.is_empty() {
240 let _ = writeln!(out, "Disposals {}: none", label);
241 } else {
242 let _ = writeln!(out, "Disposals {}:", label);
243 for d in disposals {
244 let _ = writeln!(
245 out,
246 " {} @ {} ({})",
247 dispose_kind_tag(d.kind),
248 d.disposed_at,
249 d.event.canonical()
250 );
251 for leg in &d.legs {
252 render_disposal_leg(&mut out, leg);
253 }
254 }
255 }
256
257 let removals: Vec<_> = state
258 .removals
259 .iter()
260 .filter(|r| yr(r.removed_at.year()))
261 .collect();
262 if removals.is_empty() {
263 let _ = writeln!(out, "Removals {}: none", label);
264 } else {
265 let _ = writeln!(out, "Removals {}:", label);
266 for r in removals {
267 let deduction_tag = match r.claimed_deduction {
268 Some(d) => format!(" [claimed deduction {}]", fmt_money(d)),
269 None => String::new(),
270 };
271 let _ = writeln!(
272 out,
273 " {} @ {} ({}){}",
274 removal_kind_tag(r.kind),
275 r.removed_at,
276 r.event.canonical(),
277 deduction_tag
278 );
279 for leg in &r.legs {
280 render_removal_leg(&mut out, leg);
281 }
282 }
283 }
284
285 let income: Vec<_> = state
286 .income_recognized
287 .iter()
288 .filter(|i| yr(i.recognized_at.year()))
289 .collect();
290 if income.is_empty() {
291 let _ = writeln!(out, "Income {}: none", label);
292 } else {
293 let _ = writeln!(out, "Income {}:", label);
294 for i in income {
295 let _ = writeln!(
296 out,
297 " {} @ {} {} sat = {} USD{}",
298 income_kind_tag(i.kind),
299 i.recognized_at,
300 i.sat,
301 i.usd_fmv,
302 if i.business { " [business]" } else { "" }
303 );
304 }
305 }
306
307 let charitable_total: btctax_core::conventions::Usd = state
310 .removals
311 .iter()
312 .filter(|r| yr(r.removed_at.year()) && r.kind == RemovalKind::Donation)
313 .filter_map(|r| r.claimed_deduction)
314 .sum();
315 let _ = writeln!(
316 out,
317 "Charitable deduction {} (Schedule A itemized) — BEFORE §170(b) AGI limits / carryover: {}",
318 label,
319 fmt_money(charitable_total)
320 );
321
322 out
323}
324
325fn render_disposal_leg(out: &mut String, leg: &DisposalLeg) {
326 let zone = leg
327 .gift_zone
328 .map(|z| format!(" gift-zone {}", gift_zone_tag(z)))
329 .unwrap_or_default();
330 let _ = writeln!(
331 out,
332 " {} sat: proceeds {} basis {} gain {} {}{}",
333 leg.sat,
334 leg.proceeds,
335 leg.basis,
336 leg.gain,
337 term_tag(leg.term),
338 zone
339 );
340}
341
342fn render_removal_leg(out: &mut String, leg: &RemovalLeg) {
343 let _ = writeln!(
344 out,
345 " {} sat: basis {} fmv {} {} (zero gain)",
346 leg.sat,
347 leg.basis,
348 leg.fmv_at_transfer,
349 term_tag(leg.term)
350 );
351}
352
353pub fn filing_status_tag(fs: btctax_core::FilingStatus) -> &'static str {
361 use btctax_core::FilingStatus::*;
362 match fs {
363 Single => "single",
364 Mfj => "mfj",
365 Mfs => "mfs",
366 HoH => "hoh",
367 Qss => "qss",
368 }
369}
370
371fn lot_method_display(m: LotMethod) -> &'static str {
373 match m {
374 LotMethod::Fifo => "FIFO",
375 LotMethod::Lifo => "LIFO",
376 LotMethod::Hifo => "HIFO",
377 }
378}
379
380#[derive(Debug, Clone)]
382pub struct ElectionLine {
383 pub recorded: TaxDate,
384 pub effective_from: TaxDate,
385 pub method: LotMethod,
386 pub note: &'static str,
388}
389
390#[derive(Debug, Clone)]
392pub struct VerifyReport {
393 pub conservation: ConservationReport,
394 pub hard: Vec<Blocker>,
395 pub advisory: Vec<Blocker>,
396 pub pending: usize,
397 pub unknown_basis_inbounds: usize,
398 pub safe_harbor: String,
399 pub declared_pre2025_method: LotMethod,
401 pub pre2025_method_attested: bool,
402 pub elections: Vec<ElectionLine>,
404 pub selection_count: usize,
406 pub compliance: Vec<DisposalCompliance>,
408}
409
410impl VerifyReport {
411 pub fn has_hard_blockers(&self) -> bool {
414 !self.hard.is_empty()
415 }
416}
417
418fn safe_harbor_status(state: &LedgerState, _events: &[LedgerEvent]) -> String {
429 let effective_path_b = state
432 .lots
433 .iter()
434 .any(|l| l.basis_source == BasisSource::SafeHarborAllocated)
435 || state.disposals.iter().any(|d| {
436 d.legs
437 .iter()
438 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
439 })
440 || state.removals.iter().any(|r| {
441 r.legs
442 .iter()
443 .any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
444 });
445 let unconservable = state
446 .blockers
447 .iter()
448 .any(|b| b.kind == BlockerKind::SafeHarborUnconservable);
449 let timebar = state
450 .blockers
451 .iter()
452 .any(|b| b.kind == BlockerKind::SafeHarborTimebar);
453 if unconservable {
457 "Path B allocation FAILS conservation/eligibility (hard, §7.4) — fix the allocation"
458 .to_string()
459 } else if effective_path_b {
460 "Path B safe-harbor allocation is effective (§7.4)".to_string()
461 } else if timebar {
462 "Path B time-barred -> using Path A (advisory); `reconcile safe-harbor attest` if timely in your books".to_string()
463 } else {
464 "Path A (actual per-wallet reconstruction; default, no election)".to_string()
465 }
466}
467
468pub fn build_verify(state: &LedgerState, events: &[LedgerEvent], cli: &CliConfig) -> VerifyReport {
469 let conservation = conservation_report(state);
470 let mut hard = Vec::new();
471 let mut advisory = Vec::new();
472 for b in &state.blockers {
473 match b.kind.severity() {
474 Severity::Hard => hard.push(b.clone()),
475 Severity::Advisory => advisory.push(b.clone()),
476 }
477 }
478 let unknown_basis_inbounds = state
479 .blockers
480 .iter()
481 .filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
482 .count();
483
484 let voided: BTreeSet<EventId> = events
486 .iter()
487 .filter_map(|e| match &e.payload {
488 EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
489 _ => None,
490 })
491 .collect();
492
493 let mut election_events: Vec<(u64, &LedgerEvent)> = events
495 .iter()
496 .filter_map(|e| {
497 if let EventId::Decision { seq } = e.id {
498 if matches!(e.payload, EventPayload::MethodElection(_)) {
499 return Some((seq, e));
500 }
501 }
502 None
503 })
504 .collect();
505 election_events.sort_by_key(|(s, _)| *s);
506
507 let elections: Vec<ElectionLine> = election_events
508 .iter()
509 .map(|(_, e)| {
510 let EventPayload::MethodElection(me) = &e.payload else {
511 unreachable!("filtered to MethodElection above")
512 };
513 let recorded = tax_date(e.utc_timestamp, e.original_tz);
514 let note = if voided.contains(&e.id) {
515 "voided"
516 } else if me.effective_from < TRANSITION_DATE || me.effective_from < recorded {
517 "backdated/ignored"
518 } else {
519 "in force"
520 };
521 ElectionLine {
522 recorded,
523 effective_from: me.effective_from,
524 method: me.method,
525 note,
526 }
527 })
528 .collect();
529
530 let selection_count = events
535 .iter()
536 .filter(|e| matches!(e.payload, EventPayload::LotSelection(_)) && !voided.contains(&e.id))
537 .count();
538
539 let compliance = disposal_compliance(events, state);
541
542 VerifyReport {
543 conservation,
544 hard,
545 advisory,
546 pending: state.pending_reconciliation.len(),
547 unknown_basis_inbounds,
548 safe_harbor: safe_harbor_status(state, events),
549 declared_pre2025_method: cli.pre2025_method,
550 pre2025_method_attested: cli.pre2025_method_attested,
551 elections,
552 selection_count,
553 compliance,
554 }
555}
556
557pub fn write_csv_exports(
569 out_dir: &Path,
570 state: &LedgerState,
571 tax_year: Option<i32>,
572 se_result: Option<&SeTaxResult>,
573 donation_details: &BTreeMap<EventId, DonationDetails>,
574) -> Result<(), crate::CliError> {
575 fsperms::mkdir_owner_only(out_dir)?;
576
577 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("lots.csv"))?);
578 w.write_record([
579 "origin_event",
580 "split",
581 "wallet",
582 "acquired_at",
583 "remaining_sat",
584 "usd_basis",
585 "basis_source",
586 "basis_pending",
587 ])?;
588 for l in &state.lots {
589 w.write_record([
590 l.lot_id.origin_event_id.canonical(),
591 l.lot_id.split_sequence.to_string(),
592 wallet_label(&l.wallet),
593 l.acquired_at.to_string(),
594 l.remaining_sat.to_string(),
595 l.usd_basis.to_string(),
596 basis_source_tag(l.basis_source).to_string(),
597 l.basis_pending.to_string(),
598 ])?;
599 }
600 w.flush()?;
601
602 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("disposals.csv"))?);
603 w.write_record([
604 "event",
605 "kind",
606 "disposed_at",
607 "lot",
608 "sat",
609 "proceeds",
610 "basis",
611 "gain",
612 "term",
613 "gift_zone",
614 "acquired_at",
615 "wallet",
616 ])?;
617 for d in &state.disposals {
618 for leg in &d.legs {
619 w.write_record([
620 d.event.canonical(),
621 dispose_kind_tag(d.kind).to_string(),
622 d.disposed_at.to_string(),
623 format!(
624 "{}#{}",
625 leg.lot_id.origin_event_id.canonical(),
626 leg.lot_id.split_sequence
627 ),
628 leg.sat.to_string(),
629 leg.proceeds.to_string(),
630 leg.basis.to_string(),
631 leg.gain.to_string(),
632 term_tag(leg.term).to_string(),
633 leg.gift_zone
634 .map(|z| gift_zone_tag(z).to_string())
635 .unwrap_or_default(),
636 leg.acquired_at.to_string(),
637 wallet_label(&leg.wallet),
638 ])?;
639 }
640 }
641 w.flush()?;
642
643 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("removals.csv"))?);
644 w.write_record([
651 "event",
652 "kind",
653 "removed_at",
654 "lot",
655 "sat",
656 "basis",
657 "fmv_at_transfer",
658 "term",
659 "acquired_at",
660 "claimed_deduction",
661 "donee",
662 ])?;
663 for r in &state.removals {
664 let deduction_first = r
665 .claimed_deduction
666 .map(|d| d.to_string())
667 .unwrap_or_default();
668 let donee_cell = r.donee.clone().unwrap_or_default();
669 for (leg_idx, leg) in r.legs.iter().enumerate() {
670 let deduction_cell: &str = if leg_idx == 0 { &deduction_first } else { "" };
673 w.write_record([
674 r.event.canonical(),
675 removal_kind_tag(r.kind).to_string(),
676 r.removed_at.to_string(),
677 format!(
678 "{}#{}",
679 leg.lot_id.origin_event_id.canonical(),
680 leg.lot_id.split_sequence
681 ),
682 leg.sat.to_string(),
683 leg.basis.to_string(),
684 leg.fmv_at_transfer.to_string(),
685 term_tag(leg.term).to_string(),
686 leg.acquired_at.to_string(),
687 deduction_cell.to_string(),
688 donee_cell.clone(),
689 ])?;
690 }
691 }
692 w.flush()?;
693
694 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("income.csv"))?);
695 w.write_record([
696 "event",
697 "kind",
698 "recognized_at",
699 "sat",
700 "usd_fmv",
701 "business",
702 ])?;
703 for i in &state.income_recognized {
704 w.write_record([
705 i.event.canonical(),
706 income_kind_tag(i.kind).to_string(),
707 i.recognized_at.to_string(),
708 i.sat.to_string(),
709 i.usd_fmv.to_string(),
710 i.business.to_string(),
711 ])?;
712 }
713 w.flush()?;
714
715 if let Some(year) = tax_year {
718 write_form8949_csv(out_dir, state, year)?;
719 write_schedule_d_csv(out_dir, state, year)?;
720 write_form8283_csv(out_dir, state, year, donation_details)?;
721 if let Some(se) = se_result {
728 write_schedule_se_csv(out_dir, se)?;
729 }
730 }
731 Ok(())
732}
733
734pub fn write_form_csvs(
749 out_dir: &Path,
750 state: &LedgerState,
751 year: i32,
752 se_result: Option<&SeTaxResult>,
753 donation_details: &BTreeMap<EventId, DonationDetails>,
754) -> Result<(), crate::CliError> {
755 fsperms::mkdir_owner_only(out_dir)?;
756 write_form8949_csv(out_dir, state, year)?;
757 write_schedule_d_csv(out_dir, state, year)?;
758 write_form8283_csv(out_dir, state, year, donation_details)?;
759 if let Some(se) = se_result {
760 write_schedule_se_csv(out_dir, se)?;
761 }
762 Ok(())
763}
764
765fn write_schedule_se_csv(out_dir: &Path, se: &SeTaxResult) -> Result<(), crate::CliError> {
769 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_se.csv"))?);
770 w.write_record([
771 "net_se_earnings",
772 "se_base_9235",
773 "ss_component",
774 "medicare_component",
775 "additional_medicare_component",
776 "total_se_tax",
777 "deductible_half",
778 ])?;
779 w.write_record([
780 se.net_se.to_string(),
781 se.base.to_string(),
782 se.ss.to_string(),
783 se.medicare.to_string(),
784 se.addl.to_string(),
785 se.total.to_string(),
786 se.deductible_half.to_string(),
787 ])?;
788 w.flush()?;
789 Ok(())
790}
791
792pub const FORM_8283_AGGREGATION_CAVEAT: &str =
798 "Section A/B reflects the \u{00a7}170(f)(11)(F) year-aggregate for similar property: all BTC \
799 donations in the year are summed (all BTC is 'similar property'); if the year-total claimed \
800 deduction exceeds $5,000 every row is Section B (qualified appraisal required), otherwise \
801 Section A. CCA 202302012: the readily-valued exception does not apply to crypto.";
802
803pub fn render_donation_appraisal_advisory(state: &LedgerState, year: i32) -> Option<String> {
821 use btctax_core::QUALIFIED_APPRAISAL_THRESHOLD;
822 let agg = year_donation_deduction(state, year);
823 if agg <= QUALIFIED_APPRAISAL_THRESHOLD {
824 return None;
825 }
826 let threshold = fmt_money(QUALIFIED_APPRAISAL_THRESHOLD);
829 Some(format!(
830 "\u{00a7}170(f)(11)(F): your {year} BTC donations aggregate ${} of claimed deduction \
831 (> ${threshold}) \u{2014} a qualified appraisal is required for the donated BTC even if \
832 no single donation exceeds ${threshold} (all BTC is 'similar property'; CCA 202302012 \
833 \u{2014} no readily-valued exception for crypto).",
834 fmt_money(agg)
835 ))
836}
837
838fn write_form8283_csv(
845 out_dir: &Path,
846 state: &LedgerState,
847 year: i32,
848 details: &BTreeMap<EventId, DonationDetails>,
849) -> Result<(), crate::CliError> {
850 use std::io::Write as _;
851 let mut file = fsperms::open_owner_only(&out_dir.join("form8283.csv"))?;
852
853 writeln!(file, "# {FORM_8283_AGGREGATION_CAVEAT}")?;
855
856 let total_deduction = year_donation_deduction(state, year);
862 if total_deduction <= rust_decimal::Decimal::from(500) {
863 writeln!(
864 file,
865 "# [R0-M1] The year's total noncash charitable deduction ({}) is <= $500; Form 8283 is \
866 NOT required at that level (rows below are informational only).",
867 fmt_money(total_deduction)
868 )?;
869 }
870
871 let mut w = Writer::from_writer(file);
872 w.write_record([
873 "section",
874 "description",
875 "how_acquired",
876 "date_acquired",
877 "date_contributed",
878 "cost_basis",
879 "fmv",
880 "claimed_deduction",
881 "fmv_method",
882 "donee",
883 "appraiser",
884 "needs_review",
885 "donee_ein",
887 "donee_address",
888 "appraiser_tin",
889 "appraiser_ptin",
890 "appraiser_qualifications",
891 "appraisal_date",
892 ])?;
893 for row in form_8283(state, year, details) {
894 let d = row.details.as_ref();
895 w.write_record([
896 row.section
897 .map(form8283_section_tag)
898 .unwrap_or("")
899 .to_string(),
900 row.description,
901 form8283_how_acquired_tag(row.how_acquired).to_string(),
902 row.date_acquired.to_string(),
903 row.date_contributed.to_string(),
904 row.cost_basis.to_string(),
905 row.fmv.to_string(),
906 row.claimed_deduction
907 .map(|d| d.to_string())
908 .unwrap_or_default(),
909 row.fmv_method,
910 row.donee,
911 row.appraiser,
912 row.needs_review.to_string(),
913 d.and_then(|d| d.donee_ein.clone()).unwrap_or_default(),
915 d.and_then(|d| d.donee_address.clone()).unwrap_or_default(),
916 d.and_then(|d| d.appraiser_tin.clone()).unwrap_or_default(),
917 d.and_then(|d| d.appraiser_ptin.clone()).unwrap_or_default(),
918 d.and_then(|d| d.appraiser_qualifications.clone())
919 .unwrap_or_default(),
920 d.and_then(|d| d.appraisal_date.map(|dt| dt.to_string()))
921 .unwrap_or_default(),
922 ])?;
923 }
924 w.flush()?;
925 Ok(())
926}
927
928fn write_form8949_csv(
931 out_dir: &Path,
932 state: &LedgerState,
933 year: i32,
934) -> Result<(), crate::CliError> {
935 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("form8949.csv"))?);
936 w.write_record([
937 "part",
938 "box",
939 "box_needs_review",
940 "description",
941 "date_acquired",
942 "date_sold",
943 "proceeds",
944 "cost_basis",
945 "adjustment_code",
946 "adjustment_amount",
947 "gain",
948 "wallet",
949 "disposition_kind",
950 ])?;
951 for r in form_8949(state, year) {
952 w.write_record([
953 form8949_part_tag(r.part).to_string(),
954 form8949_box_tag(r.box_).to_string(),
955 r.box_needs_review.to_string(),
956 r.description,
957 r.date_acquired.to_string(),
958 r.date_sold.to_string(),
959 r.proceeds.to_string(),
960 r.cost_basis.to_string(),
961 r.adjustment_code,
962 r.adjustment_amount.to_string(),
963 r.gain.to_string(),
964 wallet_label(&r.wallet),
965 dispose_kind_tag(r.disposition_kind).to_string(),
966 ])?;
967 }
968 w.flush()?;
969 Ok(())
970}
971
972fn write_schedule_d_csv(
975 out_dir: &Path,
976 state: &LedgerState,
977 year: i32,
978) -> Result<(), crate::CliError> {
979 let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_d.csv"))?);
980 w.write_record(["part", "proceeds", "cost_basis", "gain"])?;
981 let totals = schedule_d(state, year);
982 for (part, p) in [("ST", &totals.st), ("LT", &totals.lt)] {
983 w.write_record([
984 part.to_string(),
985 p.proceeds.to_string(),
986 p.cost_basis.to_string(),
987 p.gain.to_string(),
988 ])?;
989 }
990 w.flush()?;
991 Ok(())
992}
993
994pub fn render_tax_outcome(
1001 year: i32,
1002 out: &btctax_core::TaxOutcome,
1003 advisory: Option<&str>,
1004) -> String {
1005 use btctax_core::TaxOutcome::*;
1006 let mut s = String::new();
1007 let _ = writeln!(s, "Federal tax attributable to crypto — tax year {year}");
1008 match out {
1009 NotComputable(b) => {
1010 let _ = writeln!(s, " NOT COMPUTABLE [{:?}]: {}", b.kind, b.detail);
1011 }
1012 Computed(r) => {
1013 let _ = writeln!(
1014 s,
1015 " net short-term: {} net long-term: {}",
1016 fmt_money(r.st_net),
1017 fmt_money(r.lt_net)
1018 );
1019 let _ = writeln!(
1020 s,
1021 " crypto ordinary income (level): {}",
1022 fmt_money(r.ordinary_from_crypto)
1023 );
1024 let ordinary_rate_attributable = r.total_federal_tax_attributable - r.ltcg_tax - r.niit;
1027 let _ = writeln!(
1028 s,
1029 " ordinary-rate tax (attributable): {}",
1030 fmt_money(ordinary_rate_attributable)
1031 );
1032 let _ = writeln!(
1033 s,
1034 " LTCG tax (attributable): {} NIIT (attributable): {}",
1035 fmt_money(r.ltcg_tax),
1036 fmt_money(r.niit)
1037 );
1038 let _ = writeln!(
1039 s,
1040 " TOTAL federal tax attributable to crypto (delta): {} \
1041 (= ordinary-rate + LTCG + NIIT attributable)",
1042 fmt_money(r.total_federal_tax_attributable)
1043 );
1044 let _ = writeln!(
1045 s,
1046 " §1211 loss deduction (level): {} carryforward out: short {} / long {}",
1047 fmt_money(r.loss_deduction),
1048 fmt_money(r.carryforward_out.short),
1049 fmt_money(r.carryforward_out.long)
1050 );
1051 let _ = writeln!(
1052 s,
1053 " marginal rates: ordinary {} / LTCG {} / NIIT {}",
1054 r.marginal_rates.ordinary, r.marginal_rates.ltcg, r.marginal_rates.niit_applies
1055 );
1056 let _ = writeln!(
1057 s,
1058 " (incremental ceteris-paribus delta on the minimal profile; \
1059 excludes AGI-driven SS/IRMAA/AMT/QBI/phaseout effects — I5. §1411 NIIT reduces NII by the \
1060 §1211(b)-allowed net capital loss (≤ $3,000 / $1,500 MFS — Form 8960 line 5a / §1.1411-4(d)) \
1061 and is floored at $0; crypto ordinary income (mining/staking/airdrops/rewards) is correctly \
1062 excluded from NII; crypto-lending interest income (§1411(c)(1)(A)(i)) is INCLUDED in NII; \
1063 mining/staking/airdrops/rewards remain excluded (SE income per §1411(c)(6) or non-NII other income).)"
1064 );
1065 }
1066 }
1067 if let Some(msg) = advisory {
1070 let _ = writeln!(s, " ADVISORY (M4): {msg}");
1071 }
1072 s
1073}
1074
1075pub fn render_schedule_d(
1085 year: i32,
1086 totals: &ScheduleDTotals,
1087 outcome: &btctax_core::TaxOutcome,
1088) -> String {
1089 let mut s = String::new();
1090 let _ = writeln!(
1091 s,
1092 "Schedule D (raw pre-netting part totals) — tax year {year}"
1093 );
1094 let _ = writeln!(
1095 s,
1096 " Part I (short-term): proceeds {} cost basis {} gain {}",
1097 fmt_money(totals.st.proceeds),
1098 fmt_money(totals.st.cost_basis),
1099 fmt_money(totals.st.gain)
1100 );
1101 let _ = writeln!(
1102 s,
1103 " Part II (long-term): proceeds {} cost basis {} gain {}",
1104 fmt_money(totals.lt.proceeds),
1105 fmt_money(totals.lt.cost_basis),
1106 fmt_money(totals.lt.gain)
1107 );
1108 match outcome {
1109 btctax_core::TaxOutcome::NotComputable(_) => {
1110 let _ = writeln!(
1111 s,
1112 " (raw disposition totals shown above; the year's tax is NOT COMPUTABLE until \
1113 the blocker is resolved — these Form 8949/Schedule D part totals are \
1114 informational and are not netted/carried until the tax computes)."
1115 );
1116 }
1117 btctax_core::TaxOutcome::Computed(_) => {
1118 let _ = writeln!(
1119 s,
1120 " Note: §1222/§1211/§1212 netting + carryforward are applied in the tax \
1121 computation (report --tax-year); these are the raw pre-netting Form \
1122 8949/Schedule D part totals."
1123 );
1124 }
1125 }
1126 s
1127}
1128
1129pub fn render_schedule_se(
1151 year: i32,
1152 result: Option<&SeTaxResult>,
1153 gross_se: Usd,
1154 table_present: bool,
1155 schedule_c_expenses: Usd,
1156 w2_ss_wages: Usd,
1157 w2_medicare_wages: Usd,
1158) -> Option<String> {
1159 match result {
1160 Some(r) => {
1161 let mut s = String::new();
1162 let _ = writeln!(
1163 s,
1164 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1165 );
1166 if schedule_c_expenses > Usd::ZERO {
1168 let gross_display = r.net_se + schedule_c_expenses;
1171 let _ = writeln!(
1172 s,
1173 " gross business income {} \u{2212} Schedule C expenses {} = net SE earnings {}",
1174 fmt_money(gross_display),
1175 fmt_money(schedule_c_expenses),
1176 fmt_money(r.net_se)
1177 );
1178 let _ = writeln!(
1180 s,
1181 " (Schedule C advisory) Schedule C expenses also reduce your ORDINARY taxable \
1182 income, but the income-tax total above uses GROSS crypto income \u{2014} to first \
1183 order it OVERSTATES your tax by your marginal ordinary rate applied to {}. The tax \
1184 profile cannot express this (an `ordinary_taxable_income` edit would shift both \
1185 legs of the crypto-attributable delta); the engine-side coordination is deferred \
1186 \u{2014} coordinate it on your actual return.",
1187 fmt_money(schedule_c_expenses)
1188 );
1189 } else {
1190 let _ = writeln!(
1191 s,
1192 " net self-employment income (business crypto, Interest excluded): {}",
1193 fmt_money(r.net_se)
1194 );
1195 let _ = writeln!(
1196 s,
1197 " (Schedule C) no Schedule C expenses supplied (--schedule-c-expenses)"
1198 );
1199 }
1200 let _ = writeln!(
1201 s,
1202 " \u{00d7} 92.35% net-earnings factor (\u{00a7}1402(a)) = net SE earnings: {}",
1203 fmt_money(r.base)
1204 );
1205 let _ = writeln!(
1206 s,
1207 " Social Security component (12.4%, §1401(a); capped at the SS wage base): {}",
1208 fmt_money(r.ss)
1209 );
1210 let _ = writeln!(
1211 s,
1212 " Medicare component (2.9%, §1401(b); uncapped): {}",
1213 fmt_money(r.medicare)
1214 );
1215 let _ = writeln!(
1216 s,
1217 " Additional Medicare component (0.9%, §1401(b)(2)): {}",
1218 fmt_money(r.addl)
1219 );
1220 let _ = writeln!(
1221 s,
1222 " TOTAL self-employment tax (§1401): {}",
1223 fmt_money(r.total)
1224 );
1225 let _ = writeln!(
1226 s,
1227 " §164(f) one-half-SE-tax deduction (above-the-line; EXCLUDES Additional Medicare per \
1228 §164(f)(1)): {}",
1229 fmt_money(r.deductible_half)
1230 );
1231 let _ = writeln!(
1234 s,
1235 " (§164(f) advisory) The §164(f) deduction ({}) is NOT auto-coordinated into the \
1236 income-tax total above — to first order, that total overstates your combined tax by \
1237 your marginal ordinary rate applied to {}. The tax profile cannot express this deduction \
1238 directly (reducing `ordinary_taxable_income` would shift BOTH legs of the \
1239 crypto-attributable delta and only correct the bracket differential, not the level) — \
1240 coordinate it on your actual return.",
1241 fmt_money(r.deductible_half),
1242 fmt_money(r.deductible_half)
1243 );
1244 if w2_ss_wages > Usd::ZERO || w2_medicare_wages > Usd::ZERO {
1247 let _ = writeln!(
1248 s,
1249 " (W-2 coordination applied) SS cap = max(0, wage base \u{2212} {}) (Box 3+7); \
1250 Additional-Medicare threshold reduced (not below 0) by {} (Box 5, \
1251 §1401(b)(2)(B)/Form 8959 Part II).",
1252 fmt_money(w2_ss_wages),
1253 fmt_money(w2_medicare_wages)
1254 );
1255 } else {
1256 let _ = writeln!(
1257 s,
1258 " (W-2) assumes $0 W-2 wages (set --w2-ss-wages/--w2-medicare-wages on the tax \
1259 profile if you had a wage job)."
1260 );
1261 }
1262 if r.base < rust_decimal::Decimal::from(400) {
1265 let _ = writeln!(
1266 s,
1267 " (§6017 filing floor) Net earnings from self-employment ({base}) are below $400: \
1268 a Schedule SE filing is required on account of this income only when net earnings \
1269 from self-employment (the ×92.35% base, §1402(a)) are $400 or more (§6017), and \
1270 below that floor no §1401 SE tax is imposed (§1402(b)(2); church employee income \
1271 excepted — §1402(j)(2), not modeled) — the figures above are shown for \
1272 transparency (other self-employment activities, if any, combine on your actual \
1273 Schedule SE).",
1274 base = fmt_money(r.base)
1275 );
1276 }
1277 let _ = writeln!(
1279 s,
1280 " (standalone) This §1401 SE tax is a SEPARATE federal liability, NOT included in the \
1281 income-tax + NIIT total above; the §164(f) one-half-SE-tax deduction is not \
1282 auto-coordinated into that total."
1283 );
1284 Some(s)
1285 }
1286 None => {
1287 if gross_se.is_zero() {
1288 None } else if !table_present {
1290 let mut s = String::new();
1292 let _ = writeln!(
1293 s,
1294 "Schedule SE (§1401 self-employment tax) — tax year {year}"
1295 );
1296 let _ = writeln!(
1297 s,
1298 " SS wage base unavailable for {year}: business self-employment income is present \
1299 but no bundled tax table (ss_wage_base) exists for {year}; the §1401 SE tax was \
1300 NOT computed (no silent drop)."
1301 );
1302 Some(s)
1303 } else {
1304 let mut s = String::new();
1307 let _ = writeln!(
1308 s,
1309 "Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
1310 );
1311 let _ = writeln!(
1312 s,
1313 " fully expensed: gross {} \u{2212} Schedule C expenses {} \u{2264} $0 \
1314 \u{2192} no \u{00a7}1401 SE tax for {year}.",
1315 fmt_money(gross_se),
1316 fmt_money(schedule_c_expenses)
1317 );
1318 Some(s)
1319 }
1320 }
1321 }
1322}
1323
1324pub fn render_gift_advisory(
1347 state: &LedgerState,
1348 year: i32,
1349 prior_taxable_gifts: btctax_core::conventions::Usd,
1350 tables: &impl btctax_core::TaxTables,
1351) -> Option<String> {
1352 use std::collections::BTreeMap;
1353
1354 let any_gift = state
1356 .removals
1357 .iter()
1358 .any(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year);
1359 if !any_gift {
1360 return None; }
1362
1363 let t = match tables.table_for(year) {
1365 None => {
1366 let total: btctax_core::conventions::Usd = state
1367 .removals
1368 .iter()
1369 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1370 .flat_map(|r| r.legs.iter())
1371 .map(|leg| leg.fmv_at_transfer)
1372 .sum();
1373 return Some(format!(
1374 "Form 709 gift advisory ({year}): gift annual-exclusion table unavailable for \
1375 {year}; Form 709 exposure not evaluated — ${} in gifts recorded.",
1376 fmt_money(total)
1377 ));
1378 }
1379 Some(t) => t,
1380 };
1381 let excl = t.gift_annual_exclusion;
1382
1383 let mut labeled: BTreeMap<String, btctax_core::conventions::Usd> = BTreeMap::new();
1385 let mut unlabeled_count: usize = 0;
1386 let mut unlabeled_total: btctax_core::conventions::Usd = Default::default();
1387
1388 for r in state
1389 .removals
1390 .iter()
1391 .filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
1392 {
1393 let fmv: btctax_core::conventions::Usd = r.legs.iter().map(|l| l.fmv_at_transfer).sum();
1394 match &r.donee {
1395 Some(label) => {
1396 *labeled.entry(label.clone()).or_default() += fmv;
1397 }
1398 None => {
1399 unlabeled_count += 1;
1400 unlabeled_total += fmv;
1401 }
1402 }
1403 }
1404
1405 let mut filing_required_donees: Vec<String> = Vec::new();
1407 let mut total_taxable: btctax_core::conventions::Usd = Default::default();
1408 let mut s = format!("Form 709 gift advisory ({year}):");
1409
1410 if !labeled.is_empty() {
1411 s.push_str(&format!(
1412 "\n§2503(b) per-donee annual exclusion analysis (TY{year}, exclusion ${}):",
1413 fmt_money(excl)
1414 ));
1415 for (donee, &total) in &labeled {
1416 let applied = if total < excl { total } else { excl };
1417 let taxable: btctax_core::conventions::Usd = if total > excl {
1418 total - excl
1419 } else {
1420 Default::default()
1421 };
1422 s.push_str(&format!(
1423 "\n {donee}: total ${}, exclusion applied ${}, taxable ${}",
1424 fmt_money(total),
1425 fmt_money(applied),
1426 fmt_money(taxable)
1427 ));
1428 if total > excl {
1429 filing_required_donees.push(donee.clone());
1430 total_taxable += taxable;
1431 }
1432 }
1433 if !filing_required_donees.is_empty() {
1434 s.push_str(&format!(
1435 "\nForm 709 filing required (donee(s): {}). Total taxable gifts: ${}.",
1436 filing_required_donees.join(", "),
1437 fmt_money(total_taxable)
1438 ));
1439 } else {
1440 s.push_str(&format!(
1441 "\nNo Form 709 filing required based on per-donee totals \
1442 (each ≤ ${} exclusion). Total taxable gifts: $0.00.",
1443 fmt_money(excl)
1444 ));
1445 }
1446 }
1447
1448 if unlabeled_count > 0 {
1449 s.push_str(&format!(
1450 "\nNOTE: {unlabeled_count} gift(s) totalling ${} have no donee label — the §2503(b) \
1451 annual exclusion is PER DONEE and cannot be applied without one; label them via \
1452 `reconcile reclassify-outflow --donee`. Shown as a single conservative aggregate.",
1453 fmt_money(unlabeled_total)
1454 ));
1455 if unlabeled_total > excl {
1458 s.push_str(&format!(
1459 "\n Conservative aggregate ${} > ${} (one exclusion); \
1460 verify per-donee totals after labelling.",
1461 fmt_money(unlabeled_total),
1462 fmt_money(excl)
1463 ));
1464 } else {
1465 s.push_str(&format!(
1466 "\n Conservative aggregate ${} \u{2264} ${} (one exclusion); \
1467 per-donee exposure unverifiable without labels.",
1468 fmt_money(unlabeled_total),
1469 fmt_money(excl)
1470 ));
1471 }
1472 }
1473
1474 let lifetime_excl = t.gift_lifetime_exclusion;
1478 let cumulative_taxable = prior_taxable_gifts + total_taxable;
1479
1480 if cumulative_taxable > btctax_core::conventions::Usd::ZERO {
1482 let remaining = if cumulative_taxable >= lifetime_excl {
1483 btctax_core::conventions::Usd::ZERO
1484 } else {
1485 lifetime_excl - cumulative_taxable
1486 };
1487 s.push_str(&format!(
1488 "\n§2505 lifetime (basic) exclusion: you have used ${} of your ${} ({year}) lifetime \
1489 exclusion (${} remaining). No gift tax is DUE until cumulative taxable gifts exceed \
1490 the lifetime exclusion.",
1491 fmt_money(cumulative_taxable),
1492 fmt_money(lifetime_excl),
1493 fmt_money(remaining),
1494 ));
1495 if cumulative_taxable > lifetime_excl {
1497 let excess = cumulative_taxable - lifetime_excl;
1498 s.push_str(&format!(
1499 "\nlifetime exclusion EXCEEDED — gift tax may be due on ${} \
1500 (the excess base past the unified credit, not a computed tax); \
1501 consult a professional.",
1502 fmt_money(excess),
1503 ));
1504 }
1505 if unlabeled_count > 0 {
1508 s.push_str(&format!(
1509 "\n§2505 consumption reflects LABELED-donee taxable gifts only; \
1510 {unlabeled_count} unlabeled gift(s) totalling ${} are NOT included — \
1511 label them via `--donee` for a complete figure; consumption may be \
1512 understated / remaining overstated.",
1513 fmt_money(unlabeled_total),
1514 ));
1515 }
1516 }
1517
1518 s.push_str(
1521 "\nCaveats: §2513 gift-splitting (MFJ) not modeled (single-filer advisory only); \
1522 future-interest gifts (which require Form 709 filing regardless of amount) not \
1523 detectable; §2505 figures are advisory only — no portability/DSUE (§2010(c)(4)) \
1524 applied; prior cumulative taxable gifts are user-supplied (default $0 if \
1525 --prior-taxable-gifts not given).",
1526 );
1527
1528 Some(s)
1529}
1530
1531fn picks_str(picks: &[btctax_core::LotPick]) -> String {
1537 if picks.is_empty() {
1538 return "(none)".to_string();
1539 }
1540 picks
1541 .iter()
1542 .map(|p| {
1543 format!(
1544 "{}#{}:{}",
1545 p.lot.origin_event_id.canonical(),
1546 p.lot.split_sequence,
1547 p.sat
1548 )
1549 })
1550 .collect::<Vec<_>>()
1551 .join(", ")
1552}
1553
1554pub fn render_optimize_proposal(p: &btctax_core::OptimizeProposal) -> String {
1567 use btctax_core::{ApproxReason, Persistability};
1568 let mut s = String::new();
1569 let _ = writeln!(
1570 s,
1571 "Optimize (what-if) — tax year {} — NOTHING is filed or bound by running this.",
1572 p.year
1573 );
1574 if p.approximate {
1576 let why = match p.approx_reason {
1577 Some(ApproxReason::ComboCapExceeded { combos, cap }) => format!(
1578 "input exceeded the exhaustive bound ({combos} combos > {cap}); \
1579 a coordinate-descent fallback ran"
1580 ),
1581 Some(ApproxReason::ContentionUnenumerated { contended, .. }) => format!(
1582 "{contended} contended same-wallet disposal(s) could not be fully joint-enumerated"
1583 ),
1584 Some(ApproxReason::PoolHeuristic { lots, bound }) => format!(
1585 "a pool of {lots} lots exceeds the {bound}-lot exhaustive-enumeration bound; \
1586 only a deterministic heuristic SUBSET of that pool's identifications was searched"
1587 ),
1588 None => "approximate".to_string(),
1589 };
1590 let _ = writeln!(
1591 s,
1592 " \u{26a0} APPROXIMATE \u{2014} NOT a guaranteed global minimum: {why}."
1593 );
1594 let _ = writeln!(
1595 s,
1596 " The true least-tax assignment may be lower; this is a disclosed improvement over your"
1597 );
1598 let _ = writeln!(
1599 s,
1600 " current filing position (delta \u{2264} 0), NOT \u{2018}the least tax.\u{2019}"
1601 );
1602 }
1603 let _ = writeln!(
1604 s,
1605 " current federal tax (attributable): {}",
1606 p.baseline_tax
1607 );
1608 let _ = writeln!(
1609 s,
1610 " optimized federal tax (attributable): {}",
1611 p.optimized_tax
1612 );
1613 let _ = writeln!(
1614 s,
1615 " delta (optimized \u{2212} current): {} (negative = saving; always \u{2264} 0)",
1616 p.delta
1617 );
1618 for d in &p.per_disposal {
1619 let _ = writeln!(
1620 s,
1621 " {} @ {} [{}] :: {}",
1622 d.disposal.canonical(),
1623 d.date,
1624 wallet_label(&d.wallet),
1625 compliance_status_tag(&d.status)
1626 );
1627 if d.proposed_selection == d.current_selection {
1632 let _ = writeln!(
1633 s,
1634 " proposed: {} [no change \u{2014} already optimal under current identification]",
1635 picks_str(&d.proposed_selection)
1636 );
1637 continue;
1638 }
1639 let persist = match d.persistable {
1640 Persistability::ContemporaneousNow => {
1641 "persistable now (made \u{2264} sale \u{2192} Contemporaneous)"
1642 }
1643 Persistability::NeedsAttestation => {
1644 "already executed \u{2014} needs `optimize accept --disposal <ref> \
1645 --attest \"\u{2026}\"` (genuine contemporaneous ID only)"
1646 }
1647 Persistability::ForbiddenBroker2027 => {
1648 "2027+ broker-held \u{2014} CANNOT be persisted (own-books insufficient); \
1649 FIFO is the defensible position"
1650 }
1651 };
1652 let _ = writeln!(
1653 s,
1654 " proposed: {} [{}]",
1655 picks_str(&d.proposed_selection),
1656 persist
1657 );
1658 }
1659 let _ = writeln!(
1661 s,
1662 " (vertex-granularity identification: a multi-partial split landing exactly on a \
1663 tax-bracket kink is out of scope.)"
1664 );
1665 let _ = writeln!(
1666 s,
1667 " (this is the tax IF you had identified thus; adequate ID must exist by the time \
1668 of sale \u{2014} \u{a7}1.1012-1(j))"
1669 );
1670 let _ = writeln!(
1672 s,
1673 " (scope: global over taxable-disposal lot selections; self-transfer lot routing is \
1674 held at its baseline position and is not re-optimized.)"
1675 );
1676 s
1677}
1678
1679pub fn render_accept_outcome(o: &crate::cmd::optimize::AcceptOutcome) -> String {
1684 let mut s = String::new();
1685 let _ = writeln!(
1686 s,
1687 "Optimize accept \u{2014} {} persisted, {} skipped.",
1688 o.persisted.len(),
1689 o.skipped.len()
1690 );
1691 for (disposal, decision, basis) in &o.persisted {
1692 let _ = writeln!(
1693 s,
1694 " PERSISTED {} \u{2192} LotSelection {} [{}]{}",
1695 disposal.canonical(),
1696 decision.canonical(),
1697 basis,
1698 if *basis == "AttestedRecording" {
1699 " (+ attestation recorded; revoke with `reconcile void`)"
1700 } else {
1701 " (revoke with `reconcile void`)"
1702 }
1703 );
1704 }
1705 for (disposal, reason) in &o.skipped {
1706 let _ = writeln!(s, " skipped {}: {}", disposal.canonical(), reason);
1707 }
1708 if o.persisted.is_empty() && o.skipped.is_empty() {
1709 let _ = writeln!(s, " (no disposals matched)");
1710 }
1711 s
1712}
1713
1714pub fn render_consult(r: &btctax_core::ConsultReport) -> String {
1724 let mut s = String::new();
1725 let _ = writeln!(
1726 s,
1727 "Consult (read-only what-if): sell {} sat from {} on {}",
1728 r.req.sell_sat,
1729 wallet_label(&r.req.wallet),
1730 r.req.at
1731 );
1732 if r.approximate {
1734 let _ = writeln!(
1735 s,
1736 " \u{26a0} heuristic \u{2014} searched a subset of a large (>12-lot) pool; \
1737 the proposed selection may not be the exact minimum."
1738 );
1739 }
1740 let _ = writeln!(
1741 s,
1742 " proposed selection: {}",
1743 picks_str(&r.proposed_selection)
1744 );
1745 let _ = writeln!(
1746 s,
1747 " short-term gain: {} long-term gain: {}",
1748 r.st_gain, r.lt_gain
1749 );
1750 let _ = writeln!(
1751 s,
1752 " federal tax attributable (estimated): {}",
1753 r.total_federal_tax_attributable
1754 );
1755 if let Some(t) = &r.timing {
1756 let _ = writeln!(
1757 s,
1758 " timing: {} sat of the best selection is short-term until {}; \
1759 selling on/after then would be taxed long-term, a \u{2248} {} difference.",
1760 t.st_sat_in_selection, t.latest_crossover, t.saving_if_waited
1761 );
1762 }
1763 let _ = writeln!(
1764 s,
1765 "Tax decision-support only \u{2014} consequences of a contemplated sale; \
1766 not investment advice (no buy/sell/hold recommendation)."
1767 );
1768 s
1769}
1770
1771pub fn render_verify(r: &VerifyReport) -> String {
1772 let mut out = String::new();
1773 let c = &r.conservation;
1774 let _ = writeln!(
1775 out,
1776 "Conservation (FR9): {}",
1777 if c.balanced { "BALANCED" } else { "DRIFT" }
1778 );
1779 let _ = writeln!(
1780 out,
1781 " in {} = disposed {} + removed {} + held {} + fee-sats {} + pending {}{}",
1782 c.sigma_in,
1783 c.sigma_disposed,
1784 c.sigma_removed,
1785 c.sigma_held,
1786 c.sigma_fee_sats,
1787 c.sigma_pending,
1788 if c.has_uncovered {
1789 " [identity undefined: uncovered disposal open]"
1790 } else {
1791 ""
1792 }
1793 );
1794 let _ = writeln!(out, "2025 transition: {}", r.safe_harbor);
1795 let _ = writeln!(
1796 out,
1797 "Pending reconciliation: {} transfer(s); unknown-basis inbounds: {}",
1798 r.pending, r.unknown_basis_inbounds
1799 );
1800
1801 let _ = writeln!(
1802 out,
1803 "Hard blockers (gate tax computation): {}",
1804 r.hard.len()
1805 );
1806 for b in &r.hard {
1807 let evt = b
1808 .event
1809 .as_ref()
1810 .map(|e| e.canonical())
1811 .unwrap_or_else(|| "-".to_string());
1812 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
1813 }
1814 let _ = writeln!(out, "Advisory blockers: {}", r.advisory.len());
1815 for b in &r.advisory {
1816 let evt = b
1817 .event
1818 .as_ref()
1819 .map(|e| e.canonical())
1820 .unwrap_or_else(|| "-".to_string());
1821 let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
1822 }
1823 let _ = writeln!(
1824 out,
1825 "Pre-2025 method (attested historical fact): {} (attested: {})",
1826 lot_method_display(r.declared_pre2025_method),
1827 r.pre2025_method_attested
1828 );
1829 let _ = writeln!(
1830 out,
1831 "Standing orders (MethodElection): {}",
1832 r.elections.len()
1833 );
1834 for e in &r.elections {
1835 let _ = writeln!(
1836 out,
1837 " recorded {} effective {} -> {} [{}]",
1838 e.recorded,
1839 e.effective_from,
1840 lot_method_display(e.method),
1841 e.note
1842 );
1843 }
1844 let _ = writeln!(out, "Lot selections recorded: {}", r.selection_count);
1845 let _ = writeln!(
1846 out,
1847 "Per-disposal compliance (post-2025): {}",
1848 r.compliance.len()
1849 );
1850 for c in &r.compliance {
1851 let _ = writeln!(
1852 out,
1853 " {} @ {} :: {}",
1854 c.disposal.canonical(),
1855 c.date,
1856 compliance_status_tag(&c.status)
1857 );
1858 }
1859 out
1860}
1861
1862#[cfg(test)]
1863mod gift_advisory_tests {
1864 use super::*;
1870 use btctax_core::conventions::Usd;
1871 use btctax_core::{EventId, LotId, Removal, RemovalLeg, TaxTable};
1872 use rust_decimal_macros::dec;
1873 use std::collections::BTreeMap;
1874 use time::macros::date;
1875
1876 fn gift_removal(seq: u64, removed_at: TaxDate, fmv: Usd) -> Removal {
1878 Removal {
1879 event: EventId::decision(seq),
1880 kind: RemovalKind::Gift,
1881 removed_at,
1882 legs: vec![RemovalLeg {
1883 lot_id: LotId {
1884 origin_event_id: EventId::decision(seq),
1885 split_sequence: 0,
1886 },
1887 sat: 100,
1888 basis: dec!(0),
1889 fmv_at_transfer: fmv,
1890 term: Term::LongTerm,
1891 basis_source: BasisSource::ComputedFromCost,
1892 acquired_at: date!(2024 - 01 - 01),
1893 }],
1894 appraisal_required: false,
1895 donor_acquired_at: None,
1896 claimed_deduction: None,
1897 donee: None,
1898 }
1899 }
1900 fn gift_removal_labeled(seq: u64, removed_at: TaxDate, fmv: Usd, label: &str) -> Removal {
1902 Removal {
1903 donee: Some(label.to_string()),
1904 ..gift_removal(seq, removed_at, fmv)
1905 }
1906 }
1907 fn state_with(removals: Vec<Removal>) -> LedgerState {
1908 LedgerState {
1909 removals,
1910 ..Default::default()
1911 }
1912 }
1913 fn tables_with(year: i32, excl: Usd) -> BTreeMap<i32, TaxTable> {
1917 tables_with_lifetime(year, excl, dec!(13_990_000))
1918 }
1919
1920 fn tables_with_lifetime(year: i32, excl: Usd, lifetime_excl: Usd) -> BTreeMap<i32, TaxTable> {
1922 let mut m = BTreeMap::new();
1923 m.insert(
1924 year,
1925 TaxTable {
1926 year,
1927 source: "TEST",
1928 ordinary: BTreeMap::new(),
1929 ltcg: BTreeMap::new(),
1930 gift_annual_exclusion: excl,
1931 ss_wage_base: dec!(176100),
1932 gift_lifetime_exclusion: lifetime_excl,
1933 },
1934 );
1935 m
1936 }
1937
1938 #[test]
1942 fn no_gifts_is_none() {
1943 let st = state_with(vec![]);
1944 let tables = tables_with(2025, dec!(19000));
1945 assert!(render_gift_advisory(&st, 2025, dec!(0), &tables).is_none());
1946 }
1947
1948 #[test]
1951 fn gifts_present_but_no_table_emits_note_not_none() {
1952 let st = state_with(vec![gift_removal(1, date!(2026 - 06 - 01), dec!(50000))]);
1954 let tables = tables_with(2025, dec!(19000));
1956 let msg =
1957 render_gift_advisory(&st, 2026, dec!(0), &tables).expect("note expected, not None");
1958 assert!(msg.contains("unavailable"), "{msg}");
1959 assert!(msg.contains("Form 709 exposure not evaluated"), "{msg}");
1960 assert!(
1961 msg.contains("50000.00"),
1962 "must record the gift total: {msg}"
1963 );
1964 }
1965
1966 #[test]
1972 fn labeled_donee_over_exclusion_emits_advisory() {
1973 let st = state_with(vec![gift_removal_labeled(
1974 1,
1975 date!(2025 - 06 - 01),
1976 dec!(20000),
1977 "Alice",
1978 )]);
1979 let tables = tables_with(2025, dec!(19000));
1980 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
1981 assert!(msg.contains("20000.00"), "must show Alice's total: {msg}");
1982 assert!(msg.contains("19000.00"), "must show the exclusion: {msg}");
1983 assert!(
1984 msg.contains("Form 709 filing required"),
1985 "must flag filing required: {msg}"
1986 );
1987 assert!(msg.contains("Alice"), "must name Alice: {msg}");
1988 assert!(msg.contains("1000.00"), "taxable must be $1000.00: {msg}");
1990 assert!(
1992 !msg.contains("donee identity is not modeled"),
1993 "stale aggregate caveat must not appear: {msg}"
1994 );
1995 }
1996
1997 #[test]
2000 fn labeled_donee_under_exclusion_no_filing_required() {
2001 let st = state_with(vec![gift_removal_labeled(
2002 1,
2003 date!(2025 - 06 - 01),
2004 dec!(10000),
2005 "Alice",
2006 )]);
2007 let tables = tables_with(2025, dec!(19000));
2008 let msg =
2010 render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected, not None");
2011 assert!(
2012 msg.contains("No Form 709 filing required"),
2013 "must say no filing required: {msg}"
2014 );
2015 assert!(msg.contains("Alice"), "must mention Alice: {msg}");
2016 assert!(msg.contains("10000.00"), "must show Alice's total: {msg}");
2017 }
2018
2019 #[test]
2025 fn per_donee_under_exclusion_two_donees_no_filing_required() {
2026 let st = state_with(vec![
2027 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(15000), "Alice"),
2028 gift_removal_labeled(2, date!(2025 - 06 - 01), dec!(15000), "Bob"),
2029 ]);
2030 let tables = tables_with(2025, dec!(19000));
2031 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2032 assert!(
2034 msg.contains("No Form 709 filing required"),
2035 "neither donee exceeds exclusion → no filing required: {msg}"
2036 );
2037 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2039 assert!(msg.contains("Bob"), "Bob must appear: {msg}");
2040 assert!(msg.contains("15000.00"), "donee total must appear: {msg}");
2042 assert!(
2044 !msg.contains("Form 709 filing required (donee(s):"),
2045 "filing trigger must NOT fire: {msg}"
2046 );
2047 assert!(
2049 msg.contains("Total taxable gifts: $0.00"),
2050 "total taxable must be $0.00: {msg}"
2051 );
2052 }
2053
2054 #[test]
2057 fn one_donee_over_exclusion_filing_required() {
2058 let st = state_with(vec![gift_removal_labeled(
2059 1,
2060 date!(2025 - 06 - 01),
2061 dec!(25000),
2062 "Alice",
2063 )]);
2064 let tables = tables_with(2025, dec!(19000));
2065 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2066 assert!(
2067 msg.contains("Form 709 filing required (donee(s): Alice)"),
2068 "must trigger filing required for Alice: {msg}"
2069 );
2070 assert!(msg.contains("25000.00"), "Alice total must appear: {msg}");
2071 assert!(
2072 msg.contains("19000.00"),
2073 "exclusion applied must appear: {msg}"
2074 );
2075 assert!(
2077 msg.contains("6000.00"),
2078 "taxable $6,000.00 must appear: {msg}"
2079 );
2080 }
2081
2082 #[test]
2085 fn unlabeled_bucket_caveat_with_conservative_aggregate() {
2086 let st = state_with(vec![gift_removal(1, date!(2025 - 06 - 01), dec!(30000))]);
2087 let tables = tables_with(2025, dec!(19000));
2088 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2089 assert!(
2091 msg.contains("no donee label"),
2092 "unlabeled caveat must appear: {msg}"
2093 );
2094 assert!(
2095 msg.contains("30000.00"),
2096 "unlabeled total must appear: {msg}"
2097 );
2098 assert!(
2100 msg.contains("Conservative aggregate"),
2101 "conservative aggregate signal must appear: {msg}"
2102 );
2103 assert!(
2104 msg.contains("19000.00"),
2105 "one-exclusion comparison must appear: {msg}"
2106 );
2107 assert!(
2109 !msg.contains("Form 709 filing required (donee(s):"),
2110 "labeled filing trigger must NOT fire for unlabeled gifts: {msg}"
2111 );
2112 }
2113
2114 #[test]
2117 fn mixed_labeled_over_and_unlabeled_shows_both() {
2118 let st = state_with(vec![
2119 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(25000), "Alice"),
2120 gift_removal(2, date!(2025 - 06 - 01), dec!(5000)), ]);
2122 let tables = tables_with(2025, dec!(19000));
2123 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2124 assert!(
2126 msg.contains("Form 709 filing required"),
2127 "filing required for Alice: {msg}"
2128 );
2129 assert!(msg.contains("Alice"), "Alice must appear: {msg}");
2130 assert!(
2132 msg.contains("no donee label"),
2133 "unlabeled caveat must appear: {msg}"
2134 );
2135 assert!(
2136 msg.contains("5000.00"),
2137 "unlabeled total must appear: {msg}"
2138 );
2139 }
2140
2141 #[test]
2144 fn donations_excluded_from_form709_advisory() {
2145 let donation_removal = Removal {
2146 event: EventId::decision(1),
2147 kind: RemovalKind::Donation,
2148 removed_at: date!(2025 - 06 - 01),
2149 legs: vec![RemovalLeg {
2150 lot_id: LotId {
2151 origin_event_id: EventId::decision(1),
2152 split_sequence: 0,
2153 },
2154 sat: 100,
2155 basis: dec!(0),
2156 fmv_at_transfer: dec!(50000), term: Term::LongTerm,
2158 basis_source: BasisSource::ComputedFromCost,
2159 acquired_at: date!(2024 - 01 - 01),
2160 }],
2161 appraisal_required: false,
2162 donor_acquired_at: None,
2163 claimed_deduction: Some(dec!(50000)),
2164 donee: Some("Charity X".to_string()),
2165 };
2166 let st = state_with(vec![donation_removal]);
2167 let tables = tables_with(2025, dec!(19000));
2168 assert!(
2170 render_gift_advisory(&st, 2025, dec!(0), &tables).is_none(),
2171 "Donation must be excluded from the Form 709 advisory"
2172 );
2173 }
2174
2175 #[test]
2181 fn section_2505_under_lifetime_shows_used_and_remaining() {
2182 let st = state_with(vec![gift_removal_labeled(
2183 1,
2184 date!(2025 - 06 - 01),
2185 dec!(100000),
2186 "Alice",
2187 )]);
2188 let tables = tables_with(2025, dec!(19000)); let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2190 assert!(
2192 msg.contains("81000.00"),
2193 "taxable $81,000 must appear: {msg}"
2194 );
2195 assert!(
2197 msg.contains("§2505 lifetime (basic) exclusion"),
2198 "§2505 block must appear: {msg}"
2199 );
2200 assert!(
2201 msg.contains("13990000.00"),
2202 "lifetime exclusion $13,990,000 must appear: {msg}"
2203 );
2204 assert!(
2206 msg.contains("13909000.00"),
2207 "remaining $13,909,000 must appear: {msg}"
2208 );
2209 assert!(
2211 !msg.contains("EXCEEDED"),
2212 "must NOT say EXCEEDED when under limit: {msg}"
2213 );
2214 assert!(
2216 !msg.contains("later chunk (Chunk 3)"),
2217 "stale Chunk-3 caveat must be absent: {msg}"
2218 );
2219 }
2220
2221 #[test]
2224 fn section_2505_prior_gifts_accumulate() {
2225 let st = state_with(vec![gift_removal_labeled(
2226 1,
2227 date!(2025 - 06 - 01),
2228 dec!(100000),
2229 "Alice",
2230 )]);
2231 let tables = tables_with(2025, dec!(19000));
2232 let msg =
2233 render_gift_advisory(&st, 2025, dec!(13_900_000), &tables).expect("advisory expected");
2234 assert!(
2236 msg.contains("13981000.00"),
2237 "cumulative $13,981,000 must appear: {msg}"
2238 );
2239 assert!(
2241 msg.contains("9000.00"),
2242 "remaining $9,000 must appear: {msg}"
2243 );
2244 assert!(
2245 !msg.contains("EXCEEDED"),
2246 "must NOT say EXCEEDED when under limit: {msg}"
2247 );
2248 }
2249
2250 #[test]
2254 fn section_2505_exceeds_lifetime_shows_exceeded_and_excess() {
2255 let st = state_with(vec![gift_removal_labeled(
2256 1,
2257 date!(2025 - 06 - 01),
2258 dec!(100000),
2259 "Alice",
2260 )]);
2261 let tables = tables_with(2025, dec!(19000));
2262 let msg =
2263 render_gift_advisory(&st, 2025, dec!(13_950_000), &tables).expect("advisory expected");
2264 assert!(
2265 msg.contains("14031000.00"),
2266 "cumulative $14,031,000 must appear: {msg}"
2267 );
2268 assert!(
2269 msg.contains("EXCEEDED"),
2270 "must say EXCEEDED when over lifetime limit: {msg}"
2271 );
2272 assert!(
2274 msg.contains("41000.00"),
2275 "excess $41,000 must appear: {msg}"
2276 );
2277 }
2278
2279 #[test]
2283 fn section_2505_exact_boundary_remaining_zero_not_exceeded() {
2284 let st = state_with(vec![gift_removal_labeled(
2285 1,
2286 date!(2025 - 06 - 01),
2287 dec!(100000),
2288 "Alice",
2289 )]);
2290 let tables = tables_with(2025, dec!(19000));
2291 let msg =
2293 render_gift_advisory(&st, 2025, dec!(13_909_000), &tables).expect("advisory expected");
2294 assert!(
2295 msg.contains("13990000.00"),
2296 "cumulative $13,990,000 must appear: {msg}"
2297 );
2298 assert!(
2300 msg.contains("($0.00 remaining)"),
2301 "remaining $0.00 in exact phrasing '($0.00 remaining)' must appear: {msg}"
2302 );
2303 assert!(
2305 !msg.contains("EXCEEDED"),
2306 "must NOT say EXCEEDED at exactly the limit: {msg}"
2307 );
2308 }
2309
2310 #[test]
2314 fn section_2505_prior_only_block_shows_even_when_current_taxable_zero() {
2315 let st = state_with(vec![gift_removal_labeled(
2316 1,
2317 date!(2025 - 06 - 01),
2318 dec!(10000), "Alice",
2320 )]);
2321 let tables = tables_with(2025, dec!(19000));
2322 let msg =
2323 render_gift_advisory(&st, 2025, dec!(5_000_000), &tables).expect("advisory expected");
2324 assert!(
2326 msg.contains("5000000.00"),
2327 "cumulative $5,000,000 must appear: {msg}"
2328 );
2329 assert!(
2330 msg.contains("§2505 lifetime (basic) exclusion"),
2331 "§2505 block must appear for prior-only case: {msg}"
2332 );
2333 assert!(!msg.contains("EXCEEDED"), "must NOT say EXCEEDED: {msg}");
2334 }
2335
2336 #[test]
2339 fn section_2505_no_block_when_cumulative_zero() {
2340 let st = state_with(vec![gift_removal_labeled(
2341 1,
2342 date!(2025 - 06 - 01),
2343 dec!(10000), "Alice",
2345 )]);
2346 let tables = tables_with(2025, dec!(19000));
2347 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2348 assert!(
2349 !msg.contains("§2505 lifetime"),
2350 "§2505 block must NOT appear when cumulative = 0: {msg}"
2351 );
2352 }
2353
2354 #[test]
2356 fn section_2505_default_zero_prior_shows_caveats() {
2357 let st = state_with(vec![gift_removal_labeled(
2358 1,
2359 date!(2025 - 06 - 01),
2360 dec!(100000), "Alice",
2362 )]);
2363 let tables = tables_with(2025, dec!(19000));
2364 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2365 assert!(
2367 !msg.contains("later chunk (Chunk 3)"),
2368 "stale 'later chunk (Chunk 3)' must be absent: {msg}"
2369 );
2370 assert!(
2372 msg.contains("§2513 gift-splitting"),
2373 "§2513 caveat must be present: {msg}"
2374 );
2375 assert!(
2376 msg.contains("portability/DSUE"),
2377 "portability/DSUE caveat must be present: {msg}"
2378 );
2379 assert!(
2380 msg.contains("prior cumulative taxable gifts are user-supplied"),
2381 "prior-cumulative disclosure caveat must be present: {msg}"
2382 );
2383 }
2384
2385 #[test]
2388 fn section_2505_mixed_shows_omission_disclosure_for_unlabeled() {
2389 let st = state_with(vec![
2390 gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(100000), "Alice"),
2391 gift_removal(2, date!(2025 - 06 - 01), dec!(50000)), ]);
2393 let tables = tables_with(2025, dec!(19000));
2394 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2395 assert!(
2397 msg.contains("§2505 lifetime (basic) exclusion"),
2398 "§2505 block must appear: {msg}"
2399 );
2400 assert!(
2401 msg.contains("81000.00"),
2402 "used $81,000 (labeled only) must appear: {msg}"
2403 );
2404 assert!(
2406 msg.contains("§2505 consumption reflects LABELED-donee taxable gifts only"),
2407 "omission disclosure must appear: {msg}"
2408 );
2409 assert!(
2410 msg.contains("50000.00"),
2411 "unlabeled total $50,000 must appear in omission disclosure: {msg}"
2412 );
2413 assert!(
2414 msg.contains("consumption may be understated"),
2415 "under-stated warning must appear: {msg}"
2416 );
2417 }
2418
2419 #[test]
2421 fn section_2505_stale_chunk3_caveat_is_absent() {
2422 let st = state_with(vec![gift_removal_labeled(
2423 1,
2424 date!(2025 - 06 - 01),
2425 dec!(20000),
2426 "Alice",
2427 )]);
2428 let tables = tables_with(2025, dec!(19000));
2429 let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
2430 assert!(
2431 !msg.contains("later chunk (Chunk 3)"),
2432 "stale Chunk-3 caveat must be absent: {msg}"
2433 );
2434 assert!(
2435 !msg.contains("§2505 lifetime exemption is a later chunk"),
2436 "stale §2505 future-chunk phrase must be absent: {msg}"
2437 );
2438 }
2439}
2440
2441#[cfg(test)]
2442mod schedule_se_tests {
2443 use super::*;
2447 use rust_decimal_macros::dec;
2448
2449 fn golden1() -> SeTaxResult {
2451 SeTaxResult {
2452 net_se: dec!(100000),
2453 base: dec!(92350.00),
2454 ss: dec!(11451.40),
2455 medicare: dec!(2678.15),
2456 addl: dec!(0.00),
2457 total: dec!(14129.55),
2458 deductible_half: dec!(7064.78),
2459 }
2460 }
2461
2462 fn w2_headline() -> SeTaxResult {
2464 SeTaxResult {
2465 net_se: dec!(100000),
2466 base: dec!(92350.00),
2467 ss: dec!(3236.40),
2468 medicare: dec!(2678.15),
2469 addl: dec!(381.15),
2470 total: dec!(6295.70),
2471 deductible_half: dec!(2957.28),
2472 }
2473 }
2474
2475 fn w2_asymmetric() -> SeTaxResult {
2477 SeTaxResult {
2478 net_se: dec!(100000),
2479 base: dec!(92350.00),
2480 ss: dec!(3236.40),
2481 medicare: dec!(2678.15),
2482 addl: dec!(0.00),
2483 total: dec!(5914.55),
2484 deductible_half: dec!(2957.28),
2485 }
2486 }
2487
2488 fn expenses_headline() -> SeTaxResult {
2493 SeTaxResult {
2494 net_se: dec!(80000),
2495 base: dec!(73880.00),
2496 ss: dec!(9161.12),
2497 medicare: dec!(2142.52),
2498 addl: dec!(0.00),
2499 total: dec!(11303.64),
2500 deductible_half: dec!(5651.82),
2501 }
2502 }
2503
2504 fn expenses_w2_combined() -> SeTaxResult {
2514 SeTaxResult {
2515 net_se: dec!(80000),
2516 base: dec!(73880.00),
2517 ss: dec!(3236.40),
2518 medicare: dec!(2142.52),
2519 addl: dec!(214.92),
2520 total: dec!(5593.84),
2521 deductible_half: dec!(2689.46),
2522 }
2523 }
2524
2525 #[test]
2529 fn business_mining_year_renders_full_section() {
2530 let r = golden1();
2531 let s = render_schedule_se(
2532 2025,
2533 Some(&r),
2534 dec!(100000),
2535 true,
2536 Usd::ZERO,
2537 Usd::ZERO,
2538 Usd::ZERO,
2539 )
2540 .expect("SE section expected");
2541 assert!(s.contains("92350.00"), "net SE earnings base: {s}");
2543 assert!(s.contains("11451.40"), "SS component: {s}");
2544 assert!(s.contains("2678.15"), "Medicare component: {s}");
2545 assert!(s.contains("14129.55"), "total SE tax: {s}");
2546 assert!(s.contains("7064.78"), "§164(f) deductible half: {s}");
2547 assert!(
2548 s.contains("Additional Medicare"),
2549 "addl component labeled: {s}"
2550 );
2551 assert!(
2553 s.contains("$0 W-2 wages"),
2554 "short $0-W-2 note must appear (both W-2 = 0): {s}"
2555 );
2556 assert!(
2557 s.contains("--w2-ss-wages"),
2558 "$0 note must mention --w2-ss-wages flag: {s}"
2559 );
2560 assert!(
2561 !s.contains("OVERSTATED"),
2562 "old OVERSTATED text must be absent (Chunk A regression): {s}"
2563 );
2564 assert!(
2565 !s.contains("UNDERSTATED"),
2566 "old UNDERSTATED text must be absent (Chunk A regression): {s}"
2567 );
2568 assert!(
2570 s.contains("NOT auto-coordinated"),
2571 "§164(f) advisory must appear: {s}"
2572 );
2573 assert!(
2574 s.contains("coordinate it on your actual return"),
2575 "§164(f) advisory must include coordination instruction: {s}"
2576 );
2577 assert!(
2579 s.contains("SEPARATE federal liability"),
2580 "standalone note: {s}"
2581 );
2582 assert!(
2583 s.contains("not") && s.contains("§164(f)"),
2584 "notes §164(f) not auto-coordinated: {s}"
2585 );
2586 assert!(
2588 s.contains("no Schedule C expenses supplied"),
2589 "Chunk B $0-expenses note must appear: {s}"
2590 );
2591 assert!(
2592 s.contains("--schedule-c-expenses"),
2593 "$0 note must mention --schedule-c-expenses flag: {s}"
2594 );
2595 assert!(
2596 !s.contains("not modeled"),
2597 "old 'not modeled' caveat must be absent (replaced by Chunk B): {s}"
2598 );
2599 }
2600
2601 #[test]
2603 fn w2_set_renders_coordinated_disclosure() {
2604 let r = w2_headline();
2605 let s = render_schedule_se(
2606 2025,
2607 Some(&r),
2608 dec!(100000),
2609 true,
2610 Usd::ZERO,
2611 dec!(150000),
2612 dec!(150000),
2613 )
2614 .expect("SE section expected");
2615 assert!(
2617 s.contains("W-2 coordination applied"),
2618 "coordinated disclosure must appear: {s}"
2619 );
2620 assert!(
2621 s.contains("§1401(b)(2)(B)"),
2622 "must cite §1401(b)(2)(B): {s}"
2623 );
2624 assert!(
2625 s.contains("Form 8959 Part II"),
2626 "must cite Form 8959 Part II: {s}"
2627 );
2628 assert!(s.contains("150000"), "w2_ss_wages amount must appear: {s}");
2630 assert!(!s.contains("OVERSTATED"), "OVERSTATED must be absent: {s}");
2632 assert!(
2633 !s.contains("UNDERSTATED"),
2634 "UNDERSTATED must be absent: {s}"
2635 );
2636 assert!(s.contains("3236.40"), "reduced SS component: {s}");
2638 assert!(s.contains("381.15"), "non-zero addl: {s}");
2639 assert!(s.contains("6295.70"), "reduced total: {s}");
2640 assert!(s.contains("2957.28"), "deductible_half: {s}");
2641 }
2642
2643 #[test]
2647 fn w2_asymmetric_render_transposition_guard() {
2648 let r = w2_asymmetric();
2649 let s = render_schedule_se(
2650 2025,
2651 Some(&r),
2652 dec!(100000),
2653 true,
2654 Usd::ZERO,
2655 dec!(150000),
2656 Usd::ZERO,
2657 )
2658 .expect("SE section expected");
2659 assert!(
2661 s.contains("W-2 coordination applied"),
2662 "coordinated disclosure must appear: {s}"
2663 );
2664 assert!(s.contains("3236.40"), "ss must be 3236.40 (reduced): {s}");
2666 assert!(
2667 s.contains("0.00"),
2668 "addl must be 0.00 (threshold un-reduced): {s}"
2669 );
2670 assert!(!s.contains("OVERSTATED"), "{s}");
2672 assert!(!s.contains("UNDERSTATED"), "{s}");
2673 }
2674
2675 #[test]
2677 fn no_business_income_no_section() {
2678 assert!(
2679 render_schedule_se(2025, None, Usd::ZERO, true, Usd::ZERO, Usd::ZERO, Usd::ZERO)
2680 .is_none()
2681 );
2682 }
2683
2684 #[test]
2687 fn business_income_but_no_table_emits_note() {
2688 let s = render_schedule_se(
2689 2099,
2690 None,
2691 dec!(100000),
2692 false,
2693 Usd::ZERO,
2694 Usd::ZERO,
2695 Usd::ZERO,
2696 )
2697 .expect("wage-base-unavailable note expected");
2698 assert!(s.contains("SS wage base unavailable"), "{s}");
2699 assert!(s.contains("2099"), "names the year: {s}");
2700 assert!(s.contains("no silent drop"), "{s}");
2701 }
2702
2703 #[test]
2708 fn expenses_20k_no_w2_renders_breakout_and_advisory() {
2709 let r = expenses_headline(); let s = render_schedule_se(
2711 2025,
2712 Some(&r),
2713 dec!(100000), true,
2715 dec!(20000), Usd::ZERO,
2717 Usd::ZERO,
2718 )
2719 .expect("SE section expected");
2720 assert!(
2722 s.contains("gross business income"),
2723 "breakout line must appear: {s}"
2724 );
2725 assert!(
2726 s.contains("100000.00"),
2727 "gross ($100k) must appear in breakout: {s}"
2728 );
2729 assert!(
2730 s.contains("20000.00"),
2731 "expenses ($20k) must appear in breakout: {s}"
2732 );
2733 assert!(
2734 s.contains("80000.00"),
2735 "net_se ($80k) must appear in breakout: {s}"
2736 );
2737 assert!(
2739 s.contains("OVERSTATES"),
2740 "Schedule C advisory OVERSTATES text: {s}"
2741 );
2742 assert!(
2743 s.contains("ORDINARY taxable income"),
2744 "advisory must mention ORDINARY taxable income: {s}"
2745 );
2746 assert!(
2747 s.contains("engine-side coordination is deferred"),
2748 "advisory must mention deferred coordination: {s}"
2749 );
2750 assert!(
2752 !s.contains("reduce your ordinary_taxable_income"),
2753 "NO OTI-edit prescription allowed (spec D3): {s}"
2754 );
2755 assert!(
2756 !s.contains("set --ordinary-taxable-income"),
2757 "NO OTI-edit prescription allowed (spec D3): {s}"
2758 );
2759 assert!(s.contains("73880.00"), "base $73,880: {s}");
2761 assert!(s.contains("9161.12"), "ss $9,161.12: {s}");
2762 assert!(s.contains("2142.52"), "medicare $2,142.52: {s}");
2763 assert!(s.contains("11303.64"), "total $11,303.64: {s}");
2764 assert!(s.contains("5651.82"), "deductible_half $5,651.82: {s}");
2765 assert!(
2767 !s.contains("not modeled"),
2768 "old 'not modeled' caveat must be absent: {s}"
2769 );
2770 }
2771
2772 #[test]
2775 fn fully_expensed_shows_new_line_not_wage_base_note() {
2776 let s = render_schedule_se(
2779 2025,
2780 None,
2781 dec!(10000), true, dec!(15000), Usd::ZERO,
2785 Usd::ZERO,
2786 )
2787 .expect("fully-expensed section expected (not None)");
2788 assert!(
2790 s.contains("fully expensed"),
2791 "fully-expensed line must appear: {s}"
2792 );
2793 assert!(
2794 s.contains("10000.00"),
2795 "gross $10k must appear in fully-expensed line: {s}"
2796 );
2797 assert!(
2798 s.contains("15000.00"),
2799 "expenses $15k must appear in fully-expensed line: {s}"
2800 );
2801 assert!(
2802 s.contains("no §1401 SE tax"),
2803 "must state no SE tax owed: {s}"
2804 );
2805 assert!(s.contains("2025"), "must name the year: {s}");
2806 assert!(
2808 !s.contains("SS wage base unavailable"),
2809 "wage-base-unavailable note must be ABSENT for fully-expensed case: {s}"
2810 );
2811 }
2812
2813 #[test]
2815 fn expenses_w2_combined_renders_both() {
2816 let r = expenses_w2_combined(); let s = render_schedule_se(
2818 2025,
2819 Some(&r),
2820 dec!(100000),
2821 true,
2822 dec!(20000), dec!(150000), dec!(150000), )
2826 .expect("SE section expected");
2827 assert!(s.contains("gross business income"), "breakout line: {s}");
2829 assert!(s.contains("80000.00"), "net_se in breakout: {s}");
2830 assert!(s.contains("OVERSTATES"), "Schedule C advisory: {s}");
2832 assert!(
2834 s.contains("W-2 coordination applied"),
2835 "W-2 coordination: {s}"
2836 );
2837 assert!(s.contains("73880.00"), "base: {s}");
2839 assert!(s.contains("3236.40"), "ss (reduced by W-2 cap): {s}");
2840 assert!(s.contains("2142.52"), "medicare: {s}");
2841 assert!(s.contains("214.92"), "addl: {s}");
2842 assert!(s.contains("5593.84"), "total: {s}");
2843 assert!(s.contains("2689.46"), "deductible_half: {s}");
2844 }
2845
2846 #[test]
2848 fn schedule_se_csv_columns_and_values() {
2849 let dir = tempfile::tempdir().unwrap();
2850 let out = dir.path().join("export");
2851 let st = LedgerState::default();
2852 let r = golden1();
2853 write_csv_exports(&out, &st, Some(2025), Some(&r), &BTreeMap::new()).unwrap();
2854
2855 let path = out.join("schedule_se.csv");
2856 assert!(path.exists(), "schedule_se.csv must be written");
2857 let mut rdr = csv::Reader::from_reader(std::fs::File::open(&path).unwrap());
2858 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
2859 assert_eq!(
2860 headers,
2861 vec![
2862 "net_se_earnings",
2863 "se_base_9235",
2864 "ss_component",
2865 "medicare_component",
2866 "additional_medicare_component",
2867 "total_se_tax",
2868 "deductible_half",
2869 ]
2870 );
2871 let rec = rdr
2872 .records()
2873 .next()
2874 .expect("one data row")
2875 .expect("readable");
2876 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"); }
2884
2885 #[test]
2887 fn schedule_se_csv_omitted_when_no_se_tax() {
2888 let dir = tempfile::tempdir().unwrap();
2889 let out = dir.path().join("export");
2890 let st = LedgerState::default();
2891 write_csv_exports(&out, &st, Some(2025), None, &BTreeMap::new()).unwrap();
2892 assert!(!out.join("schedule_se.csv").exists());
2893 }
2894}
2895
2896#[cfg(test)]
2897mod form8283_csv_tests {
2898 use super::*;
2901
2902 #[test]
2904 fn form8283_csv_detail_columns_present_and_empty() {
2905 use btctax_core::{
2906 BasisSource, DonationDetails, EventId, LedgerState, Removal, RemovalKind, RemovalLeg,
2907 Term,
2908 };
2909 use time::macros::date;
2910
2911 let dir = tempfile::tempdir().unwrap();
2912 let out = dir.path().join("export");
2913
2914 let event = EventId::decision(99);
2916 let leg = RemovalLeg {
2917 lot_id: btctax_core::LotId {
2918 origin_event_id: event.clone(),
2919 split_sequence: 0,
2920 },
2921 sat: 100_000_000,
2922 basis: rust_decimal::Decimal::ZERO,
2923 fmv_at_transfer: rust_decimal::Decimal::from(52000),
2924 term: Term::LongTerm,
2925 basis_source: BasisSource::ComputedFromCost,
2926 acquired_at: date!(2025 - 01 - 01),
2927 };
2928 let removal = Removal {
2929 event: event.clone(),
2930 kind: RemovalKind::Donation,
2931 removed_at: date!(2025 - 03 - 01),
2932 legs: vec![leg],
2933 appraisal_required: false,
2934 donor_acquired_at: None,
2935 claimed_deduction: Some(rust_decimal::Decimal::from(52000)),
2936 donee: Some("Test Charity Two".into()),
2937 };
2938 let event2 = EventId::decision(100);
2940 let leg2 = RemovalLeg {
2941 lot_id: btctax_core::LotId {
2942 origin_event_id: event2.clone(),
2943 split_sequence: 0,
2944 },
2945 sat: 10_000_000,
2946 basis: rust_decimal::Decimal::ZERO,
2947 fmv_at_transfer: rust_decimal::Decimal::from(8000),
2948 term: Term::LongTerm,
2949 basis_source: BasisSource::ComputedFromCost,
2950 acquired_at: date!(2025 - 01 - 15),
2951 };
2952 let removal2 = Removal {
2953 event: event2.clone(),
2954 kind: RemovalKind::Donation,
2955 removed_at: date!(2025 - 05 - 01),
2956 legs: vec![leg2],
2957 appraisal_required: false,
2958 donor_acquired_at: None,
2959 claimed_deduction: Some(rust_decimal::Decimal::from(8000)),
2960 donee: Some("No Details Org".into()),
2961 };
2962
2963 let st = LedgerState {
2964 removals: vec![removal, removal2],
2965 ..Default::default()
2966 };
2967
2968 let mut dmap: BTreeMap<EventId, DonationDetails> = BTreeMap::new();
2969 dmap.insert(
2970 event,
2971 DonationDetails {
2972 donee_name: "Test Charity".into(),
2973 donee_ein: Some("12-3456789".into()),
2974 donee_address: Some("123 Main".into()),
2975 appraiser_name: "Test Appraiser".into(),
2976 appraiser_tin: Some("987654321".into()),
2977 appraiser_ptin: Some("P01234567".into()),
2978 appraiser_qualifications: Some("Certified".into()),
2979 appraisal_date: Some(date!(2025 - 06 - 01)),
2980 appraiser_address: None,
2981 fmv_method_override: None,
2982 },
2983 );
2984 write_csv_exports(&out, &st, Some(2025), None, &dmap).unwrap();
2987
2988 let path = out.join("form8283.csv");
2989 assert!(path.exists(), "form8283.csv must exist");
2990
2991 let mut rdr = csv::ReaderBuilder::new()
2992 .comment(Some(b'#'))
2993 .from_path(&path)
2994 .unwrap();
2995 let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
2996 let idx = |name: &str| {
2997 headers
2998 .iter()
2999 .position(|h| h == name)
3000 .unwrap_or_else(|| panic!("header {name} not found"))
3001 };
3002 let all_recs: Vec<csv::StringRecord> = rdr.records().map(|r| r.unwrap()).collect();
3006 assert_eq!(
3007 all_recs.len(),
3008 2,
3009 "must have exactly two data rows (one per removal)"
3010 );
3011 let rec = &all_recs[0];
3012 let no_details_rec = &all_recs[1];
3013
3014 assert_eq!(&rec[idx("donee")], "Test Charity");
3016 assert_eq!(&rec[idx("appraiser")], "Test Appraiser");
3017 assert_eq!(&rec[idx("donee_ein")], "12-3456789");
3018 assert_eq!(&rec[idx("donee_address")], "123 Main");
3019 assert_eq!(&rec[idx("appraiser_tin")], "987654321");
3020 assert_eq!(&rec[idx("appraiser_ptin")], "P01234567");
3021 assert_eq!(&rec[idx("appraiser_qualifications")], "Certified");
3022 assert_eq!(&rec[idx("appraisal_date")], "2025-06-01");
3023 assert_eq!(&rec[idx("needs_review")], "false");
3024
3025 assert_eq!(
3027 &no_details_rec[idx("donee_ein")],
3028 "",
3029 "no-details row: donee_ein must be empty"
3030 );
3031 assert_eq!(
3032 &no_details_rec[idx("donee_address")],
3033 "",
3034 "no-details row: donee_address must be empty"
3035 );
3036 assert_eq!(
3037 &no_details_rec[idx("appraiser_tin")],
3038 "",
3039 "no-details row: appraiser_tin must be empty"
3040 );
3041 assert_eq!(
3042 &no_details_rec[idx("appraiser_ptin")],
3043 "",
3044 "no-details row: appraiser_ptin must be empty"
3045 );
3046 assert_eq!(
3047 &no_details_rec[idx("appraiser_qualifications")],
3048 "",
3049 "no-details row: appraiser_qualifications must be empty"
3050 );
3051 assert_eq!(
3052 &no_details_rec[idx("appraisal_date")],
3053 "",
3054 "no-details row: appraisal_date must be empty"
3055 );
3056 assert_eq!(
3057 &no_details_rec[idx("needs_review")],
3058 "true",
3059 "no-details carrier row: needs_review must be true"
3060 );
3061 }
3062}