use crate::{return_inputs, tax_profile, CliError, Session};
use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
use btctax_core::tax::return_inputs::ReturnInputs;
use btctax_core::tax::tables::FullReturnTables;
use btctax_core::{
carryforward_consistency, compute_se_tax, compute_tax_year, schedule_d, se_net_income,
ScheduleDTotals, TaxOutcome, TaxProfile, TaxTables, Usd,
};
use btctax_store::Passphrase;
use std::path::Path;
pub fn set_profile(
vault: &Path,
pp: &Passphrase,
year: i32,
p: TaxProfile,
force: bool,
) -> Result<(), CliError> {
let mut s = Session::open(vault, pp)?;
if !force && return_inputs::exists(s.conn(), year)? {
return Err(CliError::Usage(format!(
"tax year {year} already has full-return inputs (`income import`); a raw tax-profile would be \
ignored (full-return inputs take precedence). Re-run with --force to store it anyway."
)));
}
tax_profile::set(s.conn(), year, &p)?;
s.save()
}
pub fn show_profile(
vault: &Path,
pp: &Passphrase,
year: i32,
) -> Result<Option<TaxProfile>, CliError> {
tax_profile::get(Session::open(vault, pp)?.conn(), year)
}
pub fn import_return_inputs(
vault: &Path,
pp: &Passphrase,
year: i32,
file: &Path,
) -> Result<(), CliError> {
let text = std::fs::read_to_string(file)?;
let mut ri = parse_return_inputs_toml(&text)?;
let mut s = Session::open(vault, pp)?;
crate::input_form_store::coherence_clear_or_refuse(s.conn(), year)?;
if let Some(existing) = return_inputs::get(s.conn(), year)? {
use btctax_core::tax::return_inputs::CarryProvenance;
let mut preserved: Vec<String> = Vec::new();
if ri.charitable_carryover_in.is_empty() {
let computed: Vec<_> = existing
.charitable_carryover_in
.iter()
.filter(|c| c.provenance == CarryProvenance::Computed)
.cloned()
.collect();
if !computed.is_empty() {
preserved.push(format!("{} charitable carryover item(s)", computed.len()));
ri.charitable_carryover_in = computed;
}
}
if ri.qbi.reit_ptp_carryforward_in.is_zero()
&& existing.qbi.reit_ptp_carryforward_in > rust_decimal::Decimal::ZERO
&& existing.qbi.reit_ptp_carryforward_in_provenance == CarryProvenance::Computed
{
preserved.push(format!(
"QBI REIT/PTP carryforward ${:.2}",
existing.qbi.reit_ptp_carryforward_in
));
ri.qbi = existing.qbi.clone();
}
if !preserved.is_empty() {
eprintln!(
"note: kept the computed carryover already on the {year} row ({}) — your TOML did not \
supply one. To replace it, put the carryover in the TOML (it then counts as user-entered), \
or re-run `report --tax-year {} --write-carryover`.",
preserved.join("; "),
year - 1
);
}
}
return_inputs::set(s.conn(), year, &ri)?;
s.save()
}
fn parse_return_inputs_toml(text: &str) -> Result<ReturnInputs, CliError> {
let value: toml::Value = toml::from_str(text)
.map_err(|e| CliError::Usage(format!("invalid ReturnInputs TOML: {e}")))?;
let mut ignored: Vec<String> = Vec::new();
let ri: ReturnInputs = serde_ignored::deserialize(value, |path| ignored.push(path.to_string()))
.map_err(|e| CliError::Usage(format!("invalid ReturnInputs TOML: {e}")))?;
if !ignored.is_empty() {
return Err(CliError::Usage(format!(
"unknown key(s) in the ReturnInputs TOML: {}. btctax does not honor these — likely a typo or a \
field removed in this version (e.g. `hsa_present` was RENAMED to `sch1.hsa_activity`; \
`box13_retirement_plan` and `ssn_valid_for_employment` were REMOVED). Fix or delete them, then \
re-run `btctax income import` — a silently-ignored key would drop data you meant to enter.",
ignored.join(", ")
)));
}
Ok(ri)
}
fn mask_ssn(ssn: &str) -> String {
if ssn.is_empty() {
return String::new();
}
let digits: String = ssn.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() >= 4 {
format!("***-**-{}", &digits[digits.len() - 4..])
} else {
"***-**-****".to_string()
}
}
fn mask_pii(ri: &ReturnInputs) -> ReturnInputs {
let mut m = ri.clone();
m.header.taxpayer.ssn = mask_ssn(&m.header.taxpayer.ssn);
if let Some(sp) = m.header.spouse.as_mut() {
sp.ssn = mask_ssn(&sp.ssn);
}
for d in &mut m.header.dependents {
d.ssn = mask_ssn(&d.ssn);
}
if m.header.ip_pin.is_some() {
m.header.ip_pin = Some("***".to_string());
}
m
}
pub fn clear_return_inputs(vault: &Path, pp: &Passphrase, year: i32) -> Result<bool, CliError> {
let mut s = Session::open(vault, pp)?;
crate::input_form_store::coherence_clear_or_refuse(s.conn(), year)?;
let removed = return_inputs::delete(s.conn(), year)?;
s.save()?;
Ok(removed)
}
pub fn show_return_inputs(
vault: &Path,
pp: &Passphrase,
year: i32,
) -> Result<Option<String>, CliError> {
let ri = return_inputs::get(Session::open(vault, pp)?.conn(), year)?;
ri.map(|ri| {
let mkerr = |e: serde_json::Error| CliError::BadConfigValue {
key: format!("return_inputs[{year}]"),
value: e.to_string(),
};
let mut val = serde_json::to_value(mask_pii(&ri)).map_err(mkerr)?;
format_dobs_readable(&mut val); serde_json::to_string_pretty(&val).map_err(mkerr)
})
.transpose()
}
fn format_dobs_readable(v: &mut serde_json::Value) {
use time::macros::format_description;
match v {
serde_json::Value::Object(map) => {
for (k, val) in map.iter_mut() {
if k == "date_of_birth" {
let readable = val.as_array().filter(|a| a.len() == 2).and_then(|a| {
let y = a[0].as_i64()? as i32;
let o = a[1].as_u64()? as u16;
let d = time::Date::from_ordinal_date(y, o).ok()?;
d.format(&format_description!("[month]/[day]/[year]")).ok()
});
if let Some(s) = readable {
*val = serde_json::Value::String(s);
continue;
}
}
format_dobs_readable(val);
}
}
serde_json::Value::Array(arr) => arr.iter_mut().for_each(format_dobs_readable),
_ => {}
}
}
#[derive(Debug)]
pub struct TaxYearReport {
pub outcome: TaxOutcome,
pub advisory: Option<String>,
pub schedule_d: ScheduleDTotals,
pub gift_advisory: Option<String>,
pub schedule_se: Option<String>,
pub donation_appraisal: Option<String>,
pub tranche_advisory: Option<String>,
pub dual_report: Option<String>,
pub pseudo_contributed: crate::render::PseudoDisclosure,
}
pub fn report_tax_year(
vault: &Path,
pp: &Passphrase,
year: i32,
prior_taxable_gifts: Usd,
) -> Result<TaxYearReport, CliError> {
let s = Session::open(vault, pp)?;
let (events, state, cfg) = s.load_events_and_project()?;
let tables = BundledTaxTables::load();
let fr_tables = BundledFullReturnTables::load();
let (profile, provenance) = match crate::resolve::resolve_and_screen(
s.conn(),
&state,
year,
cfg.pseudo_reconcile,
fr_tables.full_return_for(year),
tables.table_for(year),
)? {
crate::resolve::ProfileOutcome::Uncomputable { detail } => {
return Err(CliError::Usage(detail))
}
crate::resolve::ProfileOutcome::Ready {
profile,
provenance,
} => (profile, provenance),
};
let outcome = compute_tax_year(&events, &state, year, profile.as_ref(), &tables);
let pseudo_contributed = if state.pseudo_active() {
crate::render::PseudoDisclosure::Synthetic
} else if provenance == crate::resolve::Provenance::PseudoPlaceholder {
crate::render::PseudoDisclosure::Placeholder
} else {
crate::render::PseudoDisclosure::None
};
let dual_report: Option<String> = if provenance == crate::resolve::Provenance::ReturnInputs {
match (
crate::return_inputs::get(s.conn(), year)?,
fr_tables.full_return_for(year),
tables.table_for(year),
) {
(Some(ri), Some(params), Some(table)) => {
let ar = btctax_core::assemble_absolute(&ri, &state, params, table, year);
match btctax_core::screen_absolute(&ri, &ar, params) {
Some(refusal) => Some(format!(
"\n═══ Absolute filed return (Form 1040) — tax year {year} ═══\n \
Profile source: {}\n NOT COMPUTABLE [{:?}]: {}\n",
crate::render::provenance_label(provenance),
refusal.reason,
refusal.detail
)),
None => {
let details = s.donation_details()?;
let printed = btctax_core::tax::packet::assemble_printed_forms(
&ri, &state, &details, &ar, table, year, &events,
);
let mut block = crate::render::render_dual_report(
year,
&ar,
&printed,
&outcome,
provenance,
pseudo_contributed,
);
let advs = btctax_core::tax::advisories::advisories_for(
&ri, &state, &ar, params, year,
);
block.push_str(&crate::render::render_advisories(&advs));
Some(block)
}
}
}
_ => {
debug_assert!(
false,
"ReturnInputs provenance but missing inputs/params/table for year {year}"
);
None
}
}
} else {
None
};
let sched_d = schedule_d(&state, year);
let gift_advisory =
crate::render::render_gift_advisory(&state, year, prior_taxable_gifts, &tables);
let schedule_se = match profile.as_ref() {
Some(p) => {
let gross_se = se_net_income(&state, year);
let table_opt = tables.table_for(year);
let table_present = table_opt.is_some();
let se_result = table_opt.and_then(|t| {
compute_se_tax(
&state,
year,
p.filing_status,
t,
p.w2_ss_wages,
p.w2_medicare_wages,
p.schedule_c_expenses,
)
});
crate::render::render_schedule_se(
year,
se_result.as_ref(),
gross_se,
table_present,
p.schedule_c_expenses,
p.w2_ss_wages,
p.w2_medicare_wages,
)
}
None => None,
};
let donation_appraisal_advisory =
crate::render::render_donation_appraisal_advisory(&state, year);
let tranche_advisory = btctax_core::conservative::tranche_report_advisory(
&state,
&events,
s.prices(),
&cfg,
year,
profile.as_ref(),
&tables,
);
let advisory: Option<String> = if let Some(p) = &profile {
let prior_profile = match s.resolve_screened(&state, year - 1, &tables)? {
crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
crate::resolve::ProfileOutcome::Uncomputable { .. } => None,
};
if let Some(prev_p) = prior_profile {
let prior_out = compute_tax_year(&events, &state, year - 1, Some(&prev_p), &tables);
if let TaxOutcome::Computed(prev) = prior_out {
carryforward_consistency(
Some(&prev.carryforward_out),
&p.capital_loss_carryforward_in,
)
} else {
None
}
} else {
None
}
} else {
None
};
Ok(TaxYearReport {
outcome,
advisory,
schedule_d: sched_d,
gift_advisory,
schedule_se,
donation_appraisal: donation_appraisal_advisory,
tranche_advisory,
dual_report,
pseudo_contributed,
})
}
pub fn write_back_carryover(
vault: &Path,
pp: &Passphrase,
year: i32,
force: bool,
) -> Result<String, CliError> {
let mut s = Session::open(vault, pp)?;
crate::input_form_store::coherence_clear_or_refuse(s.conn(), year + 1)?;
let (events, state, cfg) = s.load_events_and_project()?;
let tables = BundledTaxTables::load();
let fr_tables = BundledFullReturnTables::load();
let (Some(params), Some(table)) = (fr_tables.full_return_for(year), tables.table_for(year))
else {
return Err(CliError::Usage(format!(
"no full-return tables for {year} — carryover write-back needs a supported tax year (TY2024)"
)));
};
let (profile, provenance) = match crate::resolve::resolve_and_screen(
s.conn(),
&state,
year,
cfg.pseudo_reconcile,
Some(params),
Some(table),
)? {
crate::resolve::ProfileOutcome::Uncomputable { detail } => {
return Err(CliError::Usage(detail))
}
crate::resolve::ProfileOutcome::Ready {
profile,
provenance,
} => (profile, provenance),
};
if provenance != crate::resolve::Provenance::ReturnInputs {
return Err(CliError::Usage(format!(
"carryover write-back needs full-return inputs for {year} (`income import`); the resolved \
profile source is {provenance:?}"
)));
}
if state.pseudo_active() {
return Err(CliError::Usage(format!(
"carryover write-back REFUSED for {year}: pseudo-reconcile mode is contributing synthetic \
default(s), so the derived carryover is an ESTIMATE — persisting it as {next}'s real input \
would launder a deliberately-synthetic figure. Resolve the pseudo entries (or turn the mode \
off) first.",
next = year + 1
)));
}
if let btctax_core::TaxOutcome::NotComputable(b) =
compute_tax_year(&events, &state, year, profile.as_ref(), &tables)
{
return Err(CliError::Usage(format!(
"carryover write-back REFUSED for {year}: the crypto-delta ledger is NOT COMPUTABLE [{:?}]: {} \
— a carryover from an unanswerable ledger must not be written into {next}'s inputs.",
b.kind,
b.detail,
next = year + 1
)));
}
let ri = crate::return_inputs::get(s.conn(), year)?
.ok_or_else(|| CliError::Usage(format!("no return_inputs stored for {year}")))?;
let ar = btctax_core::assemble_absolute(&ri, &state, params, table, year);
if let Some(refusal) = btctax_core::screen_absolute(&ri, &ar, params) {
return Err(CliError::Usage(format!(
"the {year} absolute return is not computable [{:?}]: {} — carryover not written",
refusal.reason, refusal.detail
)));
}
let next = crate::return_inputs::get(s.conn(), year + 1)?.ok_or_else(|| {
CliError::Usage(format!(
"year {next} has no full-return inputs yet — the carryover is written onto that row, so import \
it first (`income import --year {next} --file <toml>`) and then re-run `--write-carryover`. \
(Creating the row here would shadow any stored tax-profile for {next} and make it uncomputable \
in this version, which supports full returns for TY2024 only.)",
next = year + 1
))
})?;
let updated =
btctax_core::apply_carryover_writeback(&ar, next, force).map_err(CliError::Usage)?;
crate::return_inputs::set(s.conn(), year + 1, &updated)?;
s.save()?;
Ok(format!(
"carryover written back to {}: {} charitable carryover item(s); QBI REIT/PTP carryforward ${:.2}",
year + 1,
updated.charitable_carryover_in.len(),
updated.qbi.reit_ptp_carryforward_in
))
}
#[cfg(test)]
mod tests {
use super::*;
use btctax_core::tax::return_inputs::CharitableClass;
use btctax_core::FilingStatus;
use rust_decimal_macros::dec;
fn tmp_vault() -> (tempfile::TempDir, std::path::PathBuf, Passphrase) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vault.pgp");
{
let _ = Session::create(&path, &Passphrase::new("test-pass".into())).unwrap();
}
(dir, path, Passphrase::new("test-pass".into()))
}
#[test]
fn income_clear_refuses_a_parked_draft_and_preserves_it() {
let (_dir, path, pp) = tmp_vault();
let ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
{
let mut s = Session::open(&path, &pp).unwrap();
crate::input_form_store::set_draft_row(s.conn(), 2024, &ri, true).unwrap(); s.save().unwrap();
}
let err = clear_return_inputs(&path, &pp, 2024).unwrap_err();
assert!(
matches!(err, CliError::ParkedDraftBlocksWrite { year: 2024 }),
"income clear must refuse a parked-draft year, got {err:?}"
);
let s = Session::open(&path, &pp).unwrap();
assert!(
crate::input_form_store::draft_exists(s.conn(), 2024).unwrap(),
"a refused clear must leave the parked draft intact"
);
}
#[test]
fn return_inputs_toml_parses() {
let text = r#"
filing_status = "Mfj"
[[w2s]]
owner = "taxpayer"
employer = "ACME"
box1_wages = "82000"
box2_fed_withheld = "9100"
box5_medicare_wages = "82000"
[[div_1099]]
payer = "Vanguard"
box1a_ordinary = "3400"
box1b_qualified = "3100"
[schedule_a]
mortgage_interest_1098 = "11200"
salt_real_estate = "6800"
[[schedule_a.charitable]]
class = "cash60"
amount = "2500"
[payments]
estimated_tax_payments = "6000"
"#;
let ri = parse_return_inputs_toml(text).unwrap();
assert_eq!(ri.filing_status, FilingStatus::Mfj);
assert_eq!(ri.w2s.len(), 1);
assert_eq!(ri.w2s[0].box1_wages, dec!(82000));
assert_eq!(ri.w2s[0].box5_medicare_wages, dec!(82000));
assert_eq!(ri.div_1099[0].box1b_qualified, dec!(3100));
let a = ri.schedule_a.as_ref().unwrap();
assert_eq!(a.mortgage_interest_1098, dec!(11200));
assert_eq!(a.charitable[0].class, CharitableClass::Cash60);
assert_eq!(a.charitable[0].amount, dec!(2500));
assert_eq!(ri.payments.estimated_tax_payments, dec!(6000));
}
#[test]
fn mask_ssn_and_pii_redacts() {
assert_eq!(mask_ssn("123-45-6789"), "***-**-6789");
assert_eq!(mask_ssn("123456789"), "***-**-6789");
assert_eq!(mask_ssn(""), "");
assert_eq!(mask_ssn("12"), "***-**-****");
let mut ri = ReturnInputs::default();
ri.header.taxpayer.ssn = "123-45-6789".into();
ri.header.ip_pin = Some("999999".into());
ri.header.spouse = Some(btctax_core::tax::return_inputs::Person {
ssn: "987-65-4321".into(),
..Default::default()
});
ri.header.dependents = vec![btctax_core::tax::return_inputs::Dependent {
ssn: "111-22-3333".into(),
..Default::default()
}];
let masked = mask_pii(&ri);
assert_eq!(masked.header.taxpayer.ssn, "***-**-6789");
assert_eq!(masked.header.spouse.as_ref().unwrap().ssn, "***-**-4321");
assert_eq!(masked.header.dependents[0].ssn, "***-**-3333");
assert_eq!(masked.header.ip_pin.as_deref(), Some("***"));
assert_eq!(ri.header.taxpayer.ssn, "123-45-6789"); assert_eq!(ri.header.spouse.as_ref().unwrap().ssn, "987-65-4321"); }
#[test]
fn bad_toml_is_typed_error() {
assert!(matches!(
parse_return_inputs_toml("not = = toml").unwrap_err(),
CliError::Usage(_)
));
}
#[test]
fn income_import_rejects_unknown_toml_keys_naming_each() {
let text = r#"
filing_status = "Single"
hsa_present = false
[[w2s]]
owner = "taxpayer"
employer = "ACME"
box1_wages = "50000"
box2_fed_withheld = "8000"
box13_retirement_plan = true
"#;
let err = parse_return_inputs_toml(text).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("hsa_present"),
"must name the renamed key: {msg}"
);
assert!(
msg.contains("box13_retirement_plan"),
"must name the deleted dead field so a transcribed W-2 box 13 can't silently vanish: {msg}"
);
}
}