use crate::return_inputs::SCHEMA_VERSION;
use crate::{CliError, Session};
use btctax_core::tax::return_inputs::ReturnInputs;
use btctax_core::tax::return_refuse::{screen_inputs, Refusal};
use btctax_core::tax::tables::{FullReturnParams, TaxTable};
use rusqlite::Connection;
pub fn init_draft_table(conn: &Connection) -> Result<(), CliError> {
conn.execute(
"CREATE TABLE IF NOT EXISTS return_inputs_draft (\
year INTEGER PRIMARY KEY, inputs_json TEXT NOT NULL, \
schema_version INTEGER NOT NULL DEFAULT 0, parked INTEGER NOT NULL DEFAULT 0)",
[],
)?;
Ok(())
}
pub(crate) struct DraftRow {
pub ri: ReturnInputs,
pub version: i64,
pub parked: bool,
}
pub(crate) fn get_draft_row(conn: &Connection, year: i32) -> Result<Option<DraftRow>, CliError> {
init_draft_table(conn)?;
let row = conn.query_row(
"SELECT inputs_json, schema_version, parked FROM return_inputs_draft WHERE year=?1",
[year],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)),
);
match row {
Ok((json, version, parked)) => {
let ri: ReturnInputs = serde_json::from_str(&json).map_err(|e| CliError::BadConfigValue {
key: format!("return_inputs_draft[{year}]"),
value: format!("invalid JSON: {e}"),
})?;
Ok(Some(DraftRow { ri, version, parked: parked != 0 }))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub(crate) fn set_draft_row(
conn: &Connection,
year: i32,
ri: &ReturnInputs,
parked: bool,
) -> Result<(), CliError> {
init_draft_table(conn)?;
let j = serde_json::to_string(ri).map_err(|e| CliError::BadConfigValue {
key: format!("return_inputs_draft[{year}]"),
value: format!("could not serialize: {e}"),
})?;
conn.execute(
"INSERT INTO return_inputs_draft(year,inputs_json,schema_version,parked) VALUES(?1,?2,?3,?4) \
ON CONFLICT(year) DO UPDATE SET inputs_json=?2, schema_version=?3, parked=?4",
rusqlite::params![year, j, SCHEMA_VERSION, parked as i64],
)?;
Ok(())
}
pub(crate) fn delete_draft(conn: &Connection, year: i32) -> Result<bool, CliError> {
init_draft_table(conn)?;
Ok(conn.execute("DELETE FROM return_inputs_draft WHERE year=?1", [year])? > 0)
}
pub fn draft_exists(conn: &Connection, year: i32) -> Result<bool, CliError> {
init_draft_table(conn)?;
Ok(conn
.query_row("SELECT 1 FROM return_inputs_draft WHERE year=?1", [year], |_| Ok(()))
.is_ok())
}
pub(crate) fn parked_flag(conn: &Connection, year: i32) -> Result<Option<bool>, CliError> {
init_draft_table(conn)?;
match conn.query_row(
"SELECT parked FROM return_inputs_draft WHERE year=?1",
[year],
|r| r.get::<_, i64>(0),
) {
Ok(p) => Ok(Some(p != 0)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn save_draft(sess: &mut Session, year: i32, ri: &ReturnInputs) -> Result<(), CliError> {
let parked = parked_flag(sess.conn(), year)?.unwrap_or(false); set_draft_row(sess.conn(), year, ri, parked)?;
sess.save()?; Ok(())
}
pub enum Loaded {
Draft { ri: ReturnInputs, parked: bool },
Committed(ReturnInputs),
Fresh,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StaleNote {
pub year: i32,
pub found: i64,
pub expected: i64,
}
impl std::fmt::Display for StaleNote {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"discarded a stale draft for {} (schema v{}, expected v{})",
self.year, self.found, self.expected
)
}
}
pub fn load(conn: &Connection, year: i32) -> Result<(Loaded, Option<StaleNote>), CliError> {
if let Some(d) = get_draft_row(conn, year)? {
if d.version != SCHEMA_VERSION {
if d.parked {
return Err(CliError::StaleParkedDraft {
year,
found: d.version,
expected: SCHEMA_VERSION,
});
}
delete_draft(conn, year)?;
let note = StaleNote {
year,
found: d.version,
expected: SCHEMA_VERSION,
};
return Ok((committed_or_fresh(conn, year)?, Some(note)));
} else {
return Ok((
Loaded::Draft {
ri: d.ri,
parked: d.parked,
},
None,
));
}
}
Ok((committed_or_fresh(conn, year)?, None))
}
fn committed_or_fresh(conn: &Connection, year: i32) -> Result<Loaded, CliError> {
match crate::return_inputs::get(conn, year)? {
Some(ri) => Ok(Loaded::Committed(ri)),
None => Ok(Loaded::Fresh),
}
}
pub fn coherence_clear_or_refuse(conn: &Connection, year: i32) -> Result<(), CliError> {
match parked_flag(conn, year)? {
None => Ok(()),
Some(true) => Err(CliError::ParkedDraftBlocksWrite { year }), Some(false) => {
if let Some(d) = get_draft_row(conn, year)? {
if d.ri != ReturnInputs::default() {
eprintln!(
"note: superseding a work-in-progress draft for {year} with this write."
);
}
}
delete_draft(conn, year)?;
Ok(())
}
}
}
pub enum CommitOutcome {
Committed,
Refused(Refusal),
NoTables,
}
pub fn commit(
sess: &mut Session,
year: i32,
ri: &ReturnInputs,
table: Option<&TaxTable>,
params: Option<&FullReturnParams>,
) -> Result<CommitOutcome, CliError> {
let (Some(table), Some(params)) = (table, params) else {
return Ok(CommitOutcome::NoTables); };
if table.year != year || params.year != year {
return Ok(CommitOutcome::NoTables);
}
if let Some(refusal) = screen_inputs(ri, table, params) {
return Ok(CommitOutcome::Refused(refusal)); }
let snap = sess.snapshot()?;
crate::return_inputs::set(sess.conn(), year, ri)?;
delete_draft(sess.conn(), year)?;
if let Err(e) = sess.save() {
sess.restore(&snap)?; return Err(e);
}
Ok(CommitOutcome::Committed)
}
pub fn park_to_profile(sess: &mut Session, year: i32) -> Result<(), CliError> {
let Some(ri) = crate::return_inputs::get(sess.conn(), year)? else {
return Err(CliError::Usage(format!(
"no committed return to park for {year}"
)));
};
if parked_flag(sess.conn(), year)? == Some(false) {
return Err(CliError::Usage(format!(
"year {year} has a work-in-progress draft; finish or discard it before switching to the tax-profile"
)));
}
let snap = sess.snapshot()?;
set_draft_row(sess.conn(), year, &ri, true)?; crate::return_inputs::delete(sess.conn(), year)?; if let Err(e) = sess.save() {
sess.restore(&snap)?; return Err(e);
}
Ok(())
}
pub enum ActiveSource {
FullReturn,
TaxProfile,
Neither,
}
pub fn active_source(conn: &Connection, year: i32) -> Result<ActiveSource, CliError> {
if crate::return_inputs::exists(conn, year)? {
return Ok(ActiveSource::FullReturn);
}
if crate::tax_profile::years(conn)?.contains(&year) {
return Ok(ActiveSource::TaxProfile);
}
Ok(ActiveSource::Neither)
}
pub fn shadows_profile(conn: &Connection, year: i32) -> Result<bool, CliError> {
Ok(crate::tax_profile::years(conn)?.contains(&year))
}
pub fn discard_parked_draft(sess: &mut Session, year: i32) -> Result<(), CliError> {
if parked_flag(sess.conn(), year)? != Some(true) {
return Err(CliError::Usage(format!(
"year {year} has no parked draft to discard"
)));
}
let snap = sess.snapshot()?;
delete_draft(sess.conn(), year)?;
if let Err(e) = sess.save() {
sess.restore(&snap)?;
return Err(e);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use btctax_core::tax::return_inputs::ReturnInputs;
use btctax_core::tax::types::FilingStatus;
use btctax_store::Passphrase;
use rusqlite::Connection;
fn pp() -> Passphrase {
Passphrase::new("test-pass".into())
}
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, &pp()).unwrap(); }
(dir, path, pp())
}
#[test]
fn save_draft_preserves_parked_and_reaches_disk() {
let (_dir, path, pp) = tmp_vault();
let ri_a = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
{
let mut sess = Session::open(&path, &pp).unwrap();
set_draft_row(sess.conn(), 2024, &ri_a, true).unwrap();
let ri_b = ReturnInputs {
filing_status: FilingStatus::Mfj,
..Default::default()
};
save_draft(&mut sess, 2024, &ri_b).unwrap();
assert_eq!(
parked_flag(sess.conn(), 2024).unwrap(),
Some(true),
"NI-1: parked survives an edit"
);
}
let sess2 = Session::open(&path, &pp).unwrap();
let row = get_draft_row(sess2.conn(), 2024).unwrap().unwrap();
assert_eq!(row.ri.filing_status, FilingStatus::Mfj);
assert!(row.parked);
}
#[test]
fn save_draft_on_fresh_year_is_unparked() {
let (_dir, path, pp) = tmp_vault();
let mut sess = Session::open(&path, &pp).unwrap();
save_draft(&mut sess, 2024, &ReturnInputs::default()).unwrap();
assert_eq!(parked_flag(sess.conn(), 2024).unwrap(), Some(false));
}
#[test]
fn load_precedence_draft_then_committed_then_fresh() {
let conn = Connection::open_in_memory().unwrap();
crate::return_inputs::init_table(&conn).unwrap();
init_draft_table(&conn).unwrap();
let (loaded, note) = load(&conn, 2024).unwrap();
assert!(matches!(loaded, Loaded::Fresh));
assert!(note.is_none(), "no stale discard on a fresh year");
let cri = ReturnInputs { filing_status: FilingStatus::HoH, ..Default::default() };
crate::return_inputs::set(&conn, 2024, &cri).unwrap();
let (loaded, note) = load(&conn, 2024).unwrap();
assert!(matches!(loaded, Loaded::Committed(r) if r.filing_status == FilingStatus::HoH));
assert!(note.is_none(), "no stale discard on the committed path");
let dri = ReturnInputs { filing_status: FilingStatus::Mfj, ..Default::default() };
set_draft_row(&conn, 2024, &dri, false).unwrap();
let (loaded, note) = load(&conn, 2024).unwrap();
assert!(matches!(loaded, Loaded::Draft { ri, parked: false } if ri.filing_status == FilingStatus::Mfj));
assert!(note.is_none(), "no stale discard on a version-current draft");
}
#[test]
fn load_discards_stale_wip_but_refuses_stale_parked() {
let conn = Connection::open_in_memory().unwrap();
init_draft_table(&conn).unwrap();
let ri = ReturnInputs { filing_status: FilingStatus::Single, ..Default::default() };
let j = serde_json::to_string(&ri).unwrap();
conn.execute("INSERT INTO return_inputs_draft(year,inputs_json,schema_version,parked) VALUES(2024,?1,0,0)", [&j]).unwrap();
let (loaded, note) = load(&conn, 2024).unwrap();
assert!(matches!(loaded, Loaded::Fresh));
assert_eq!(
note,
Some(StaleNote { year: 2024, found: 0, expected: SCHEMA_VERSION }),
"the stale-WIP discard returns the note (not an eprintln!)"
);
assert!(!draft_exists(&conn, 2024).unwrap(), "stale WIP is discarded");
conn.execute("INSERT INTO return_inputs_draft(year,inputs_json,schema_version,parked) VALUES(2025,?1,0,1)", [&j]).unwrap();
assert!(matches!(load(&conn, 2025), Err(CliError::StaleParkedDraft { year: 2025, found: 0, .. })));
assert!(draft_exists(&conn, 2025).unwrap(), "stale parked is preserved, not discarded");
}
fn clean_screened_ri() -> ReturnInputs {
let mut ri = ReturnInputs {
filing_status: FilingStatus::Single,
header: btctax_core::tax::testonly::not_a_dependent(),
..Default::default()
};
btctax_core::tax::testonly::answer_all_live_declarations(&mut ri);
ri
}
#[test]
fn commit_non2024_is_notables_and_writes_nothing() {
let (_dir, path, pp) = tmp_vault();
let mut sess = Session::open(&path, &pp).unwrap();
let ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
set_draft_row(sess.conn(), 2099, &ri, false).unwrap();
let out = commit(&mut sess, 2099, &ri, None, None).unwrap();
assert!(matches!(out, CommitOutcome::NoTables));
assert!(
!crate::return_inputs::exists(sess.conn(), 2099).unwrap(),
"NoTables writes no committed row"
);
assert!(
draft_exists(sess.conn(), 2099).unwrap(),
"NoTables leaves the draft"
);
}
#[test]
fn commit_clean_sets_row_and_deletes_draft_refused_writes_nothing() {
use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
use btctax_core::tax::tables::FullReturnTables;
use btctax_core::TaxTables;
let (_dir, path, pp) = tmp_vault();
let tables = BundledTaxTables::load(); let fr = BundledFullReturnTables::load();
let (t, p) = (
tables.table_for(2024).unwrap(),
fr.full_return_for(2024).unwrap(),
); let mut sess = Session::open(&path, &pp).unwrap();
let clean = clean_screened_ri();
set_draft_row(sess.conn(), 2024, &clean, false).unwrap();
assert!(matches!(
commit(&mut sess, 2024, &clean, Some(t), Some(p)).unwrap(),
CommitOutcome::Committed
));
assert!(
crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
"clean commit writes the row"
);
assert!(
!draft_exists(sess.conn(), 2024).unwrap(),
"clean commit deletes the draft"
);
let refused = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
}; set_draft_row(sess.conn(), 2024, &refused, false).unwrap();
assert!(matches!(
commit(&mut sess, 2024, &refused, Some(t), Some(p)).unwrap(),
CommitOutcome::Refused(_)
));
assert!(
crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
"a refused commit does not delete the earlier committed row"
);
assert!(
draft_exists(sess.conn(), 2024).unwrap(),
"a refused commit leaves the draft"
);
}
#[test]
fn commit_refuses_tables_for_a_different_year_and_writes_nothing() {
use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
use btctax_core::tax::tables::FullReturnTables;
use btctax_core::TaxTables;
let (_dir, path, pp) = tmp_vault();
let tables = BundledTaxTables::load(); let fr = BundledFullReturnTables::load();
let (t2024, p2024) = (
tables.table_for(2024).unwrap(),
fr.full_return_for(2024).unwrap(),
);
let mut sess = Session::open(&path, &pp).unwrap();
let clean = clean_screened_ri();
let out = commit(&mut sess, 2025, &clean, Some(t2024), Some(p2024)).unwrap();
assert!(
matches!(out, CommitOutcome::NoTables),
"tables for a different year → NoTables, not a committed write"
);
assert!(
!crate::return_inputs::exists(sess.conn(), 2025).unwrap(),
"the table-less year is never poisoned with a committed row"
);
}
#[test]
fn coherence_clears_wip_but_refuses_parked() {
let conn = Connection::open_in_memory().unwrap();
init_draft_table(&conn).unwrap();
let ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
set_draft_row(&conn, 2024, &ri, false).unwrap();
coherence_clear_or_refuse(&conn, 2024).unwrap();
assert!(
!draft_exists(&conn, 2024).unwrap(),
"coherence clears a WIP draft"
);
set_draft_row(&conn, 2025, &ri, true).unwrap();
let err = coherence_clear_or_refuse(&conn, 2025).unwrap_err();
assert!(matches!(err, CliError::ParkedDraftBlocksWrite { year: 2025 }));
let msg = err.to_string();
assert!(
msg.contains("use full return") && msg.contains("discard parked draft"),
"M-d: names both exits"
);
assert!(
draft_exists(&conn, 2025).unwrap(),
"a parked draft is never silently destroyed"
);
coherence_clear_or_refuse(&conn, 2030).unwrap();
}
#[test]
fn park_stashes_then_deletes_committed_atomically() {
let (_dir, path, pp) = tmp_vault();
let mut sess = Session::open(&path, &pp).unwrap();
let ri = ReturnInputs {
filing_status: FilingStatus::Mfj,
..Default::default()
};
crate::return_inputs::set(sess.conn(), 2024, &ri).unwrap();
park_to_profile(&mut sess, 2024).unwrap();
assert!(
!crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
"park deletes the committed row"
);
let d = get_draft_row(sess.conn(), 2024).unwrap().unwrap();
assert!(
d.parked && d.ri.filing_status == FilingStatus::Mfj,
"park stashes the row as parked"
);
drop(sess);
let s2 = Session::open(&path, &pp).unwrap();
assert!(get_draft_row(s2.conn(), 2024).unwrap().unwrap().parked);
assert!(
!crate::return_inputs::exists(s2.conn(), 2024).unwrap(),
"the committed-row DELETE also reached disk, not just the stash"
);
}
#[test]
fn park_refuses_without_committed_row_and_on_any_wip() {
let (_dir, path, pp) = tmp_vault();
let mut sess = Session::open(&path, &pp).unwrap();
assert!(park_to_profile(&mut sess, 2024).is_err(), "nothing to park");
let ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
crate::return_inputs::set(sess.conn(), 2024, &ri).unwrap();
set_draft_row(sess.conn(), 2024, &ri, false).unwrap(); assert!(
park_to_profile(&mut sess, 2024).is_err(),
"clean-state gate: won't clobber a WIP draft"
);
assert!(
crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
"a refused park leaves the committed row"
);
}
fn sample_profile() -> btctax_core::TaxProfile {
use btctax_core::{Carryforward, TaxProfile};
use rust_decimal_macros::dec;
TaxProfile {
filing_status: FilingStatus::Mfj,
ordinary_taxable_income: dec!(120000),
magi_excluding_crypto: dec!(130000),
qualified_dividends_and_other_pref_income: dec!(0),
other_net_capital_gain: dec!(0),
capital_loss_carryforward_in: Carryforward { short: dec!(0), long: dec!(0) },
w2_ss_wages: dec!(0),
w2_medicare_wages: dec!(0),
schedule_c_expenses: dec!(0),
}
}
#[test]
fn active_source_follows_resolve_precedence() {
let conn = Connection::open_in_memory().unwrap();
crate::return_inputs::init_table(&conn).unwrap();
crate::tax_profile::init_table(&conn).unwrap();
assert!(matches!(active_source(&conn, 2024).unwrap(), ActiveSource::Neither));
crate::tax_profile::set(&conn, 2024, &sample_profile()).unwrap();
assert!(matches!(active_source(&conn, 2024).unwrap(), ActiveSource::TaxProfile));
assert!(shadows_profile(&conn, 2024).unwrap());
crate::return_inputs::set(&conn, 2024, &ReturnInputs::default()).unwrap();
assert!(matches!(active_source(&conn, 2024).unwrap(), ActiveSource::FullReturn));
}
#[test]
fn discard_parked_draft_only_deletes_a_parked_row() {
let (_dir, path, pp) = tmp_vault();
let mut sess = Session::open(&path, &pp).unwrap();
let ri = ReturnInputs { filing_status: FilingStatus::Single, ..Default::default() };
set_draft_row(sess.conn(), 2024, &ri, false).unwrap();
assert!(discard_parked_draft(&mut sess, 2024).is_err(), "won't delete a WIP behind 'discard parked'");
assert!(draft_exists(sess.conn(), 2024).unwrap());
set_draft_row(sess.conn(), 2024, &ri, true).unwrap();
discard_parked_draft(&mut sess, 2024).unwrap();
assert!(!draft_exists(sess.conn(), 2024).unwrap());
}
#[test]
fn draft_row_set_get_delete_roundtrip_with_parked() {
let conn = Connection::open_in_memory().unwrap();
init_draft_table(&conn).unwrap();
let ri = ReturnInputs { filing_status: FilingStatus::Mfj, ..Default::default() };
set_draft_row(&conn, 2024, &ri, false).unwrap();
let got = get_draft_row(&conn, 2024).unwrap().unwrap();
assert_eq!(got.ri.filing_status, FilingStatus::Mfj);
assert_eq!(got.version, SCHEMA_VERSION);
assert!(!got.parked);
assert_eq!(parked_flag(&conn, 2024).unwrap(), Some(false));
set_draft_row(&conn, 2024, &ri, true).unwrap();
assert!(get_draft_row(&conn, 2024).unwrap().unwrap().parked);
assert!(delete_draft(&conn, 2024).unwrap());
assert!(get_draft_row(&conn, 2024).unwrap().is_none());
assert!(!delete_draft(&conn, 2024).unwrap()); }
}