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, LotMethod, ScheduleDPart, Severity, TaxTables, Usd,
};
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 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 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 (state, _cfg) = session.project()?;
if state.pseudo_active() {
require_attestation(attest)?;
}
let sqlite = session.vault().export_snapshot(out_dir)?; 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,
)?;
let unresolved_hard = state
.blockers
.iter()
.filter(|b| b.kind.severity() == Severity::Hard)
.count();
Ok(ExportReport {
path: sqlite,
unresolved_hard,
})
}
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 full_return_paths: Vec<PathBuf>,
pub full_return_manifest: Option<PathBuf>,
}
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 (state, _cfg) = session.project()?;
if crate::return_inputs::exists(session.conn(), tax_year)? {
return export_full_return(&session, &state, out_dir, tax_year, attest);
}
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 stamp = |bytes: Vec<u8>| -> Result<Vec<u8>, CliError> {
Ok(if watermarked {
btctax_forms::stamp_draft_watermark(&bytes)?
} else {
bytes
})
};
fsperms::mkdir_owner_only(out_dir)?;
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 unresolved_hard = state
.blockers
.iter()
.filter(|b| b.kind.severity() == Severity::Hard)
.count();
Ok(IrsPdfReport {
full_return_paths: Vec::new(),
full_return_manifest: None,
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,
})
}
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(())
}
pub fn backup_key(vault_path: &Path, pp: &Passphrase, out_path: &Path) -> Result<(), CliError> {
Session::open(vault_path, pp)?
.vault()
.backup_key(out_path)?;
Ok(())
}
fn export_full_return(
session: &Session,
state: &btctax_core::state::LedgerState,
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 _;
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,
)
.map_err(|e| {
CliError::Usage(format!("the {tax_year} return cannot be printed: {e}"))
})?;
let packet = btctax_forms::fill_full_return(&printed, tax_year)?;
fsperms::mkdir_owner_only(out_dir)?;
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),
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,
})
}