use crate::cli::FormArg;
use crate::config::{set_fee_treatment, set_pre2025_method as config_set_pre2025_method};
use crate::render::write_csv_exports;
use crate::{require_attestation, CliConfig, CliError, Session};
use btctax_adapters::BundledTaxTables;
use btctax_core::{
compute_se_tax, se_net_income, FeeTreatment, LedgerEvent, LotMethod, ScheduleDPart, Severity,
TaxTables, Usd, DIGITAL_ASSET_8949_FIRST_YEAR,
};
use btctax_forms::Form1040Inputs;
use btctax_store::{fsperms, Passphrase};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ExportReport {
pub path: PathBuf,
pub unresolved_hard: usize,
pub experimental_notice_active: bool,
}
pub fn show_config(vault_path: &Path, pp: &Passphrase) -> Result<CliConfig, CliError> {
Session::open(vault_path, pp)?.config()
}
pub fn set_config(
vault_path: &Path,
pp: &Passphrase,
fee_treatment: Option<FeeTreatment>,
) -> Result<CliConfig, CliError> {
let mut session = Session::open(vault_path, pp)?;
if let Some(t) = fee_treatment {
set_fee_treatment(session.conn(), t)?;
session.save()?;
}
session.config()
}
pub fn set_pre2025_method(
vault_path: &Path,
pp: &Passphrase,
m: LotMethod,
attested: bool,
) -> Result<CliConfig, CliError> {
let mut session = Session::open(vault_path, pp)?;
config_set_pre2025_method(session.conn(), m, attested)?;
session.save()?;
session.config()
}
pub fn promote_export_gate(
state: &btctax_core::state::LedgerState,
events: &[LedgerEvent],
year: Option<i32>,
) -> Result<(), CliError> {
let years: Vec<i32> = match year {
Some(y) => vec![y],
None => crate::chokepoint::promoted_filing_years(state)
.into_iter()
.collect(),
};
for y in years {
if let Some(disc) = btctax_core::tax::form8275::disclosure_8275(events, state, y) {
if disc.incomplete {
return Err(CliError::Usage(format!(
"refusing to export a packet with a promoted-basis leg but no complete Form 8275 \
disclosure for {y}: Reg \u{00a7}1.6662-4(f) makes disclosure adequate only on a \
COMPLETED Form 8275, and this promoted disposal has an empty Part II narrative. \
Record the Part II explanation (re-run `btctax reconcile promote-tranche … \
--part-ii-file <path>`) before exporting."
)));
}
}
}
Ok(())
}
pub fn export_snapshot(
vault_path: &Path,
pp: &Passphrase,
out_dir: &Path,
tax_year: Option<i32>,
attest: Option<&str>,
) -> Result<ExportReport, CliError> {
let session = Session::open(vault_path, pp)?;
let (events, state, _cfg) = session.load_events_and_project()?;
promote_export_gate(&state, &events, tax_year)?;
if state.pseudo_active() {
require_attestation(attest)?;
}
let sqlite = session
.vault()
.export_snapshot(out_dir)
.map_err(|e| crate::store_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?; let se_result = match tax_year {
Some(y) => {
let tables = BundledTaxTables::load();
let profile = match session.resolve_screened(&state, y, &tables)? {
crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
crate::resolve::ProfileOutcome::Uncomputable { .. } => None,
};
profile.and_then(|p| {
tables.table_for(y).and_then(|t| {
compute_se_tax(
&state,
y,
p.filing_status,
t,
p.w2_ss_wages,
p.w2_medicare_wages,
p.schedule_c_expenses,
)
})
})
}
None => None,
};
let donation_details = session.donation_details()?;
write_csv_exports(
out_dir,
&state,
tax_year,
se_result.as_ref(),
&donation_details,
)
.map_err(|e| crate::cli_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?;
match tax_year {
Some(y) => {
crate::render::write_form_8275_txt(out_dir, &state, &events, y)
.map_err(|e| crate::cli_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?;
}
None => {
let mut promoted_years: std::collections::BTreeSet<i32> =
std::collections::BTreeSet::new();
for d in &state.disposals {
if d.legs
.iter()
.any(|l| state.promoted_origins.contains(&l.lot_id.origin_event_id))
{
promoted_years.insert(d.disposed_at.year());
}
}
for y in promoted_years {
crate::render::write_form_8275_txt_named(
out_dir,
&state,
&events,
y,
&format!("form_8275_{y}.txt"),
)
.map_err(|e| crate::cli_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?;
}
}
}
let unresolved_hard = state
.blockers
.iter()
.filter(|b| b.kind.severity() == Severity::Hard)
.count();
Ok(ExportReport {
path: sqlite,
unresolved_hard,
experimental_notice_active: btctax_core::experimental::uses_approach_b(&events),
})
}
pub fn export_pseudo_active(vault_path: &Path, pp: &Passphrase) -> Result<bool, CliError> {
let (state, _cfg) = Session::open(vault_path, pp)?.project()?;
Ok(state.pseudo_active())
}
#[derive(Debug, Clone)]
pub struct IrsPdfReport {
pub f8949_path: Option<PathBuf>,
pub schedule_d_path: Option<PathBuf>,
pub tax_year: i32,
pub unresolved_hard: usize,
pub broker_reported_rows: usize,
pub watermarked: bool,
pub schedule_se_path: Option<PathBuf>,
pub se_below_floor: bool,
pub se_addl_medicare: Option<Usd>,
pub se_income_without_profile: bool,
pub form_8283_path: Option<PathBuf>,
pub form_8283_needs_review: bool,
pub form_8283_section_b: Option<bool>,
pub form_1040_path: Option<PathBuf>,
pub form_1040_filled_7a: bool,
pub form_1040_loss: bool,
pub form_8275_path: Option<PathBuf>,
pub full_return_paths: Vec<PathBuf>,
pub full_return_manifest: Option<PathBuf>,
pub forms_ignored_full_return: bool,
pub experimental_notice_active: bool,
}
pub fn broker_reporting_advisory(tax_year: i32, broker_reported_rows: usize) -> Option<String> {
if broker_reported_rows == 0 {
return None;
}
let (broker_form, separate_boxes, filed_boxes) = if tax_year >= DIGITAL_ASSET_8949_FIRST_YEAR {
("1099-DA", "Box G/H/J/K", "Box I/L")
} else {
("1099-B", "Box A/B (ST) / D/E (LT)", "Box C/F")
};
Some(format!(
"⚠ [I5] {broker_reported_rows} disposition(s) occurred on an exchange that MAY have issued \
{broker_form} broker basis reporting — those would belong on a SEPARATE Form 8949 under \
{separate_boxes}. This export files EVERY Bitcoin row under {filed_boxes} (not-reported \
default) and says so; reclassify by hand if you received a {broker_form}."
))
}
fn wants(selected: &[FormArg], f: FormArg) -> bool {
selected.is_empty() || selected.contains(&f)
}
fn sd_part_active(p: &ScheduleDPart) -> bool {
!p.proceeds.is_zero() || !p.cost_basis.is_zero() || !p.gain.is_zero()
}
pub fn export_irs_pdf(
vault_path: &Path,
pp: &Passphrase,
out_dir: &Path,
tax_year: i32,
forms: &[FormArg],
attest: Option<&str>,
) -> Result<IrsPdfReport, CliError> {
let session = Session::open(vault_path, pp)?;
let (events, state, _cfg) = session.load_events_and_project()?;
export_irs_pdf_from_session(&session, &state, &events, out_dir, tax_year, forms, attest)
}
pub(crate) fn export_irs_pdf_from_session(
session: &Session,
state: &btctax_core::state::LedgerState,
events: &[LedgerEvent],
out_dir: &Path,
tax_year: i32,
forms: &[FormArg],
attest: Option<&str>,
) -> Result<IrsPdfReport, CliError> {
if crate::return_inputs::exists(session.conn(), tax_year)? {
let mut report = export_full_return(session, state, events, out_dir, tax_year, attest)?;
report.forms_ignored_full_return = !forms.is_empty();
return Ok(report);
}
promote_export_gate(state, events, Some(tax_year))?;
let watermarked = state.pseudo_active();
if watermarked {
require_attestation(attest)?;
}
let rows = btctax_core::form_8949(state, tax_year);
let totals = btctax_core::schedule_d(state, tax_year);
let printed_8275 = btctax_core::tax::form8275::disclosure_8275(events, state, tax_year)
.map(|d| btctax_core::tax::printed::printed_8275(&d));
if let Some(p) = &printed_8275 {
let cap = btctax_forms::Form8275Map::for_year(tax_year)?.rows.len();
if p.part_i.len() > cap {
return Err(CliError::Usage(format!(
"cannot export {tax_year}: {n} promoted disposal leg(s) each need a Form 8275 Part I \
row, but this revision holds only {cap} — Form 8275 cannot yet paginate beyond {cap} \
rows. File the 8275 manually for {tax_year}, or reduce the number of promoted disposal \
legs filed in {tax_year} (e.g. void one of the promotes) and re-export.",
n = p.part_i.len(),
)));
}
}
if let Some(p) = &printed_8275 {
if let btctax_forms::PartIiCapacity::Overflow(overflow) =
btctax_forms::part_ii_capacity_check(&p.part_ii, tax_year)?
{
return Err(CliError::Usage(part_ii_overflow_message(
tax_year, &overflow,
)));
}
}
let stamp = |bytes: Vec<u8>| -> Result<Vec<u8>, CliError> {
Ok(if watermarked {
btctax_forms::stamp_draft_watermark(&bytes)?
} else {
bytes
})
};
if !btctax_forms::SUPPORTED_YEARS.contains(&tax_year) {
return Err(CliError::FormFill(
btctax_forms::FormsError::UnsupportedYear(tax_year),
));
}
mkdir_out(out_dir)?;
crate::render::write_basis_methodology_txt(out_dir, state, tax_year)?;
crate::render::write_form_8275_txt(out_dir, state, events, tax_year)?;
let experimental_notice_active = btctax_core::experimental::uses_approach_b(events);
let f8949_path = if wants(forms, FormArg::F8949) {
let bytes = stamp(btctax_forms::fill_form_8949(&rows, tax_year)?)?;
let path = out_dir.join("f8949.pdf");
write_bytes_owner_only(&path, &bytes)?;
Some(path)
} else {
None
};
let schedule_d_path = if wants(forms, FormArg::ScheduleD) {
let bytes = stamp(btctax_forms::fill_schedule_d(&totals, tax_year)?)?;
let path = out_dir.join("schedule_d.pdf");
write_bytes_owner_only(&path, &bytes)?;
Some(path)
} else {
None
};
let se_computed = {
let tables = BundledTaxTables::load();
let profile = match session.resolve_screened(state, tax_year, &tables)? {
crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
crate::resolve::ProfileOutcome::Uncomputable { .. } => None, };
profile.and_then(|p| {
tables.table_for(tax_year).and_then(|t| {
compute_se_tax(
state,
tax_year,
p.filing_status,
t,
p.w2_ss_wages,
p.w2_medicare_wages,
p.schedule_c_expenses,
)
.map(|se| (se, p.w2_ss_wages, t.ss_wage_base))
})
})
};
let se_income_without_profile =
se_computed.is_none() && !se_net_income(state, tax_year).is_zero();
let mut schedule_se_path = None;
let mut se_below_floor = false;
let mut se_addl_medicare = None;
if wants(forms, FormArg::ScheduleSe) {
if let Some((se, w2_ss_wages, ss_wage_base)) = se_computed {
if !se.addl.is_zero() {
se_addl_medicare = Some(se.addl);
}
match btctax_forms::fill_schedule_se(&se, w2_ss_wages, ss_wage_base, tax_year)? {
Some(bytes) => {
let bytes = stamp(bytes)?;
let path = out_dir.join("schedule_se.pdf");
write_bytes_owner_only(&path, &bytes)?;
schedule_se_path = Some(path);
}
None => se_below_floor = true, }
}
}
let mut form_8283_path = None;
let mut form_8283_needs_review = false;
let mut form_8283_section_b = None;
if wants(forms, FormArg::Form8283) {
let details = session.donation_details()?;
let rows_8283 = btctax_core::form_8283(state, tax_year, &details);
if let Some(bytes) = btctax_forms::fill_form_8283(&rows_8283, tax_year)? {
form_8283_needs_review = rows_8283.iter().any(|r| r.needs_review);
form_8283_section_b = rows_8283
.iter()
.find_map(|r| r.section)
.map(|s| s == btctax_core::Form8283Section::B);
let bytes = stamp(bytes)?;
let path = out_dir.join("form_8283.pdf");
write_bytes_owner_only(&path, &bytes)?;
form_8283_path = Some(path);
}
}
let mut form_1040_path = None;
let mut form_1040_filled_7a = false;
let mut form_1040_loss = false;
if wants(forms, FormArg::Form1040) {
let da_yes = !rows.is_empty()
|| state
.income_recognized
.iter()
.any(|i| i.recognized_at.year() == tax_year)
|| state
.removals
.iter()
.any(|r| r.removed_at.year() == tax_year);
let inputs = Form1040Inputs {
da_yes,
schedule_d_active: sd_part_active(&totals.st) || sd_part_active(&totals.lt),
schedule_d_line16: totals.st.gain + totals.lt.gain,
};
if let Some(fill) = btctax_forms::fill_form_1040_capgains(&inputs, tax_year)? {
form_1040_filled_7a = fill.filled_7a;
form_1040_loss = fill.loss;
let bytes = stamp(fill.pdf)?;
let path = out_dir.join("form_1040_capgains.pdf");
write_bytes_owner_only(&path, &bytes)?;
form_1040_path = Some(path);
}
}
let mut form_8275_path = None;
if let Some(p) = &printed_8275 {
if let Some(bytes) = btctax_forms::fill_form_8275_slice(p, tax_year)? {
let bytes = stamp(bytes)?;
let path = out_dir.join("form_8275.pdf");
write_bytes_owner_only(&path, &bytes)?;
form_8275_path = Some(path);
}
}
let unresolved_hard = state
.blockers
.iter()
.filter(|b| b.kind.severity() == Severity::Hard)
.count();
Ok(IrsPdfReport {
full_return_paths: Vec::new(),
full_return_manifest: None,
forms_ignored_full_return: false, f8949_path,
schedule_d_path,
tax_year,
unresolved_hard,
broker_reported_rows: btctax_forms::rows_possibly_broker_reported(&rows),
watermarked,
schedule_se_path,
se_below_floor,
se_addl_medicare,
se_income_without_profile,
form_8283_path,
form_8283_needs_review,
form_8283_section_b,
form_1040_path,
form_1040_filled_7a,
form_1040_loss,
form_8275_path,
experimental_notice_active,
})
}
fn part_ii_overflow_message(tax_year: i32, overflow: &btctax_forms::PartIiOverflow) -> String {
format!(
"cannot export {tax_year}: the Form 8275 Part II narrative needs about {rows} single-line \
fields but only {cap} are available (Part II's own line 1 + Part IV's continuation lines) at \
8pt \u{2014} roughly the first {chars} characters of it would fit. The narrative is fixed once \
recorded (the vault is append-only), so shortening it now means voiding the promote(s) whose \
narrative is too long and re-recording with a shorter --part-ii-file. File the 8275 manually \
for {tax_year} instead, or void and re-record, then re-export.",
rows = overflow.rows_needed,
cap = overflow.capacity,
chars = overflow.chars_fit,
)
}
fn write_bytes_owner_only(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
use std::io::Write;
let mut f = fsperms::open_owner_only(path)?;
f.write_all(bytes)?;
f.flush()?;
Ok(())
}
fn mkdir_out(out_dir: &Path) -> Result<(), CliError> {
fsperms::mkdir_owner_only(out_dir)
.map_err(|e| crate::store_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))
}
pub fn backup_key(vault_path: &Path, pp: &Passphrase, out_path: &Path) -> Result<(), CliError> {
Session::open(vault_path, pp)?
.vault()
.backup_key(out_path)
.map_err(|e| crate::store_io_with_path(e, out_path, crate::EXPORT_OUT_HINT))?;
Ok(())
}
fn export_full_return(
session: &Session,
state: &btctax_core::state::LedgerState,
events: &[LedgerEvent],
out_dir: &Path,
tax_year: i32,
attest: Option<&str>,
) -> Result<IrsPdfReport, CliError> {
use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
use btctax_core::tax::tables::{FullReturnTables, TaxTables};
use std::fmt::Write as _;
promote_export_gate(state, events, Some(tax_year))?;
let tables = BundledTaxTables::load();
let fr_tables = BundledFullReturnTables::load();
let (Some(params), Some(table)) = (
fr_tables.full_return_for(tax_year),
tables.table_for(tax_year),
) else {
return Err(CliError::Usage(format!(
"no full-return tables for {tax_year} — the full-return packet needs a supported tax year \
(TY2024)"
)));
};
let ri = crate::return_inputs::get(session.conn(), tax_year)?
.ok_or_else(|| CliError::Usage(format!("no return_inputs stored for {tax_year}")))?;
let refuse = |r: btctax_core::tax::return_refuse::Refusal| {
CliError::Usage(format!(
"the {tax_year} return is not computable [{:?}]: {} — no forms were written",
r.reason, r.detail
))
};
if let Some(r) = btctax_core::tax::return_refuse::screen_inputs(&ri, table, params) {
return Err(refuse(r));
}
if let Some(r) =
btctax_core::tax::return_1040::screen_compute_dependent(&ri, state, tax_year, params)
{
return Err(refuse(r));
}
let ar = btctax_core::tax::return_1040::assemble_absolute(&ri, state, params, table, tax_year);
if let Some(r) = btctax_core::tax::return_1040::screen_absolute(&ri, &ar, params) {
return Err(refuse(r));
}
let watermarked = state.pseudo_active();
if watermarked {
require_attestation(attest)?;
}
let details = session.donation_details()?;
let printed = btctax_core::tax::packet::assemble_printed_return(
&ri, state, &details, &ar, table, tax_year, events,
)
.map_err(|e| {
CliError::Usage(format!("the {tax_year} return cannot be printed: {e}"))
})?;
if let Some(f8275) = &printed.forms.f8275 {
let cap = btctax_forms::Form8275Map::for_year(tax_year)?.rows.len();
if f8275.part_i.len() > cap {
return Err(CliError::Usage(format!(
"cannot export {tax_year}: {n} promoted disposal leg(s) each need a Form 8275 Part I \
row, but this revision holds only {cap} — Form 8275 cannot yet paginate beyond {cap} \
rows. File the 8275 manually for {tax_year}, or reduce the number of promoted disposal \
legs filed in {tax_year} (e.g. void one of the promotes) and re-export.",
n = f8275.part_i.len(),
)));
}
}
if let Some(f8275) = &printed.forms.f8275 {
if let btctax_forms::PartIiCapacity::Overflow(overflow) =
btctax_forms::part_ii_capacity_check(&f8275.part_ii, tax_year)?
{
return Err(CliError::Usage(part_ii_overflow_message(
tax_year, &overflow,
)));
}
}
let packet = btctax_forms::fill_full_return(&printed, tax_year)?;
mkdir_out(out_dir)?;
crate::render::write_basis_methodology_txt(out_dir, state, tax_year)?;
crate::render::write_form_8275_txt(out_dir, state, events, tax_year)?;
let experimental_notice_active = btctax_core::experimental::uses_approach_b(events);
let mut manifest = String::from("# btctax full-return packet — staple in this order\n");
let mut paths: Vec<PathBuf> = Vec::new();
for form in &packet {
let bytes = if watermarked {
btctax_forms::stamp_draft_watermark(&form.bytes)?
} else {
form.bytes.clone()
};
let path = out_dir.join(format!(
"{}_{}.pdf",
form.attachment_sequence.unwrap_or("00"),
form.name
));
write_bytes_owner_only(&path, &bytes)?;
let seq = form.attachment_sequence.unwrap_or("—");
let _ = writeln!(
manifest,
"{seq:>4} {}",
path.file_name().unwrap_or_default().to_string_lossy()
);
paths.push(path);
}
let manifest_path = out_dir.join("manifest.txt");
write_bytes_owner_only(&manifest_path, manifest.as_bytes())?;
let unresolved_hard = state
.blockers
.iter()
.filter(|b| b.kind.severity() == Severity::Hard)
.count();
Ok(IrsPdfReport {
watermarked,
tax_year,
unresolved_hard,
broker_reported_rows: 0,
full_return_paths: paths,
full_return_manifest: Some(manifest_path),
forms_ignored_full_return: false, f8949_path: None,
schedule_d_path: None,
schedule_se_path: None,
se_below_floor: false,
se_addl_medicare: None,
se_income_without_profile: false,
form_8283_path: None,
form_8283_needs_review: printed
.forms
.f8283
.as_ref()
.is_some_and(|r| r.rows().iter().any(|row| row.needs_review)),
form_8283_section_b: printed.forms.f8283.as_ref().map(|r| {
r.rows()
.iter()
.any(|row| row.section == Some(btctax_core::Form8283Section::B))
}),
form_1040_path: None,
form_1040_filled_7a: false,
form_1040_loss: false,
form_8275_path: None,
experimental_notice_active,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn broker_advisory_ty2025_cites_1099da_and_digital_asset_boxes() {
let msg = broker_reporting_advisory(2025, 3).expect("3 broker rows → an advisory");
assert!(msg.contains("1099-DA"), "TY2025 cites the 1099-DA: {msg}");
assert!(msg.contains("Box G/H/J/K"), "separate 8949 boxes: {msg}");
assert!(msg.contains("Box I/L"), "filed-under boxes: {msg}");
assert!(msg.contains('3'), "the row count: {msg}");
assert!(
!msg.contains("1099-B"),
"no pre-2025 1099-B on TY2025: {msg}"
);
assert!(
!msg.contains("Box C/F"),
"no securities C/F on TY2025: {msg}"
);
}
#[test]
fn broker_advisory_pre_2025_cites_1099b_and_securities_boxes() {
let msg = broker_reporting_advisory(2024, 1).expect("1 broker row → an advisory");
assert!(msg.contains("1099-B"), "pre-2025 cites the 1099-B: {msg}");
assert!(msg.contains("Box A/B"), "separate ST securities box: {msg}");
assert!(msg.contains("D/E"), "separate LT securities box: {msg}");
assert!(
msg.contains("Box C/F"),
"filed-under securities boxes: {msg}"
);
assert!(!msg.contains("1099-DA"), "no 1099-DA pre-2025: {msg}");
assert!(!msg.contains("G/H/J/K"), "no digital boxes pre-2025: {msg}");
assert!(
!msg.contains("Box I/L"),
"no digital filed-boxes pre-2025: {msg}"
);
}
#[test]
fn broker_advisory_is_none_without_broker_rows() {
assert!(broker_reporting_advisory(2025, 0).is_none());
assert!(broker_reporting_advisory(2024, 0).is_none());
}
#[test]
fn mkdir_out_collision_names_path_and_hint() {
let dir = tempfile::tempdir().unwrap();
let collide = dir.path().join("collide");
std::fs::write(&collide, b"i am a file, not a directory").unwrap();
let err = mkdir_out(&collide).expect_err("a file collision must error");
match &err {
CliError::PathIo { path, hint, .. } => {
assert!(path.contains("collide"), "names the path: {path}");
assert_eq!(hint, crate::EXPORT_OUT_HINT, "carries the export-out hint");
}
other => panic!("expected PathIo, got {other:?}"),
}
let msg = err.to_string();
assert!(msg.contains("collide"), "Display names the path: {msg}");
assert!(
msg.contains("does not already exist as a file"),
"Display carries the hint content: {msg}"
);
}
}