use crate::config::CliConfig;
use btctax_adapters::FileReport;
use btctax_core::conventions::{tax_date, Usd, TRANSITION_DATE};
use btctax_core::persistence::ImportReport;
use btctax_core::DonationDetails;
use btctax_core::{
conservation_report, disposal_compliance, form_8283, form_8949, schedule_d,
year_donation_deduction, BasisSource, Blocker, BlockerKind, ComplianceStatus,
ConservationReport, DisposalCompliance, DisposalLeg, DisposeKind, EventId, EventPayload,
Form8283HowAcquired, Form8283Section, Form8949Box, Form8949Part, GiftZone, HarvestReport,
HarvestStatus, HarvestTarget, IncomeKind, LedgerEvent, LedgerState, LotMethod, LtcgBracket,
RemovalKind, RemovalLeg, ScheduleDTotals, SeTaxResult, SellReport, SellStatus, Severity,
TaxDate, Term, WalletId,
};
use btctax_store::fsperms;
use csv::Writer;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::Path;
fn fmt_money(d: btctax_core::conventions::Usd) -> String {
format!("{d:.2}")
}
fn basis_source_tag(bs: BasisSource) -> &'static str {
match bs {
BasisSource::ExchangeProvided => "exchange",
BasisSource::ComputedFromCost => "cost",
BasisSource::FmvAtIncome => "income_fmv",
BasisSource::CarriedFromTransfer => "transferred",
BasisSource::GiftCarryover => "gift_carryover",
BasisSource::GiftFmvFallback => "gift_fmv_fallback",
BasisSource::SafeHarborAllocated => "safe_harbor",
BasisSource::ReconstructedPerWallet => "reconstructed",
BasisSource::SelfTransferInbound => "self_transfer_in",
}
}
fn pseudo_tag(pseudo: bool) -> &'static str {
if pseudo {
" [PSEUDO]"
} else {
""
}
}
fn dispose_kind_tag(dk: DisposeKind) -> &'static str {
match dk {
DisposeKind::Sell => "sell",
DisposeKind::Spend => "spend",
}
}
fn income_kind_tag(ik: IncomeKind) -> &'static str {
match ik {
IncomeKind::Mining => "mining",
IncomeKind::Staking => "staking",
IncomeKind::Interest => "interest",
IncomeKind::Airdrop => "airdrop",
IncomeKind::Reward => "reward",
}
}
fn gift_zone_tag(gz: GiftZone) -> &'static str {
match gz {
GiftZone::Gain => "gain",
GiftZone::Loss => "loss",
GiftZone::NoGainNoLoss => "no_gain_no_loss",
}
}
fn removal_kind_tag(rk: RemovalKind) -> &'static str {
match rk {
RemovalKind::Gift => "gift",
RemovalKind::Donation => "donation",
}
}
fn term_tag(t: Term) -> &'static str {
match t {
Term::ShortTerm => "short",
Term::LongTerm => "long",
}
}
fn form8949_part_tag(p: Form8949Part) -> &'static str {
match p {
Form8949Part::ShortTerm => "ST",
Form8949Part::LongTerm => "LT",
}
}
fn form8949_box_tag(b: Form8949Box) -> &'static str {
match b {
Form8949Box::C => "C",
Form8949Box::F => "F",
}
}
fn form8283_section_tag(s: Form8283Section) -> &'static str {
match s {
Form8283Section::A => "A",
Form8283Section::B => "B",
}
}
fn form8283_how_acquired_tag(h: Form8283HowAcquired) -> &'static str {
match h {
Form8283HowAcquired::Purchased => "Purchased",
Form8283HowAcquired::Gift => "Gift",
Form8283HowAcquired::Other => "Other",
Form8283HowAcquired::Review => "Review",
}
}
fn compliance_status_tag(cs: &ComplianceStatus) -> String {
match cs {
ComplianceStatus::StandingOrder { effective_from } => {
format!("standing_order:{effective_from}")
}
ComplianceStatus::Contemporaneous => "contemporaneous".into(),
ComplianceStatus::AttestedRecording => "attested_recording".into(),
ComplianceStatus::NonCompliant => "non_compliant".into(),
}
}
pub fn render_file_reports(reports: &[FileReport], import: &ImportReport) -> String {
let mut out = String::new();
let _ = writeln!(out, "Import:");
for r in reports {
let _ = writeln!(
out,
" {} [{}]: parsed {} rows -> {} BTC events ({} dropped no-BTC, {} unclassified)",
r.source.tag(),
r.label,
r.parsed_rows,
r.btc_events,
r.dropped_no_btc,
r.unclassified
);
}
let _ = writeln!(
out,
" appended {} | duplicates {} | NEW import-conflicts {}",
import.appended, import.duplicates, import.conflicts
);
if import.conflicts > 0 {
let _ = writeln!(
out,
" ! resolve conflicts with `reconcile accept-conflict <id>` or `reject-conflict <id>` (see `verify`)"
);
}
out
}
pub fn wallet_label(w: &WalletId) -> String {
match w {
WalletId::Exchange { provider, account } => format!("exchange:{provider}:{account}"),
WalletId::SelfCustody { label } => format!("self:{label}"),
}
}
fn disposal_year(d: &btctax_core::Disposal) -> i32 {
d.disposed_at.year()
}
pub fn render_report(state: &LedgerState, year: Option<i32>) -> String {
let mut out = String::new();
let yr = |y: i32| year.is_none_or(|f| f == y);
let _ = writeln!(out, "Holdings (per wallet):");
if state.holdings_by_wallet.is_empty() {
let _ = writeln!(out, " none");
}
for (w, sat) in &state.holdings_by_wallet {
let _ = writeln!(out, " {}: {} sat", wallet_label(w), sat);
}
let _ = writeln!(out, "Lots:");
if state.lots.is_empty() {
let _ = writeln!(out, " none");
}
for l in &state.lots {
let _ = writeln!(
out,
" {}#{} {} remaining {} sat | basis {} ({}){}{}",
l.lot_id.origin_event_id.canonical(),
l.lot_id.split_sequence,
wallet_label(&l.wallet),
l.remaining_sat,
l.usd_basis,
basis_source_tag(l.basis_source),
if l.basis_pending {
" [basis pending]"
} else {
""
},
pseudo_tag(l.pseudo),
);
}
let label = match year {
Some(y) => format!("(year {y})"),
None => "(all years)".to_string(),
};
let disposals: Vec<_> = state
.disposals
.iter()
.filter(|d| yr(disposal_year(d)))
.collect();
if disposals.is_empty() {
let _ = writeln!(out, "Disposals {}: none", label);
} else {
let _ = writeln!(out, "Disposals {}:", label);
for d in disposals {
let _ = writeln!(
out,
" {} @ {} ({})",
dispose_kind_tag(d.kind),
d.disposed_at,
d.event.canonical()
);
for leg in &d.legs {
render_disposal_leg(&mut out, leg);
}
}
}
let removals: Vec<_> = state
.removals
.iter()
.filter(|r| yr(r.removed_at.year()))
.collect();
if removals.is_empty() {
let _ = writeln!(out, "Removals {}: none", label);
} else {
let _ = writeln!(out, "Removals {}:", label);
for r in removals {
let deduction_tag = match r.claimed_deduction {
Some(d) => format!(" [claimed deduction {}]", fmt_money(d)),
None => String::new(),
};
let _ = writeln!(
out,
" {} @ {} ({}){}",
removal_kind_tag(r.kind),
r.removed_at,
r.event.canonical(),
deduction_tag
);
for leg in &r.legs {
render_removal_leg(&mut out, leg);
}
}
}
let income: Vec<_> = state
.income_recognized
.iter()
.filter(|i| yr(i.recognized_at.year()))
.collect();
if income.is_empty() {
let _ = writeln!(out, "Income {}: none", label);
} else {
let _ = writeln!(out, "Income {}:", label);
for i in income {
let _ = writeln!(
out,
" {} @ {} {} sat = {} USD{}{}",
income_kind_tag(i.kind),
i.recognized_at,
i.sat,
i.usd_fmv,
if i.business { " [business]" } else { "" },
pseudo_tag(i.pseudo), );
}
}
let charitable_total: btctax_core::conventions::Usd = state
.removals
.iter()
.filter(|r| yr(r.removed_at.year()) && r.kind == RemovalKind::Donation)
.filter_map(|r| r.claimed_deduction)
.sum();
let _ = writeln!(
out,
"Charitable deduction {} (Schedule A itemized) — BEFORE §170(b) AGI limits / carryover: {}",
label,
fmt_money(charitable_total)
);
out
}
fn render_disposal_leg(out: &mut String, leg: &DisposalLeg) {
let zone = leg
.gift_zone
.map(|z| format!(" gift-zone {}", gift_zone_tag(z)))
.unwrap_or_default();
let _ = writeln!(
out,
" {} sat: proceeds {} basis {} gain {} {}{}{}",
leg.sat,
leg.proceeds,
leg.basis,
leg.gain,
term_tag(leg.term),
zone,
pseudo_tag(leg.pseudo),
);
}
fn render_removal_leg(out: &mut String, leg: &RemovalLeg) {
let _ = writeln!(
out,
" {} sat: basis {} fmv {} {} (zero gain){}",
leg.sat,
leg.basis,
leg.fmv_at_transfer,
term_tag(leg.term),
pseudo_tag(leg.pseudo),
);
}
pub fn filing_status_tag(fs: btctax_core::FilingStatus) -> &'static str {
use btctax_core::FilingStatus::*;
match fs {
Single => "single",
Mfj => "mfj",
Mfs => "mfs",
HoH => "hoh",
Qss => "qss",
}
}
fn lot_method_display(m: LotMethod) -> &'static str {
match m {
LotMethod::Fifo => "FIFO",
LotMethod::Lifo => "LIFO",
LotMethod::Hifo => "HIFO",
}
}
#[derive(Debug, Clone)]
pub struct ElectionLine {
pub recorded: TaxDate,
pub effective_from: TaxDate,
pub method: LotMethod,
pub note: &'static str,
}
#[derive(Debug, Clone)]
pub struct VerifyReport {
pub conservation: ConservationReport,
pub hard: Vec<Blocker>,
pub advisory: Vec<Blocker>,
pub pending: usize,
pub unknown_basis_inbounds: usize,
pub safe_harbor: String,
pub declared_pre2025_method: LotMethod,
pub pre2025_method_attested: bool,
pub elections: Vec<ElectionLine>,
pub selection_count: usize,
pub compliance: Vec<DisposalCompliance>,
}
impl VerifyReport {
pub fn has_hard_blockers(&self) -> bool {
!self.hard.is_empty()
}
}
fn safe_harbor_status(state: &LedgerState, _events: &[LedgerEvent]) -> String {
let effective_path_b = state
.lots
.iter()
.any(|l| l.basis_source == BasisSource::SafeHarborAllocated)
|| state.disposals.iter().any(|d| {
d.legs
.iter()
.any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
})
|| state.removals.iter().any(|r| {
r.legs
.iter()
.any(|leg| leg.basis_source == BasisSource::SafeHarborAllocated)
});
let unconservable = state
.blockers
.iter()
.any(|b| b.kind == BlockerKind::SafeHarborUnconservable);
let timebar = state
.blockers
.iter()
.any(|b| b.kind == BlockerKind::SafeHarborTimebar);
if unconservable {
"Path B allocation FAILS conservation/eligibility (hard, §7.4) — fix the allocation"
.to_string()
} else if effective_path_b {
"Path B safe-harbor allocation is effective (§7.4)".to_string()
} else if timebar {
"Path B time-barred -> using Path A (advisory); `reconcile safe-harbor attest` if timely in your books".to_string()
} else {
"Path A (actual per-wallet reconstruction; default, no election)".to_string()
}
}
pub fn build_verify(state: &LedgerState, events: &[LedgerEvent], cli: &CliConfig) -> VerifyReport {
let conservation = conservation_report(state);
let mut hard = Vec::new();
let mut advisory = Vec::new();
for b in &state.blockers {
match b.kind.severity() {
Severity::Hard => hard.push(b.clone()),
Severity::Advisory => advisory.push(b.clone()),
}
}
let unknown_basis_inbounds = state
.blockers
.iter()
.filter(|b| b.kind == BlockerKind::UnknownBasisInbound)
.count();
let voided: BTreeSet<EventId> = events
.iter()
.filter_map(|e| match &e.payload {
EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
_ => None,
})
.collect();
let mut election_events: Vec<(u64, &LedgerEvent)> = events
.iter()
.filter_map(|e| {
if let EventId::Decision { seq } = e.id {
if matches!(e.payload, EventPayload::MethodElection(_)) {
return Some((seq, e));
}
}
None
})
.collect();
election_events.sort_by_key(|(s, _)| *s);
let elections: Vec<ElectionLine> = election_events
.iter()
.map(|(_, e)| {
let EventPayload::MethodElection(me) = &e.payload else {
unreachable!("filtered to MethodElection above")
};
let recorded = tax_date(e.utc_timestamp, e.original_tz);
let note = if voided.contains(&e.id) {
"voided"
} else if me.effective_from < TRANSITION_DATE || me.effective_from < recorded {
"backdated/ignored"
} else {
"in force"
};
ElectionLine {
recorded,
effective_from: me.effective_from,
method: me.method,
note,
}
})
.collect();
let selection_count = events
.iter()
.filter(|e| matches!(e.payload, EventPayload::LotSelection(_)) && !voided.contains(&e.id))
.count();
let compliance = disposal_compliance(events, state);
VerifyReport {
conservation,
hard,
advisory,
pending: state.pending_reconciliation.len(),
unknown_basis_inbounds,
safe_harbor: safe_harbor_status(state, events),
declared_pre2025_method: cli.pre2025_method,
pre2025_method_attested: cli.pre2025_method_attested,
elections,
selection_count,
compliance,
}
}
pub fn write_csv_exports(
out_dir: &Path,
state: &LedgerState,
tax_year: Option<i32>,
se_result: Option<&SeTaxResult>,
donation_details: &BTreeMap<EventId, DonationDetails>,
) -> Result<(), crate::CliError> {
fsperms::mkdir_owner_only(out_dir)?;
let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("lots.csv"))?);
w.write_record([
"origin_event",
"split",
"wallet",
"acquired_at",
"remaining_sat",
"usd_basis",
"basis_source",
"basis_pending",
])?;
for l in &state.lots {
w.write_record([
l.lot_id.origin_event_id.canonical(),
l.lot_id.split_sequence.to_string(),
wallet_label(&l.wallet),
l.acquired_at.to_string(),
l.remaining_sat.to_string(),
l.usd_basis.to_string(),
basis_source_tag(l.basis_source).to_string(),
l.basis_pending.to_string(),
])?;
}
w.flush()?;
let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("disposals.csv"))?);
w.write_record([
"event",
"kind",
"disposed_at",
"lot",
"sat",
"proceeds",
"basis",
"gain",
"term",
"gift_zone",
"acquired_at",
"wallet",
])?;
for d in &state.disposals {
for leg in &d.legs {
w.write_record([
d.event.canonical(),
dispose_kind_tag(d.kind).to_string(),
d.disposed_at.to_string(),
format!(
"{}#{}",
leg.lot_id.origin_event_id.canonical(),
leg.lot_id.split_sequence
),
leg.sat.to_string(),
leg.proceeds.to_string(),
leg.basis.to_string(),
leg.gain.to_string(),
term_tag(leg.term).to_string(),
leg.gift_zone
.map(|z| gift_zone_tag(z).to_string())
.unwrap_or_default(),
leg.acquired_at.to_string(),
wallet_label(&leg.wallet),
])?;
}
}
w.flush()?;
let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("removals.csv"))?);
w.write_record([
"event",
"kind",
"removed_at",
"lot",
"sat",
"basis",
"fmv_at_transfer",
"term",
"acquired_at",
"claimed_deduction",
"donee",
])?;
for r in &state.removals {
let deduction_first = r
.claimed_deduction
.map(|d| d.to_string())
.unwrap_or_default();
let donee_cell = r.donee.clone().unwrap_or_default();
for (leg_idx, leg) in r.legs.iter().enumerate() {
let deduction_cell: &str = if leg_idx == 0 { &deduction_first } else { "" };
w.write_record([
r.event.canonical(),
removal_kind_tag(r.kind).to_string(),
r.removed_at.to_string(),
format!(
"{}#{}",
leg.lot_id.origin_event_id.canonical(),
leg.lot_id.split_sequence
),
leg.sat.to_string(),
leg.basis.to_string(),
leg.fmv_at_transfer.to_string(),
term_tag(leg.term).to_string(),
leg.acquired_at.to_string(),
deduction_cell.to_string(),
donee_cell.clone(),
])?;
}
}
w.flush()?;
let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("income.csv"))?);
w.write_record([
"event",
"kind",
"recognized_at",
"sat",
"usd_fmv",
"business",
])?;
for i in &state.income_recognized {
w.write_record([
i.event.canonical(),
income_kind_tag(i.kind).to_string(),
i.recognized_at.to_string(),
i.sat.to_string(),
i.usd_fmv.to_string(),
i.business.to_string(),
])?;
}
w.flush()?;
if let Some(year) = tax_year {
write_form8949_csv(out_dir, state, year)?;
write_schedule_d_csv(out_dir, state, year)?;
write_form8283_csv(out_dir, state, year, donation_details)?;
if let Some(se) = se_result {
write_schedule_se_csv(out_dir, se)?;
}
}
Ok(())
}
pub fn write_form_csvs(
out_dir: &Path,
state: &LedgerState,
year: i32,
se_result: Option<&SeTaxResult>,
donation_details: &BTreeMap<EventId, DonationDetails>,
) -> Result<(), crate::CliError> {
fsperms::mkdir_owner_only(out_dir)?;
write_form8949_csv(out_dir, state, year)?;
write_schedule_d_csv(out_dir, state, year)?;
write_form8283_csv(out_dir, state, year, donation_details)?;
if let Some(se) = se_result {
write_schedule_se_csv(out_dir, se)?;
}
Ok(())
}
fn write_schedule_se_csv(out_dir: &Path, se: &SeTaxResult) -> Result<(), crate::CliError> {
let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_se.csv"))?);
w.write_record([
"net_se_earnings",
"se_base_9235",
"ss_component",
"medicare_component",
"additional_medicare_component",
"total_se_tax",
"deductible_half",
])?;
w.write_record([
se.net_se.to_string(),
se.base.to_string(),
se.ss.to_string(),
se.medicare.to_string(),
se.addl.to_string(),
se.total.to_string(),
se.deductible_half.to_string(),
])?;
w.flush()?;
Ok(())
}
pub const FORM_8283_AGGREGATION_CAVEAT: &str =
"Section A/B reflects the \u{00a7}170(f)(11)(F) year-aggregate for similar property: all BTC \
donations in the year are summed (all BTC is 'similar property'); if the year-total claimed \
deduction exceeds $5,000 every row is Section B (qualified appraisal required), otherwise \
Section A. CCA 202302012: the readily-valued exception does not apply to crypto.";
pub fn render_donation_appraisal_advisory(state: &LedgerState, year: i32) -> Option<String> {
use btctax_core::QUALIFIED_APPRAISAL_THRESHOLD;
let agg = year_donation_deduction(state, year);
if agg <= QUALIFIED_APPRAISAL_THRESHOLD {
return None;
}
let threshold = fmt_money(QUALIFIED_APPRAISAL_THRESHOLD);
Some(format!(
"\u{00a7}170(f)(11)(F): your {year} BTC donations aggregate ${} of claimed deduction \
(> ${threshold}) \u{2014} a qualified appraisal is required for the donated BTC even if \
no single donation exceeds ${threshold} (all BTC is 'similar property'; CCA 202302012 \
\u{2014} no readily-valued exception for crypto).",
fmt_money(agg)
))
}
fn write_form8283_csv(
out_dir: &Path,
state: &LedgerState,
year: i32,
details: &BTreeMap<EventId, DonationDetails>,
) -> Result<(), crate::CliError> {
use std::io::Write as _;
let mut file = fsperms::open_owner_only(&out_dir.join("form8283.csv"))?;
writeln!(file, "# {FORM_8283_AGGREGATION_CAVEAT}")?;
let total_deduction = year_donation_deduction(state, year);
if total_deduction <= rust_decimal::Decimal::from(500) {
writeln!(
file,
"# [R0-M1] The year's total noncash charitable deduction ({}) is <= $500; Form 8283 is \
NOT required at that level (rows below are informational only).",
fmt_money(total_deduction)
)?;
}
let mut w = Writer::from_writer(file);
w.write_record([
"section",
"description",
"how_acquired",
"date_acquired",
"date_contributed",
"cost_basis",
"fmv",
"claimed_deduction",
"fmv_method",
"donee",
"appraiser",
"needs_review",
"donee_ein",
"donee_address",
"appraiser_tin",
"appraiser_ptin",
"appraiser_qualifications",
"appraisal_date",
])?;
for row in form_8283(state, year, details) {
let d = row.details.as_ref();
w.write_record([
row.section
.map(form8283_section_tag)
.unwrap_or("")
.to_string(),
row.description,
form8283_how_acquired_tag(row.how_acquired).to_string(),
row.date_acquired.to_string(),
row.date_contributed.to_string(),
row.cost_basis.to_string(),
row.fmv.to_string(),
row.claimed_deduction
.map(|d| d.to_string())
.unwrap_or_default(),
row.fmv_method,
row.donee,
row.appraiser,
row.needs_review.to_string(),
d.and_then(|d| d.donee_ein.clone()).unwrap_or_default(),
d.and_then(|d| d.donee_address.clone()).unwrap_or_default(),
d.and_then(|d| d.appraiser_tin.clone()).unwrap_or_default(),
d.and_then(|d| d.appraiser_ptin.clone()).unwrap_or_default(),
d.and_then(|d| d.appraiser_qualifications.clone())
.unwrap_or_default(),
d.and_then(|d| d.appraisal_date.map(|dt| dt.to_string()))
.unwrap_or_default(),
])?;
}
w.flush()?;
Ok(())
}
fn write_form8949_csv(
out_dir: &Path,
state: &LedgerState,
year: i32,
) -> Result<(), crate::CliError> {
let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("form8949.csv"))?);
w.write_record([
"part",
"box",
"box_needs_review",
"description",
"date_acquired",
"date_sold",
"proceeds",
"cost_basis",
"adjustment_code",
"adjustment_amount",
"gain",
"wallet",
"disposition_kind",
])?;
for r in form_8949(state, year) {
w.write_record([
form8949_part_tag(r.part).to_string(),
form8949_box_tag(r.box_).to_string(),
r.box_needs_review.to_string(),
r.description,
r.date_acquired.to_string(),
r.date_sold.to_string(),
r.proceeds.to_string(),
r.cost_basis.to_string(),
r.adjustment_code,
r.adjustment_amount.to_string(),
r.gain.to_string(),
wallet_label(&r.wallet),
dispose_kind_tag(r.disposition_kind).to_string(),
])?;
}
w.flush()?;
Ok(())
}
fn write_schedule_d_csv(
out_dir: &Path,
state: &LedgerState,
year: i32,
) -> Result<(), crate::CliError> {
let mut w = Writer::from_writer(fsperms::open_owner_only(&out_dir.join("schedule_d.csv"))?);
w.write_record(["part", "proceeds", "cost_basis", "gain"])?;
let totals = schedule_d(state, year);
for (part, p) in [("ST", &totals.st), ("LT", &totals.lt)] {
w.write_record([
part.to_string(),
p.proceeds.to_string(),
p.cost_basis.to_string(),
p.gain.to_string(),
])?;
}
w.flush()?;
Ok(())
}
pub fn render_tax_outcome(
year: i32,
out: &btctax_core::TaxOutcome,
advisory: Option<&str>,
) -> String {
use btctax_core::TaxOutcome::*;
let mut s = String::new();
let _ = writeln!(s, "Federal tax attributable to crypto — tax year {year}");
match out {
NotComputable(b) => {
let _ = writeln!(s, " NOT COMPUTABLE [{:?}]: {}", b.kind, b.detail);
}
Computed(r) => {
let _ = writeln!(
s,
" net short-term: {} net long-term: {}",
fmt_money(r.st_net),
fmt_money(r.lt_net)
);
let _ = writeln!(
s,
" crypto ordinary income (level): {}",
fmt_money(r.ordinary_from_crypto)
);
let ordinary_rate_attributable = r.total_federal_tax_attributable - r.ltcg_tax - r.niit;
let _ = writeln!(
s,
" ordinary-rate tax (attributable): {}",
fmt_money(ordinary_rate_attributable)
);
let _ = writeln!(
s,
" LTCG tax (attributable): {} NIIT (attributable): {}",
fmt_money(r.ltcg_tax),
fmt_money(r.niit)
);
let _ = writeln!(
s,
" TOTAL federal tax attributable to crypto (delta): {} \
(= ordinary-rate + LTCG + NIIT attributable)",
fmt_money(r.total_federal_tax_attributable)
);
let _ = writeln!(
s,
" §1211 loss deduction (level): {} carryforward out: short {} / long {}",
fmt_money(r.loss_deduction),
fmt_money(r.carryforward_out.short),
fmt_money(r.carryforward_out.long)
);
let _ = writeln!(
s,
" marginal rates: ordinary {} / LTCG {} / NIIT {}",
r.marginal_rates.ordinary, r.marginal_rates.ltcg, r.marginal_rates.niit_applies
);
let _ = writeln!(
s,
" (incremental ceteris-paribus delta on the minimal profile; \
excludes AGI-driven SS/IRMAA/AMT/QBI/phaseout effects — I5. §1411 NIIT reduces NII by the \
§1211(b)-allowed net capital loss (≤ $3,000 / $1,500 MFS — Form 8960 line 5a / §1.1411-4(d)) \
and is floored at $0; crypto ordinary income (mining/staking/airdrops/rewards) is correctly \
excluded from NII; crypto-lending interest income (§1411(c)(1)(A)(i)) is INCLUDED in NII; \
mining/staking/airdrops/rewards remain excluded (SE income per §1411(c)(6) or non-NII other income).)"
);
}
}
if let Some(msg) = advisory {
let _ = writeln!(s, " ADVISORY (M4): {msg}");
}
s
}
pub(crate) const ADVISORY_WRAP_COLS: usize = 92;
pub(crate) fn wrap_bulleted(text: &str) -> String {
const BULLET: &str = " \u{2022} ";
const HANG: &str = " ";
let mut out = String::new();
let mut line = String::from(BULLET);
let mut have_word = false;
for word in text.split_whitespace() {
let prospective = line.chars().count() + usize::from(have_word) + word.chars().count();
if have_word && prospective > ADVISORY_WRAP_COLS {
out.push_str(line.trim_end());
out.push('\n');
line = String::from(HANG);
have_word = false;
}
if have_word {
line.push(' ');
}
line.push_str(word);
have_word = true;
}
out.push_str(line.trim_end());
out
}
pub fn render_advisories(advisories: &[btctax_core::tax::advisories::Advisory]) -> String {
let mut s = String::new();
if advisories.is_empty() {
return s;
}
let _ = writeln!(s, "\n ── ADVISORIES ({}) ──", advisories.len());
for a in advisories {
let _ = writeln!(s, "{}", wrap_bulleted(&a.message()));
}
let _ = writeln!(
s,
" (Advisories never change a number and never fail the command. See `btctax limitations`.)"
);
s
}
pub fn provenance_label(p: crate::resolve::Provenance) -> &'static str {
use crate::resolve::Provenance::*;
match p {
ReturnInputs => "ReturnInputs (derived from line items)",
StoredProfile => "stored TaxProfile (raw override)",
PseudoPlaceholder => "pseudo-reconcile placeholder ($0)",
Missing => "none (TaxProfileMissing)",
}
}
pub fn render_dual_report(
year: i32,
ar: &btctax_core::AbsoluteReturn,
printed: &btctax_core::tax::packet::PrintedForms,
delta: &btctax_core::TaxOutcome,
provenance: crate::resolve::Provenance,
) -> String {
let f = &printed.f1040;
let mut s = String::new();
let _ = writeln!(
s,
"\n═══ Absolute filed return (Form 1040) — tax year {year} ═══"
);
let _ = writeln!(s, " Profile source: {}", provenance_label(provenance));
let _ = writeln!(s, " Total income (1040 L9): {}", fmt_money(f.line9));
let _ = writeln!(s, " Adjustments (L10): {}", fmt_money(f.line10));
let _ = writeln!(s, " AGI (L11): {}", fmt_money(f.line11));
let ded_kind = if ar.deduction_is_itemized {
"itemized"
} else {
"standard"
};
let _ = writeln!(s, " Deduction (L12, {ded_kind}): {}", fmt_money(f.line12));
if ar.qbi_deduction > Usd::ZERO {
let _ = writeln!(s, " QBI deduction (L13): {}", fmt_money(f.line13));
}
let _ = writeln!(s, " Taxable income (L15): {}", fmt_money(f.line15));
let _ = writeln!(s, " Tax (L16): {}", fmt_money(f.line16));
if ar.foreign_tax_credit > Usd::ZERO {
let _ = writeln!(
s,
" Foreign tax credit (Sch 3 L1): {}",
fmt_money(printed.sch_3.map_or(Usd::ZERO, |s3| s3.line1))
);
}
if ar.se_tax_sch2_l4 > Usd::ZERO {
let _ = writeln!(
s,
" Self-employment tax (Sch 2 L4): {}",
fmt_money(printed.sch_2.map_or(Usd::ZERO, |s2| s2.line4))
);
}
if ar.additional_medicare.additional_medicare_tax > Usd::ZERO {
let _ = writeln!(
s,
" Additional Medicare (Form 8959 → Sch 2 L11): {}",
fmt_money(printed.f8959.line18)
);
}
if ar.niit.tax > Usd::ZERO {
let _ = writeln!(
s,
" Net Investment Income Tax (Form 8960 → Sch 2 L12): {}",
fmt_money(printed.f8960.map_or(Usd::ZERO, |f| f.line17))
);
}
let _ = writeln!(s, " TOTAL TAX (L24): {}", fmt_money(f.line24));
let _ = writeln!(s, " Total payments (L33): {}", fmt_money(f.line33));
if f.line34 > Usd::ZERO {
let _ = writeln!(s, " → REFUND (L35a): {}", fmt_money(f.line34));
} else {
let _ = writeln!(s, " → AMOUNT OWED (L37): {}", fmt_money(f.line37));
}
let delta_str = match delta {
btctax_core::TaxOutcome::Computed(r) => fmt_money(r.total_federal_tax_attributable),
btctax_core::TaxOutcome::NotComputable(_) => "not computable".to_string(),
};
let _ = writeln!(
s,
"\n ── Two DIFFERENT questions — NOT reconciled (SPEC §6) ──"
);
let _ = writeln!(
s,
" • Absolute TOTAL TAX (this filed return, WITH crypto): {}",
fmt_money(f.line24)
);
let _ = writeln!(
s,
" • Crypto-attributable tax (DELTA, shown above): {delta_str}"
);
let _ = writeln!(
s,
" The delta's implied deduction is fixed at derivation time (non-crypto AGI), so it is \
APPROXIMATE where a\n deduction is AGI-sensitive (e.g. the 7.5% medical floor); the two do NOT \
reconcile to the dollar."
);
s
}
pub fn render_schedule_d(
year: i32,
totals: &ScheduleDTotals,
outcome: &btctax_core::TaxOutcome,
) -> String {
let mut s = String::new();
let _ = writeln!(
s,
"Schedule D (raw pre-netting part totals) — tax year {year}"
);
let _ = writeln!(
s,
" Part I (short-term): proceeds {} cost basis {} gain {}",
fmt_money(totals.st.proceeds),
fmt_money(totals.st.cost_basis),
fmt_money(totals.st.gain)
);
let _ = writeln!(
s,
" Part II (long-term): proceeds {} cost basis {} gain {}",
fmt_money(totals.lt.proceeds),
fmt_money(totals.lt.cost_basis),
fmt_money(totals.lt.gain)
);
match outcome {
btctax_core::TaxOutcome::NotComputable(_) => {
let _ = writeln!(
s,
" (raw disposition totals shown above; the year's tax is NOT COMPUTABLE until \
the blocker is resolved — these Form 8949/Schedule D part totals are \
informational and are not netted/carried until the tax computes)."
);
}
btctax_core::TaxOutcome::Computed(_) => {
let _ = writeln!(
s,
" Note: §1222/§1211/§1212 netting + carryforward are applied in the tax \
computation (report --tax-year); these are the raw pre-netting Form \
8949/Schedule D part totals."
);
}
}
s
}
pub fn render_schedule_se(
year: i32,
result: Option<&SeTaxResult>,
gross_se: Usd,
table_present: bool,
schedule_c_expenses: Usd,
w2_ss_wages: Usd,
w2_medicare_wages: Usd,
) -> Option<String> {
match result {
Some(r) => {
let mut s = String::new();
let _ = writeln!(
s,
"Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
);
if schedule_c_expenses > Usd::ZERO {
let gross_display = r.net_se + schedule_c_expenses;
let _ = writeln!(
s,
" gross business income {} \u{2212} Schedule C expenses {} = net SE earnings {}",
fmt_money(gross_display),
fmt_money(schedule_c_expenses),
fmt_money(r.net_se)
);
let _ = writeln!(
s,
" (Schedule C advisory) Schedule C expenses also reduce your ORDINARY taxable \
income, but the income-tax total above uses GROSS crypto income \u{2014} to first \
order it OVERSTATES your tax by your marginal ordinary rate applied to {}. The tax \
profile cannot express this (an `ordinary_taxable_income` edit would shift both \
legs of the crypto-attributable delta); the engine-side coordination is deferred \
\u{2014} coordinate it on your actual return.",
fmt_money(schedule_c_expenses)
);
} else {
let _ = writeln!(
s,
" net self-employment income (business crypto, Interest excluded): {}",
fmt_money(r.net_se)
);
let _ = writeln!(
s,
" (Schedule C) no Schedule C expenses supplied (--schedule-c-expenses)"
);
}
let _ = writeln!(
s,
" \u{00d7} 92.35% net-earnings factor (\u{00a7}1402(a)) = net SE earnings: {}",
fmt_money(r.base)
);
let _ = writeln!(
s,
" Social Security component (12.4%, §1401(a); capped at the SS wage base): {}",
fmt_money(r.ss)
);
let _ = writeln!(
s,
" Medicare component (2.9%, §1401(b); uncapped): {}",
fmt_money(r.medicare)
);
let _ = writeln!(
s,
" Additional Medicare component (0.9%, §1401(b)(2)): {}",
fmt_money(r.addl)
);
let _ = writeln!(
s,
" TOTAL self-employment tax (§1401): {}",
fmt_money(r.total)
);
let _ = writeln!(
s,
" §164(f) one-half-SE-tax deduction (above-the-line; EXCLUDES Additional Medicare per \
§164(f)(1)): {}",
fmt_money(r.deductible_half)
);
let _ = writeln!(
s,
" (§164(f) advisory) The §164(f) deduction ({}) is NOT auto-coordinated into the \
income-tax total above — to first order, that total overstates your combined tax by \
your marginal ordinary rate applied to {}. The tax profile cannot express this deduction \
directly (reducing `ordinary_taxable_income` would shift BOTH legs of the \
crypto-attributable delta and only correct the bracket differential, not the level) — \
coordinate it on your actual return.",
fmt_money(r.deductible_half),
fmt_money(r.deductible_half)
);
if w2_ss_wages > Usd::ZERO || w2_medicare_wages > Usd::ZERO {
let _ = writeln!(
s,
" (W-2 coordination applied) SS cap = max(0, wage base \u{2212} {}) (Box 3+7); \
Additional-Medicare threshold reduced (not below 0) by {} (Box 5, \
§1401(b)(2)(B)/Form 8959 Part II).",
fmt_money(w2_ss_wages),
fmt_money(w2_medicare_wages)
);
} else {
let _ = writeln!(
s,
" (W-2) assumes $0 W-2 wages (set --w2-ss-wages/--w2-medicare-wages on the tax \
profile if you had a wage job)."
);
}
if r.base < rust_decimal::Decimal::from(400) {
let _ = writeln!(
s,
" (§6017 filing floor) Net earnings from self-employment ({base}) are below $400: \
a Schedule SE filing is required on account of this income only when net earnings \
from self-employment (the ×92.35% base, §1402(a)) are $400 or more (§6017), and \
below that floor no §1401 SE tax is imposed (§1402(b)(2); church employee income \
excepted — §1402(j)(2), not modeled) — the figures above are shown for \
transparency (other self-employment activities, if any, combine on your actual \
Schedule SE).",
base = fmt_money(r.base)
);
}
let _ = writeln!(
s,
" (standalone) This §1401 SE tax is a SEPARATE federal liability, NOT included in the \
income-tax + NIIT total above; the §164(f) one-half-SE-tax deduction is not \
auto-coordinated into that total."
);
Some(s)
}
None => {
if gross_se.is_zero() {
None } else if !table_present {
let mut s = String::new();
let _ = writeln!(
s,
"Schedule SE (§1401 self-employment tax) — tax year {year}"
);
let _ = writeln!(
s,
" SS wage base unavailable for {year}: business self-employment income is present \
but no bundled tax table (ss_wage_base) exists for {year}; the §1401 SE tax was \
NOT computed (no silent drop)."
);
Some(s)
} else {
let mut s = String::new();
let _ = writeln!(
s,
"Schedule SE (§1401 self-employment tax on business crypto income) — tax year {year}"
);
let _ = writeln!(
s,
" fully expensed: gross {} \u{2212} Schedule C expenses {} \u{2264} $0 \
\u{2192} no \u{00a7}1401 SE tax for {year}.",
fmt_money(gross_se),
fmt_money(schedule_c_expenses)
);
Some(s)
}
}
}
}
pub fn render_gift_advisory(
state: &LedgerState,
year: i32,
prior_taxable_gifts: btctax_core::conventions::Usd,
tables: &impl btctax_core::TaxTables,
) -> Option<String> {
use std::collections::BTreeMap;
let any_gift = state
.removals
.iter()
.any(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year);
if !any_gift {
return None; }
let t = match tables.table_for(year) {
None => {
let total: btctax_core::conventions::Usd = state
.removals
.iter()
.filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
.flat_map(|r| r.legs.iter())
.map(|leg| leg.fmv_at_transfer)
.sum();
return Some(format!(
"Form 709 gift advisory ({year}): gift annual-exclusion table unavailable for \
{year}; Form 709 exposure not evaluated — ${} in gifts recorded.",
fmt_money(total)
));
}
Some(t) => t,
};
let excl = t.gift_annual_exclusion;
let mut labeled: BTreeMap<String, btctax_core::conventions::Usd> = BTreeMap::new();
let mut unlabeled_count: usize = 0;
let mut unlabeled_total: btctax_core::conventions::Usd = Default::default();
for r in state
.removals
.iter()
.filter(|r| r.kind == RemovalKind::Gift && r.removed_at.year() == year)
{
let fmv: btctax_core::conventions::Usd = r.legs.iter().map(|l| l.fmv_at_transfer).sum();
match &r.donee {
Some(label) => {
*labeled.entry(label.clone()).or_default() += fmv;
}
None => {
unlabeled_count += 1;
unlabeled_total += fmv;
}
}
}
let mut filing_required_donees: Vec<String> = Vec::new();
let mut total_taxable: btctax_core::conventions::Usd = Default::default();
let mut s = format!("Form 709 gift advisory ({year}):");
if !labeled.is_empty() {
s.push_str(&format!(
"\n§2503(b) per-donee annual exclusion analysis (TY{year}, exclusion ${}):",
fmt_money(excl)
));
for (donee, &total) in &labeled {
let applied = if total < excl { total } else { excl };
let taxable: btctax_core::conventions::Usd = if total > excl {
total - excl
} else {
Default::default()
};
s.push_str(&format!(
"\n {donee}: total ${}, exclusion applied ${}, taxable ${}",
fmt_money(total),
fmt_money(applied),
fmt_money(taxable)
));
if total > excl {
filing_required_donees.push(donee.clone());
total_taxable += taxable;
}
}
if !filing_required_donees.is_empty() {
s.push_str(&format!(
"\nForm 709 filing required (donee(s): {}). Total taxable gifts: ${}.",
filing_required_donees.join(", "),
fmt_money(total_taxable)
));
} else {
s.push_str(&format!(
"\nNo Form 709 filing required based on per-donee totals \
(each ≤ ${} exclusion). Total taxable gifts: $0.00.",
fmt_money(excl)
));
}
}
if unlabeled_count > 0 {
s.push_str(&format!(
"\nNOTE: {unlabeled_count} gift(s) totalling ${} have no donee label — the §2503(b) \
annual exclusion is PER DONEE and cannot be applied without one; label them via \
`reconcile reclassify-outflow --donee`. Shown as a single conservative aggregate.",
fmt_money(unlabeled_total)
));
if unlabeled_total > excl {
s.push_str(&format!(
"\n Conservative aggregate ${} > ${} (one exclusion); \
verify per-donee totals after labelling.",
fmt_money(unlabeled_total),
fmt_money(excl)
));
} else {
s.push_str(&format!(
"\n Conservative aggregate ${} \u{2264} ${} (one exclusion); \
per-donee exposure unverifiable without labels.",
fmt_money(unlabeled_total),
fmt_money(excl)
));
}
}
let lifetime_excl = t.gift_lifetime_exclusion;
let cumulative_taxable = prior_taxable_gifts + total_taxable;
if cumulative_taxable > btctax_core::conventions::Usd::ZERO {
let remaining = if cumulative_taxable >= lifetime_excl {
btctax_core::conventions::Usd::ZERO
} else {
lifetime_excl - cumulative_taxable
};
s.push_str(&format!(
"\n§2505 lifetime (basic) exclusion: you have used ${} of your ${} ({year}) lifetime \
exclusion (${} remaining). No gift tax is DUE until cumulative taxable gifts exceed \
the lifetime exclusion.",
fmt_money(cumulative_taxable),
fmt_money(lifetime_excl),
fmt_money(remaining),
));
if cumulative_taxable > lifetime_excl {
let excess = cumulative_taxable - lifetime_excl;
s.push_str(&format!(
"\nlifetime exclusion EXCEEDED — gift tax may be due on ${} \
(the excess base past the unified credit, not a computed tax); \
consult a professional.",
fmt_money(excess),
));
}
if unlabeled_count > 0 {
s.push_str(&format!(
"\n§2505 consumption reflects LABELED-donee taxable gifts only; \
{unlabeled_count} unlabeled gift(s) totalling ${} are NOT included — \
label them via `--donee` for a complete figure; consumption may be \
understated / remaining overstated.",
fmt_money(unlabeled_total),
));
}
}
s.push_str(
"\nCaveats: §2513 gift-splitting (MFJ) not modeled (single-filer advisory only); \
future-interest gifts (which require Form 709 filing regardless of amount) not \
detectable; §2505 figures are advisory only — no portability/DSUE (§2010(c)(4)) \
applied; prior cumulative taxable gifts are user-supplied (default $0 if \
--prior-taxable-gifts not given).",
);
Some(s)
}
fn picks_str(picks: &[btctax_core::LotPick]) -> String {
if picks.is_empty() {
return "(none)".to_string();
}
picks
.iter()
.map(|p| {
format!(
"{}#{}:{}",
p.lot.origin_event_id.canonical(),
p.lot.split_sequence,
p.sat
)
})
.collect::<Vec<_>>()
.join(", ")
}
pub fn render_optimize_proposal(p: &btctax_core::OptimizeProposal) -> String {
use btctax_core::{ApproxReason, Persistability};
let mut s = String::new();
let _ = writeln!(
s,
"Optimize (what-if) — tax year {} — NOTHING is filed or bound by running this.",
p.year
);
if p.approximate {
let why = match p.approx_reason {
Some(ApproxReason::ComboCapExceeded { combos, cap }) => format!(
"input exceeded the exhaustive bound ({combos} combos > {cap}); \
a coordinate-descent fallback ran"
),
Some(ApproxReason::ContentionUnenumerated { contended, .. }) => format!(
"{contended} contended same-wallet disposal(s) could not be fully joint-enumerated"
),
Some(ApproxReason::PoolHeuristic { lots, bound }) => format!(
"a pool of {lots} lots exceeds the {bound}-lot exhaustive-enumeration bound; \
only a deterministic heuristic SUBSET of that pool's identifications was searched"
),
None => "approximate".to_string(),
};
let _ = writeln!(
s,
" \u{26a0} APPROXIMATE \u{2014} NOT a guaranteed global minimum: {why}."
);
let _ = writeln!(
s,
" The true least-tax assignment may be lower; this is a disclosed improvement over your"
);
let _ = writeln!(
s,
" current filing position (delta \u{2264} 0), NOT \u{2018}the least tax.\u{2019}"
);
}
let _ = writeln!(
s,
" current federal tax (attributable): {}",
p.baseline_tax
);
let _ = writeln!(
s,
" optimized federal tax (attributable): {}",
p.optimized_tax
);
let _ = writeln!(
s,
" delta (optimized \u{2212} current): {} (negative = saving; always \u{2264} 0)",
p.delta
);
for d in &p.per_disposal {
let _ = writeln!(
s,
" {} @ {} [{}] :: {}",
d.disposal.canonical(),
d.date,
wallet_label(&d.wallet),
compliance_status_tag(&d.status)
);
if d.proposed_selection == d.current_selection {
let _ = writeln!(
s,
" proposed: {} [no change \u{2014} already optimal under current identification]",
picks_str(&d.proposed_selection)
);
continue;
}
let persist = match d.persistable {
Persistability::ContemporaneousNow => {
"persistable now (made \u{2264} sale \u{2192} Contemporaneous)"
}
Persistability::NeedsAttestation => {
"already executed \u{2014} needs `optimize accept --disposal <ref> \
--attest \"\u{2026}\"` (genuine contemporaneous ID only)"
}
Persistability::ForbiddenBroker2027 => {
"2027+ broker-held \u{2014} CANNOT be persisted (own-books insufficient); \
FIFO is the defensible position"
}
};
let _ = writeln!(
s,
" proposed: {} [{}]",
picks_str(&d.proposed_selection),
persist
);
}
let _ = writeln!(
s,
" (vertex-granularity identification: a multi-partial split landing exactly on a \
tax-bracket kink is out of scope.)"
);
let _ = writeln!(
s,
" (this is the tax IF you had identified thus; adequate ID must exist by the time \
of sale \u{2014} \u{a7}1.1012-1(j))"
);
let _ = writeln!(
s,
" (scope: global over taxable-disposal lot selections; self-transfer lot routing is \
held at its baseline position and is not re-optimized.)"
);
s
}
pub fn render_accept_outcome(o: &crate::cmd::optimize::AcceptOutcome) -> String {
let mut s = String::new();
let _ = writeln!(
s,
"Optimize accept \u{2014} {} persisted, {} skipped.",
o.persisted.len(),
o.skipped.len()
);
for (disposal, decision, basis) in &o.persisted {
let _ = writeln!(
s,
" PERSISTED {} \u{2192} LotSelection {} [{}]{}",
disposal.canonical(),
decision.canonical(),
basis,
if *basis == "AttestedRecording" {
" (+ attestation recorded; revoke with `reconcile void`)"
} else {
" (revoke with `reconcile void`)"
}
);
}
for (disposal, reason) in &o.skipped {
let _ = writeln!(s, " skipped {}: {}", disposal.canonical(), reason);
}
if o.persisted.is_empty() && o.skipped.is_empty() {
let _ = writeln!(s, " (no disposals matched)");
}
s
}
pub fn render_consult(r: &btctax_core::ConsultReport) -> String {
let mut s = String::new();
let _ = writeln!(
s,
"Consult (read-only what-if): sell {} sat from {} on {}",
r.req.sell_sat,
wallet_label(&r.req.wallet),
r.req.at
);
if r.approximate {
let _ = writeln!(
s,
" \u{26a0} heuristic \u{2014} searched a subset of a large (>12-lot) pool; \
the proposed selection may not be the exact minimum."
);
}
let _ = writeln!(
s,
" proposed selection: {}",
picks_str(&r.proposed_selection)
);
let _ = writeln!(
s,
" short-term gain: {} long-term gain: {}",
r.st_gain, r.lt_gain
);
let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
let _ = writeln!(
s,
" whole-year federal tax attributable (with this sale): {}",
r.total_federal_tax_attributable
);
if let Some(t) = &r.timing {
let _ = writeln!(
s,
" timing: {} sat of the best selection is short-term until {}; \
selling on/after then would be taxed long-term, a \u{2248} {} difference.",
t.st_sat_in_selection, t.latest_crossover, t.saving_if_waited
);
}
let _ = writeln!(
s,
"Tax decision-support only \u{2014} consequences of a contemplated sale; \
not investment advice (no buy/sell/hold recommendation)."
);
s
}
fn ltcg_bracket_label(b: LtcgBracket) -> &'static str {
match b {
LtcgBracket::Zero => "0%",
LtcgBracket::Fifteen => "15%",
LtcgBracket::Twenty => "20%",
}
}
pub fn render_whatif_sell(r: &SellReport, magi_caveat: bool) -> String {
let mut s = String::new();
let _ = writeln!(
s,
"What-if (read-only): sell {} sat from {} on {}",
r.req.sell_sat,
wallet_label(&r.req.wallet),
r.req.at
);
if magi_caveat {
let _ = writeln!(
s,
" \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
);
}
let _ = writeln!(s, " proceeds: {}", r.proceeds);
let _ = writeln!(s, " lots consumed:");
for leg in &r.lots {
let term = match leg.term {
Term::ShortTerm => "ST",
Term::LongTerm => "LT",
};
let _ = writeln!(
s,
" {}#{} {} sat basis {} {} \u{2192} {} {} gain {}",
leg.lot_id.origin_event_id.canonical(),
leg.lot_id.split_sequence,
leg.sat,
leg.basis,
leg.acquired_at,
leg.sold_at,
term,
leg.gain
);
}
let _ = writeln!(
s,
" short-term gain: {} long-term gain: {}",
r.st_gain, r.lt_gain
);
match r.bracket_room {
Some(room) => {
let _ = writeln!(
s,
" \u{00a7}1(h) LTCG bracket: {} (room {} before the next breakpoint)",
ltcg_bracket_label(r.bracket),
room
);
}
None => {
let _ = writeln!(
s,
" \u{00a7}1(h) LTCG bracket: {} (top bracket \u{2014} no headroom)",
ltcg_bracket_label(r.bracket)
);
}
}
let _ = writeln!(s, " marginal federal tax (this sale): {}", r.marginal_tax);
match r.effective_rate {
Some(rate) => {
let _ = writeln!(s, " effective rate: {rate}");
}
None => {
let _ = writeln!(
s,
" effective rate: n/a (a loss/zero-gain sale \u{2014} its value is the carryforward, \
not this-year tax)"
);
}
}
let carried = r.carryforward_delta.short + r.carryforward_delta.long;
if carried != Usd::ZERO || r.ordinary_offset_delta != Usd::ZERO {
let _ = writeln!(
s,
" \u{00a7}1212: {} offsets ordinary income this year, {} carried to next year \
(short {} / long {})",
r.ordinary_offset_delta, carried, r.carryforward_delta.short, r.carryforward_delta.long
);
}
if r.niit_applies {
let dir = if r.niit_incremental < Usd::ZERO {
"decrease"
} else {
"increase"
};
let _ = writeln!(
s,
" \u{00a7}1411 NIIT: {} ({dir}) attributable to this sale",
r.niit_incremental
);
}
let status = match r.status {
SellStatus::Gain => "net gain",
SellStatus::Loss => "net loss (the carryforward is the value \u{2014} not this-year tax)",
};
let _ = writeln!(s, " status: {status}");
let _ = writeln!(
s,
"Tax decision-support only \u{2014} consequences of a contemplated sale; \
not investment advice (no buy/sell/hold recommendation)."
);
s
}
fn harvest_target_label(t: &HarvestTarget) -> String {
match t {
HarvestTarget::ZeroLtcg => {
"zero-ltcg (all gain in the \u{00a7}1(h) 0% bracket)".to_string()
}
HarvestTarget::FifteenLtcg => "fifteen-ltcg (stay at/under 15%)".to_string(),
HarvestTarget::Gain(x) => format!("gain \u{2264} {x}"),
HarvestTarget::Tax(x) => format!("marginal tax \u{2264} {x}"),
}
}
pub fn render_whatif_harvest(r: &HarvestReport, magi_caveat: bool) -> String {
let mut s = String::new();
let _ = writeln!(
s,
"What-if HARVEST (read-only): {} from {} on {}",
harvest_target_label(&r.req.target),
wallet_label(&r.req.wallet),
r.req.at
);
if magi_caveat {
let _ = writeln!(
s,
" \u{26a0} MAGI assumed = ordinary income; NIIT may be understated if you have other MAGI."
);
}
let status = match &r.status {
HarvestStatus::Found => "FOUND (the target binds)",
HarvestStatus::NotBinding => "NOT BINDING (the whole position fits)",
HarvestStatus::AlreadyBreached => "ALREADY BREACHED at N=0 (nothing can be harvested)",
HarvestStatus::NoLots => "NO LOTS (nothing to harvest from that wallet)",
HarvestStatus::ProceedsRequired => "PROCEEDS REQUIRED",
HarvestStatus::PreTransitionYear => "PRE-2025 (a closed year, not a plan)",
HarvestStatus::YearNotComputable(_) => "YEAR NOT COMPUTABLE",
};
let _ = writeln!(s, " status: {status}");
let _ = writeln!(s, " \u{2192} sell up to {} BTC ({} sat)", r.n_btc, r.n_sat);
let _ = writeln!(s, " bound by: {}", r.binding_constraint);
let _ = writeln!(
s,
" realized gain at N*: short-term {} long-term {}",
r.st_gain, r.lt_gain
);
let ps = &r.with_result.pref_split;
let bracket = if ps.at_20 > Usd::ZERO {
"20%"
} else if ps.at_15 > Usd::ZERO {
"15%"
} else {
"0%"
};
let _ = writeln!(
s,
" \u{00a7}1(h) preferential dollars at N*: {} in 0% / {} in 15% / {} in 20% (top bracket: {})",
ps.at_0, ps.at_15, ps.at_20, bracket
);
let _ = writeln!(s, " marginal federal tax at N*: {}", r.marginal_tax);
let carried = r.carryforward_delta.short + r.carryforward_delta.long;
if carried != Usd::ZERO {
let dir = if carried < Usd::ZERO {
"burned (spent)"
} else {
"carried to next year"
};
let _ = writeln!(
s,
" \u{00a7}1212 carryforward {}: {} (short {} / long {})",
dir, carried, r.carryforward_delta.short, r.carryforward_delta.long
);
}
if r.niit_applies {
let dir = if r.niit_incremental < Usd::ZERO {
"decrease"
} else {
"increase"
};
let _ = writeln!(
s,
" \u{00a7}1411 NIIT: {} ({dir}) at N* \u{2014} the +3.8% kink applies even inside a 0%/15% bracket answer",
r.niit_incremental
);
}
if let Some(note) = &r.plateau_note {
let _ = writeln!(s, " \u{2139} {note}");
}
let _ = writeln!(
s,
"Tax decision-support only \u{2014} the engine-verified consequences of a contemplated harvest; \
not investment advice (no buy/sell/hold recommendation)."
);
s
}
pub fn render_verify(r: &VerifyReport) -> String {
let mut out = String::new();
let c = &r.conservation;
let _ = writeln!(
out,
"Conservation (FR9): {}",
if c.balanced { "BALANCED" } else { "DRIFT" }
);
let _ = writeln!(
out,
" in {} = disposed {} + removed {} + held {} + fee-sats {} + pending {}{}",
c.sigma_in,
c.sigma_disposed,
c.sigma_removed,
c.sigma_held,
c.sigma_fee_sats,
c.sigma_pending,
if c.has_uncovered {
" [identity undefined: uncovered disposal open]"
} else {
""
}
);
let _ = writeln!(out, "2025 transition: {}", r.safe_harbor);
let _ = writeln!(
out,
"Pending reconciliation: {} transfer(s); unknown-basis inbounds: {}",
r.pending, r.unknown_basis_inbounds
);
let _ = writeln!(
out,
"Hard blockers (gate tax computation): {}",
r.hard.len()
);
for b in &r.hard {
let evt = b
.event
.as_ref()
.map(|e| e.canonical())
.unwrap_or_else(|| "-".to_string());
let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
if b.kind == BlockerKind::FmvMissing {
let _ = writeln!(out, " ↳ {}", crate::price_cache::UPDATE_PRICES_HINT);
}
}
let _ = writeln!(out, "Advisory blockers: {}", r.advisory.len());
for b in &r.advisory {
let evt = b
.event
.as_ref()
.map(|e| e.canonical())
.unwrap_or_else(|| "-".to_string());
let _ = writeln!(out, " [{:?}] {} :: {}", b.kind, evt, b.detail);
}
let _ = writeln!(
out,
"Pre-2025 method (attested historical fact): {} (attested: {})",
lot_method_display(r.declared_pre2025_method),
r.pre2025_method_attested
);
let _ = writeln!(
out,
"Standing orders (MethodElection): {}",
r.elections.len()
);
for e in &r.elections {
let _ = writeln!(
out,
" recorded {} effective {} -> {} [{}]",
e.recorded,
e.effective_from,
lot_method_display(e.method),
e.note
);
}
let _ = writeln!(out, "Lot selections recorded: {}", r.selection_count);
let _ = writeln!(
out,
"Per-disposal compliance (post-2025): {}",
r.compliance.len()
);
for c in &r.compliance {
let _ = writeln!(
out,
" {} @ {} :: {}",
c.disposal.canonical(),
c.date,
compliance_status_tag(&c.status)
);
}
out
}
#[cfg(test)]
mod gift_advisory_tests {
use super::*;
use btctax_core::conventions::Usd;
use btctax_core::{EventId, LotId, Removal, RemovalLeg, TaxTable};
use rust_decimal_macros::dec;
use std::collections::BTreeMap;
use time::macros::date;
fn gift_removal(seq: u64, removed_at: TaxDate, fmv: Usd) -> Removal {
Removal {
event: EventId::decision(seq),
kind: RemovalKind::Gift,
removed_at,
legs: vec![RemovalLeg {
lot_id: LotId {
origin_event_id: EventId::decision(seq),
split_sequence: 0,
},
sat: 100,
basis: dec!(0),
fmv_at_transfer: fmv,
term: Term::LongTerm,
basis_source: BasisSource::ComputedFromCost,
acquired_at: date!(2024 - 01 - 01),
pseudo: false,
}],
appraisal_required: false,
donor_acquired_at: None,
claimed_deduction: None,
donee: None,
}
}
fn gift_removal_labeled(seq: u64, removed_at: TaxDate, fmv: Usd, label: &str) -> Removal {
Removal {
donee: Some(label.to_string()),
..gift_removal(seq, removed_at, fmv)
}
}
fn state_with(removals: Vec<Removal>) -> LedgerState {
LedgerState {
removals,
..Default::default()
}
}
fn tables_with(year: i32, excl: Usd) -> BTreeMap<i32, TaxTable> {
tables_with_lifetime(year, excl, dec!(13_990_000))
}
fn tables_with_lifetime(year: i32, excl: Usd, lifetime_excl: Usd) -> BTreeMap<i32, TaxTable> {
let mut m = BTreeMap::new();
m.insert(
year,
TaxTable {
year,
source: "TEST",
ordinary: BTreeMap::new(),
ltcg: BTreeMap::new(),
gift_annual_exclusion: excl,
ss_wage_base: dec!(176100),
gift_lifetime_exclusion: lifetime_excl,
},
);
m
}
#[test]
fn no_gifts_is_none() {
let st = state_with(vec![]);
let tables = tables_with(2025, dec!(19000));
assert!(render_gift_advisory(&st, 2025, dec!(0), &tables).is_none());
}
#[test]
fn gifts_present_but_no_table_emits_note_not_none() {
let st = state_with(vec![gift_removal(1, date!(2026 - 06 - 01), dec!(50000))]);
let tables = tables_with(2025, dec!(19000));
let msg =
render_gift_advisory(&st, 2026, dec!(0), &tables).expect("note expected, not None");
assert!(msg.contains("unavailable"), "{msg}");
assert!(msg.contains("Form 709 exposure not evaluated"), "{msg}");
assert!(
msg.contains("50000.00"),
"must record the gift total: {msg}"
);
}
#[test]
fn labeled_donee_over_exclusion_emits_advisory() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(20000),
"Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(msg.contains("20000.00"), "must show Alice's total: {msg}");
assert!(msg.contains("19000.00"), "must show the exclusion: {msg}");
assert!(
msg.contains("Form 709 filing required"),
"must flag filing required: {msg}"
);
assert!(msg.contains("Alice"), "must name Alice: {msg}");
assert!(msg.contains("1000.00"), "taxable must be $1000.00: {msg}");
assert!(
!msg.contains("donee identity is not modeled"),
"stale aggregate caveat must not appear: {msg}"
);
}
#[test]
fn labeled_donee_under_exclusion_no_filing_required() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(10000),
"Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg =
render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected, not None");
assert!(
msg.contains("No Form 709 filing required"),
"must say no filing required: {msg}"
);
assert!(msg.contains("Alice"), "must mention Alice: {msg}");
assert!(msg.contains("10000.00"), "must show Alice's total: {msg}");
}
#[test]
fn per_donee_under_exclusion_two_donees_no_filing_required() {
let st = state_with(vec![
gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(15000), "Alice"),
gift_removal_labeled(2, date!(2025 - 06 - 01), dec!(15000), "Bob"),
]);
let tables = tables_with(2025, dec!(19000));
let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(
msg.contains("No Form 709 filing required"),
"neither donee exceeds exclusion → no filing required: {msg}"
);
assert!(msg.contains("Alice"), "Alice must appear: {msg}");
assert!(msg.contains("Bob"), "Bob must appear: {msg}");
assert!(msg.contains("15000.00"), "donee total must appear: {msg}");
assert!(
!msg.contains("Form 709 filing required (donee(s):"),
"filing trigger must NOT fire: {msg}"
);
assert!(
msg.contains("Total taxable gifts: $0.00"),
"total taxable must be $0.00: {msg}"
);
}
#[test]
fn one_donee_over_exclusion_filing_required() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(25000),
"Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(
msg.contains("Form 709 filing required (donee(s): Alice)"),
"must trigger filing required for Alice: {msg}"
);
assert!(msg.contains("25000.00"), "Alice total must appear: {msg}");
assert!(
msg.contains("19000.00"),
"exclusion applied must appear: {msg}"
);
assert!(
msg.contains("6000.00"),
"taxable $6,000.00 must appear: {msg}"
);
}
#[test]
fn unlabeled_bucket_caveat_with_conservative_aggregate() {
let st = state_with(vec![gift_removal(1, date!(2025 - 06 - 01), dec!(30000))]);
let tables = tables_with(2025, dec!(19000));
let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(
msg.contains("no donee label"),
"unlabeled caveat must appear: {msg}"
);
assert!(
msg.contains("30000.00"),
"unlabeled total must appear: {msg}"
);
assert!(
msg.contains("Conservative aggregate"),
"conservative aggregate signal must appear: {msg}"
);
assert!(
msg.contains("19000.00"),
"one-exclusion comparison must appear: {msg}"
);
assert!(
!msg.contains("Form 709 filing required (donee(s):"),
"labeled filing trigger must NOT fire for unlabeled gifts: {msg}"
);
}
#[test]
fn mixed_labeled_over_and_unlabeled_shows_both() {
let st = state_with(vec![
gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(25000), "Alice"),
gift_removal(2, date!(2025 - 06 - 01), dec!(5000)), ]);
let tables = tables_with(2025, dec!(19000));
let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(
msg.contains("Form 709 filing required"),
"filing required for Alice: {msg}"
);
assert!(msg.contains("Alice"), "Alice must appear: {msg}");
assert!(
msg.contains("no donee label"),
"unlabeled caveat must appear: {msg}"
);
assert!(
msg.contains("5000.00"),
"unlabeled total must appear: {msg}"
);
}
#[test]
fn donations_excluded_from_form709_advisory() {
let donation_removal = Removal {
event: EventId::decision(1),
kind: RemovalKind::Donation,
removed_at: date!(2025 - 06 - 01),
legs: vec![RemovalLeg {
lot_id: LotId {
origin_event_id: EventId::decision(1),
split_sequence: 0,
},
sat: 100,
basis: dec!(0),
fmv_at_transfer: dec!(50000), term: Term::LongTerm,
basis_source: BasisSource::ComputedFromCost,
acquired_at: date!(2024 - 01 - 01),
pseudo: false,
}],
appraisal_required: false,
donor_acquired_at: None,
claimed_deduction: Some(dec!(50000)),
donee: Some("Charity X".to_string()),
};
let st = state_with(vec![donation_removal]);
let tables = tables_with(2025, dec!(19000));
assert!(
render_gift_advisory(&st, 2025, dec!(0), &tables).is_none(),
"Donation must be excluded from the Form 709 advisory"
);
}
#[test]
fn section_2505_under_lifetime_shows_used_and_remaining() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(100000),
"Alice",
)]);
let tables = tables_with(2025, dec!(19000)); let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(
msg.contains("81000.00"),
"taxable $81,000 must appear: {msg}"
);
assert!(
msg.contains("§2505 lifetime (basic) exclusion"),
"§2505 block must appear: {msg}"
);
assert!(
msg.contains("13990000.00"),
"lifetime exclusion $13,990,000 must appear: {msg}"
);
assert!(
msg.contains("13909000.00"),
"remaining $13,909,000 must appear: {msg}"
);
assert!(
!msg.contains("EXCEEDED"),
"must NOT say EXCEEDED when under limit: {msg}"
);
assert!(
!msg.contains("later chunk (Chunk 3)"),
"stale Chunk-3 caveat must be absent: {msg}"
);
}
#[test]
fn section_2505_prior_gifts_accumulate() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(100000),
"Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg =
render_gift_advisory(&st, 2025, dec!(13_900_000), &tables).expect("advisory expected");
assert!(
msg.contains("13981000.00"),
"cumulative $13,981,000 must appear: {msg}"
);
assert!(
msg.contains("9000.00"),
"remaining $9,000 must appear: {msg}"
);
assert!(
!msg.contains("EXCEEDED"),
"must NOT say EXCEEDED when under limit: {msg}"
);
}
#[test]
fn section_2505_exceeds_lifetime_shows_exceeded_and_excess() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(100000),
"Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg =
render_gift_advisory(&st, 2025, dec!(13_950_000), &tables).expect("advisory expected");
assert!(
msg.contains("14031000.00"),
"cumulative $14,031,000 must appear: {msg}"
);
assert!(
msg.contains("EXCEEDED"),
"must say EXCEEDED when over lifetime limit: {msg}"
);
assert!(
msg.contains("41000.00"),
"excess $41,000 must appear: {msg}"
);
}
#[test]
fn section_2505_exact_boundary_remaining_zero_not_exceeded() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(100000),
"Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg =
render_gift_advisory(&st, 2025, dec!(13_909_000), &tables).expect("advisory expected");
assert!(
msg.contains("13990000.00"),
"cumulative $13,990,000 must appear: {msg}"
);
assert!(
msg.contains("($0.00 remaining)"),
"remaining $0.00 in exact phrasing '($0.00 remaining)' must appear: {msg}"
);
assert!(
!msg.contains("EXCEEDED"),
"must NOT say EXCEEDED at exactly the limit: {msg}"
);
}
#[test]
fn section_2505_prior_only_block_shows_even_when_current_taxable_zero() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(10000), "Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg =
render_gift_advisory(&st, 2025, dec!(5_000_000), &tables).expect("advisory expected");
assert!(
msg.contains("5000000.00"),
"cumulative $5,000,000 must appear: {msg}"
);
assert!(
msg.contains("§2505 lifetime (basic) exclusion"),
"§2505 block must appear for prior-only case: {msg}"
);
assert!(!msg.contains("EXCEEDED"), "must NOT say EXCEEDED: {msg}");
}
#[test]
fn section_2505_no_block_when_cumulative_zero() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(10000), "Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(
!msg.contains("§2505 lifetime"),
"§2505 block must NOT appear when cumulative = 0: {msg}"
);
}
#[test]
fn section_2505_default_zero_prior_shows_caveats() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(100000), "Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(
!msg.contains("later chunk (Chunk 3)"),
"stale 'later chunk (Chunk 3)' must be absent: {msg}"
);
assert!(
msg.contains("§2513 gift-splitting"),
"§2513 caveat must be present: {msg}"
);
assert!(
msg.contains("portability/DSUE"),
"portability/DSUE caveat must be present: {msg}"
);
assert!(
msg.contains("prior cumulative taxable gifts are user-supplied"),
"prior-cumulative disclosure caveat must be present: {msg}"
);
}
#[test]
fn section_2505_mixed_shows_omission_disclosure_for_unlabeled() {
let st = state_with(vec![
gift_removal_labeled(1, date!(2025 - 03 - 01), dec!(100000), "Alice"),
gift_removal(2, date!(2025 - 06 - 01), dec!(50000)), ]);
let tables = tables_with(2025, dec!(19000));
let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(
msg.contains("§2505 lifetime (basic) exclusion"),
"§2505 block must appear: {msg}"
);
assert!(
msg.contains("81000.00"),
"used $81,000 (labeled only) must appear: {msg}"
);
assert!(
msg.contains("§2505 consumption reflects LABELED-donee taxable gifts only"),
"omission disclosure must appear: {msg}"
);
assert!(
msg.contains("50000.00"),
"unlabeled total $50,000 must appear in omission disclosure: {msg}"
);
assert!(
msg.contains("consumption may be understated"),
"under-stated warning must appear: {msg}"
);
}
#[test]
fn section_2505_stale_chunk3_caveat_is_absent() {
let st = state_with(vec![gift_removal_labeled(
1,
date!(2025 - 06 - 01),
dec!(20000),
"Alice",
)]);
let tables = tables_with(2025, dec!(19000));
let msg = render_gift_advisory(&st, 2025, dec!(0), &tables).expect("advisory expected");
assert!(
!msg.contains("later chunk (Chunk 3)"),
"stale Chunk-3 caveat must be absent: {msg}"
);
assert!(
!msg.contains("§2505 lifetime exemption is a later chunk"),
"stale §2505 future-chunk phrase must be absent: {msg}"
);
}
}
#[cfg(test)]
mod schedule_se_tests {
use super::*;
use rust_decimal_macros::dec;
fn golden1() -> SeTaxResult {
SeTaxResult {
net_se: dec!(100000),
base: dec!(92350.00),
ss: dec!(11451.40),
medicare: dec!(2678.15),
addl: dec!(0.00),
total: dec!(14129.55),
deductible_half: dec!(7064.78),
}
}
fn w2_headline() -> SeTaxResult {
SeTaxResult {
net_se: dec!(100000),
base: dec!(92350.00),
ss: dec!(3236.40),
medicare: dec!(2678.15),
addl: dec!(381.15),
total: dec!(6295.70),
deductible_half: dec!(2957.28),
}
}
fn w2_asymmetric() -> SeTaxResult {
SeTaxResult {
net_se: dec!(100000),
base: dec!(92350.00),
ss: dec!(3236.40),
medicare: dec!(2678.15),
addl: dec!(0.00),
total: dec!(5914.55),
deductible_half: dec!(2957.28),
}
}
fn expenses_headline() -> SeTaxResult {
SeTaxResult {
net_se: dec!(80000),
base: dec!(73880.00),
ss: dec!(9161.12),
medicare: dec!(2142.52),
addl: dec!(0.00),
total: dec!(11303.64),
deductible_half: dec!(5651.82),
}
}
fn expenses_w2_combined() -> SeTaxResult {
SeTaxResult {
net_se: dec!(80000),
base: dec!(73880.00),
ss: dec!(3236.40),
medicare: dec!(2142.52),
addl: dec!(214.92),
total: dec!(5593.84),
deductible_half: dec!(2689.46),
}
}
#[test]
fn business_mining_year_renders_full_section() {
let r = golden1();
let s = render_schedule_se(
2025,
Some(&r),
dec!(100000),
true,
Usd::ZERO,
Usd::ZERO,
Usd::ZERO,
)
.expect("SE section expected");
assert!(s.contains("92350.00"), "net SE earnings base: {s}");
assert!(s.contains("11451.40"), "SS component: {s}");
assert!(s.contains("2678.15"), "Medicare component: {s}");
assert!(s.contains("14129.55"), "total SE tax: {s}");
assert!(s.contains("7064.78"), "§164(f) deductible half: {s}");
assert!(
s.contains("Additional Medicare"),
"addl component labeled: {s}"
);
assert!(
s.contains("$0 W-2 wages"),
"short $0-W-2 note must appear (both W-2 = 0): {s}"
);
assert!(
s.contains("--w2-ss-wages"),
"$0 note must mention --w2-ss-wages flag: {s}"
);
assert!(
!s.contains("OVERSTATED"),
"old OVERSTATED text must be absent (Chunk A regression): {s}"
);
assert!(
!s.contains("UNDERSTATED"),
"old UNDERSTATED text must be absent (Chunk A regression): {s}"
);
assert!(
s.contains("NOT auto-coordinated"),
"§164(f) advisory must appear: {s}"
);
assert!(
s.contains("coordinate it on your actual return"),
"§164(f) advisory must include coordination instruction: {s}"
);
assert!(
s.contains("SEPARATE federal liability"),
"standalone note: {s}"
);
assert!(
s.contains("not") && s.contains("§164(f)"),
"notes §164(f) not auto-coordinated: {s}"
);
assert!(
s.contains("no Schedule C expenses supplied"),
"Chunk B $0-expenses note must appear: {s}"
);
assert!(
s.contains("--schedule-c-expenses"),
"$0 note must mention --schedule-c-expenses flag: {s}"
);
assert!(
!s.contains("not modeled"),
"old 'not modeled' caveat must be absent (replaced by Chunk B): {s}"
);
}
#[test]
fn w2_set_renders_coordinated_disclosure() {
let r = w2_headline();
let s = render_schedule_se(
2025,
Some(&r),
dec!(100000),
true,
Usd::ZERO,
dec!(150000),
dec!(150000),
)
.expect("SE section expected");
assert!(
s.contains("W-2 coordination applied"),
"coordinated disclosure must appear: {s}"
);
assert!(
s.contains("§1401(b)(2)(B)"),
"must cite §1401(b)(2)(B): {s}"
);
assert!(
s.contains("Form 8959 Part II"),
"must cite Form 8959 Part II: {s}"
);
assert!(s.contains("150000"), "w2_ss_wages amount must appear: {s}");
assert!(!s.contains("OVERSTATED"), "OVERSTATED must be absent: {s}");
assert!(
!s.contains("UNDERSTATED"),
"UNDERSTATED must be absent: {s}"
);
assert!(s.contains("3236.40"), "reduced SS component: {s}");
assert!(s.contains("381.15"), "non-zero addl: {s}");
assert!(s.contains("6295.70"), "reduced total: {s}");
assert!(s.contains("2957.28"), "deductible_half: {s}");
}
#[test]
fn w2_asymmetric_render_transposition_guard() {
let r = w2_asymmetric();
let s = render_schedule_se(
2025,
Some(&r),
dec!(100000),
true,
Usd::ZERO,
dec!(150000),
Usd::ZERO,
)
.expect("SE section expected");
assert!(
s.contains("W-2 coordination applied"),
"coordinated disclosure must appear: {s}"
);
assert!(s.contains("3236.40"), "ss must be 3236.40 (reduced): {s}");
assert!(
s.contains("0.00"),
"addl must be 0.00 (threshold un-reduced): {s}"
);
assert!(!s.contains("OVERSTATED"), "{s}");
assert!(!s.contains("UNDERSTATED"), "{s}");
}
#[test]
fn no_business_income_no_section() {
assert!(
render_schedule_se(2025, None, Usd::ZERO, true, Usd::ZERO, Usd::ZERO, Usd::ZERO)
.is_none()
);
}
#[test]
fn business_income_but_no_table_emits_note() {
let s = render_schedule_se(
2099,
None,
dec!(100000),
false,
Usd::ZERO,
Usd::ZERO,
Usd::ZERO,
)
.expect("wage-base-unavailable note expected");
assert!(s.contains("SS wage base unavailable"), "{s}");
assert!(s.contains("2099"), "names the year: {s}");
assert!(s.contains("no silent drop"), "{s}");
}
#[test]
fn expenses_20k_no_w2_renders_breakout_and_advisory() {
let r = expenses_headline(); let s = render_schedule_se(
2025,
Some(&r),
dec!(100000), true,
dec!(20000), Usd::ZERO,
Usd::ZERO,
)
.expect("SE section expected");
assert!(
s.contains("gross business income"),
"breakout line must appear: {s}"
);
assert!(
s.contains("100000.00"),
"gross ($100k) must appear in breakout: {s}"
);
assert!(
s.contains("20000.00"),
"expenses ($20k) must appear in breakout: {s}"
);
assert!(
s.contains("80000.00"),
"net_se ($80k) must appear in breakout: {s}"
);
assert!(
s.contains("OVERSTATES"),
"Schedule C advisory OVERSTATES text: {s}"
);
assert!(
s.contains("ORDINARY taxable income"),
"advisory must mention ORDINARY taxable income: {s}"
);
assert!(
s.contains("engine-side coordination is deferred"),
"advisory must mention deferred coordination: {s}"
);
assert!(
!s.contains("reduce your ordinary_taxable_income"),
"NO OTI-edit prescription allowed (spec D3): {s}"
);
assert!(
!s.contains("set --ordinary-taxable-income"),
"NO OTI-edit prescription allowed (spec D3): {s}"
);
assert!(s.contains("73880.00"), "base $73,880: {s}");
assert!(s.contains("9161.12"), "ss $9,161.12: {s}");
assert!(s.contains("2142.52"), "medicare $2,142.52: {s}");
assert!(s.contains("11303.64"), "total $11,303.64: {s}");
assert!(s.contains("5651.82"), "deductible_half $5,651.82: {s}");
assert!(
!s.contains("not modeled"),
"old 'not modeled' caveat must be absent: {s}"
);
}
#[test]
fn fully_expensed_shows_new_line_not_wage_base_note() {
let s = render_schedule_se(
2025,
None,
dec!(10000), true, dec!(15000), Usd::ZERO,
Usd::ZERO,
)
.expect("fully-expensed section expected (not None)");
assert!(
s.contains("fully expensed"),
"fully-expensed line must appear: {s}"
);
assert!(
s.contains("10000.00"),
"gross $10k must appear in fully-expensed line: {s}"
);
assert!(
s.contains("15000.00"),
"expenses $15k must appear in fully-expensed line: {s}"
);
assert!(
s.contains("no §1401 SE tax"),
"must state no SE tax owed: {s}"
);
assert!(s.contains("2025"), "must name the year: {s}");
assert!(
!s.contains("SS wage base unavailable"),
"wage-base-unavailable note must be ABSENT for fully-expensed case: {s}"
);
}
#[test]
fn expenses_w2_combined_renders_both() {
let r = expenses_w2_combined(); let s = render_schedule_se(
2025,
Some(&r),
dec!(100000),
true,
dec!(20000), dec!(150000), dec!(150000), )
.expect("SE section expected");
assert!(s.contains("gross business income"), "breakout line: {s}");
assert!(s.contains("80000.00"), "net_se in breakout: {s}");
assert!(s.contains("OVERSTATES"), "Schedule C advisory: {s}");
assert!(
s.contains("W-2 coordination applied"),
"W-2 coordination: {s}"
);
assert!(s.contains("73880.00"), "base: {s}");
assert!(s.contains("3236.40"), "ss (reduced by W-2 cap): {s}");
assert!(s.contains("2142.52"), "medicare: {s}");
assert!(s.contains("214.92"), "addl: {s}");
assert!(s.contains("5593.84"), "total: {s}");
assert!(s.contains("2689.46"), "deductible_half: {s}");
}
#[test]
fn schedule_se_csv_columns_and_values() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("export");
let st = LedgerState::default();
let r = golden1();
write_csv_exports(&out, &st, Some(2025), Some(&r), &BTreeMap::new()).unwrap();
let path = out.join("schedule_se.csv");
assert!(path.exists(), "schedule_se.csv must be written");
let mut rdr = csv::Reader::from_reader(std::fs::File::open(&path).unwrap());
let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
assert_eq!(
headers,
vec![
"net_se_earnings",
"se_base_9235",
"ss_component",
"medicare_component",
"additional_medicare_component",
"total_se_tax",
"deductible_half",
]
);
let rec = rdr
.records()
.next()
.expect("one data row")
.expect("readable");
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"); }
#[test]
fn schedule_se_csv_omitted_when_no_se_tax() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("export");
let st = LedgerState::default();
write_csv_exports(&out, &st, Some(2025), None, &BTreeMap::new()).unwrap();
assert!(!out.join("schedule_se.csv").exists());
}
}
#[cfg(test)]
mod form8283_csv_tests {
use super::*;
#[test]
fn form8283_csv_detail_columns_present_and_empty() {
use btctax_core::{
BasisSource, DonationDetails, EventId, LedgerState, Removal, RemovalKind, RemovalLeg,
Term,
};
use time::macros::date;
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("export");
let event = EventId::decision(99);
let leg = RemovalLeg {
lot_id: btctax_core::LotId {
origin_event_id: event.clone(),
split_sequence: 0,
},
sat: 100_000_000,
basis: rust_decimal::Decimal::ZERO,
fmv_at_transfer: rust_decimal::Decimal::from(52000),
term: Term::LongTerm,
basis_source: BasisSource::ComputedFromCost,
acquired_at: date!(2025 - 01 - 01),
pseudo: false,
};
let removal = Removal {
event: event.clone(),
kind: RemovalKind::Donation,
removed_at: date!(2025 - 03 - 01),
legs: vec![leg],
appraisal_required: false,
donor_acquired_at: None,
claimed_deduction: Some(rust_decimal::Decimal::from(52000)),
donee: Some("Test Charity Two".into()),
};
let event2 = EventId::decision(100);
let leg2 = RemovalLeg {
lot_id: btctax_core::LotId {
origin_event_id: event2.clone(),
split_sequence: 0,
},
sat: 10_000_000,
basis: rust_decimal::Decimal::ZERO,
fmv_at_transfer: rust_decimal::Decimal::from(8000),
term: Term::LongTerm,
basis_source: BasisSource::ComputedFromCost,
acquired_at: date!(2025 - 01 - 15),
pseudo: false,
};
let removal2 = Removal {
event: event2.clone(),
kind: RemovalKind::Donation,
removed_at: date!(2025 - 05 - 01),
legs: vec![leg2],
appraisal_required: false,
donor_acquired_at: None,
claimed_deduction: Some(rust_decimal::Decimal::from(8000)),
donee: Some("No Details Org".into()),
};
let st = LedgerState {
removals: vec![removal, removal2],
..Default::default()
};
let mut dmap: BTreeMap<EventId, DonationDetails> = BTreeMap::new();
dmap.insert(
event,
DonationDetails {
donee_name: "Test Charity".into(),
donee_ein: Some("12-3456789".into()),
donee_address: Some("123 Main".into()),
appraiser_name: "Test Appraiser".into(),
appraiser_tin: Some("987654321".into()),
appraiser_ptin: Some("P01234567".into()),
appraiser_qualifications: Some("Certified".into()),
appraisal_date: Some(date!(2025 - 06 - 01)),
appraiser_address: None,
fmv_method_override: None,
},
);
write_csv_exports(&out, &st, Some(2025), None, &dmap).unwrap();
let path = out.join("form8283.csv");
assert!(path.exists(), "form8283.csv must exist");
let mut rdr = csv::ReaderBuilder::new()
.comment(Some(b'#'))
.from_path(&path)
.unwrap();
let headers: Vec<String> = rdr.headers().unwrap().iter().map(String::from).collect();
let idx = |name: &str| {
headers
.iter()
.position(|h| h == name)
.unwrap_or_else(|| panic!("header {name} not found"))
};
let all_recs: Vec<csv::StringRecord> = rdr.records().map(|r| r.unwrap()).collect();
assert_eq!(
all_recs.len(),
2,
"must have exactly two data rows (one per removal)"
);
let rec = &all_recs[0];
let no_details_rec = &all_recs[1];
assert_eq!(&rec[idx("donee")], "Test Charity");
assert_eq!(&rec[idx("appraiser")], "Test Appraiser");
assert_eq!(&rec[idx("donee_ein")], "12-3456789");
assert_eq!(&rec[idx("donee_address")], "123 Main");
assert_eq!(&rec[idx("appraiser_tin")], "987654321");
assert_eq!(&rec[idx("appraiser_ptin")], "P01234567");
assert_eq!(&rec[idx("appraiser_qualifications")], "Certified");
assert_eq!(&rec[idx("appraisal_date")], "2025-06-01");
assert_eq!(&rec[idx("needs_review")], "false");
assert_eq!(
&no_details_rec[idx("donee_ein")],
"",
"no-details row: donee_ein must be empty"
);
assert_eq!(
&no_details_rec[idx("donee_address")],
"",
"no-details row: donee_address must be empty"
);
assert_eq!(
&no_details_rec[idx("appraiser_tin")],
"",
"no-details row: appraiser_tin must be empty"
);
assert_eq!(
&no_details_rec[idx("appraiser_ptin")],
"",
"no-details row: appraiser_ptin must be empty"
);
assert_eq!(
&no_details_rec[idx("appraiser_qualifications")],
"",
"no-details row: appraiser_qualifications must be empty"
);
assert_eq!(
&no_details_rec[idx("appraisal_date")],
"",
"no-details row: appraisal_date must be empty"
);
assert_eq!(
&no_details_rec[idx("needs_review")],
"true",
"no-details carrier row: needs_review must be true"
);
}
}
#[cfg(test)]
mod advisory_wrap_tests {
use super::*;
#[test]
fn advisories_wrap_to_the_house_width_with_a_hanging_indent() {
use btctax_core::tax::advisories::Advisory;
let out = render_advisories(&[Advisory::CtcOdcOmitted { dependents: 2 }]);
for line in out.lines() {
assert!(
line.chars().count() <= ADVISORY_WRAP_COLS,
"line is {} cols, over the {}-col house width: {line:?}",
line.chars().count(),
ADVISORY_WRAP_COLS
);
}
assert!(
out.lines()
.any(|l| l.starts_with(" ") && !l.trim().is_empty()),
"a 300-char advisory must wrap onto continuation lines, got:\n{out}"
);
}
}