1use crate::return_inputs::SCHEMA_VERSION;
9use crate::{CliError, Session};
10use btctax_core::tax::return_inputs::ReturnInputs;
11use btctax_core::tax::return_refuse::{screen_inputs, Refusal};
12use btctax_core::tax::tables::{FullReturnParams, TaxTable};
13use rusqlite::Connection;
14
15pub fn init_draft_table(conn: &Connection) -> Result<(), CliError> {
17 conn.execute(
18 "CREATE TABLE IF NOT EXISTS return_inputs_draft (\
19 year INTEGER PRIMARY KEY, inputs_json TEXT NOT NULL, \
20 schema_version INTEGER NOT NULL DEFAULT 0, parked INTEGER NOT NULL DEFAULT 0)",
21 [],
22 )?;
23 Ok(())
24}
25
26pub(crate) struct DraftRow {
27 pub ri: ReturnInputs,
28 pub version: i64,
29 pub parked: bool,
30}
31
32pub(crate) fn get_draft_row(conn: &Connection, year: i32) -> Result<Option<DraftRow>, CliError> {
34 init_draft_table(conn)?;
35 let row = conn.query_row(
36 "SELECT inputs_json, schema_version, parked FROM return_inputs_draft WHERE year=?1",
37 [year],
38 |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)),
39 );
40 match row {
41 Ok((json, version, parked)) => {
42 let ri: ReturnInputs = serde_json::from_str(&json).map_err(|e| CliError::BadConfigValue {
45 key: format!("return_inputs_draft[{year}]"),
46 value: format!("invalid JSON: {e}"),
47 })?;
48 Ok(Some(DraftRow { ri, version, parked: parked != 0 }))
49 }
50 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
51 Err(e) => Err(e.into()),
52 }
53}
54
55pub(crate) fn set_draft_row(
56 conn: &Connection,
57 year: i32,
58 ri: &ReturnInputs,
59 parked: bool,
60) -> Result<(), CliError> {
61 init_draft_table(conn)?;
62 let j = serde_json::to_string(ri).map_err(|e| CliError::BadConfigValue {
64 key: format!("return_inputs_draft[{year}]"),
65 value: format!("could not serialize: {e}"),
66 })?;
67 conn.execute(
68 "INSERT INTO return_inputs_draft(year,inputs_json,schema_version,parked) VALUES(?1,?2,?3,?4) \
69 ON CONFLICT(year) DO UPDATE SET inputs_json=?2, schema_version=?3, parked=?4",
70 rusqlite::params![year, j, SCHEMA_VERSION, parked as i64],
71 )?;
72 Ok(())
73}
74
75pub(crate) fn delete_draft(conn: &Connection, year: i32) -> Result<bool, CliError> {
76 init_draft_table(conn)?;
77 Ok(conn.execute("DELETE FROM return_inputs_draft WHERE year=?1", [year])? > 0)
78}
79
80pub fn draft_exists(conn: &Connection, year: i32) -> Result<bool, CliError> {
81 init_draft_table(conn)?;
82 Ok(conn
83 .query_row("SELECT 1 FROM return_inputs_draft WHERE year=?1", [year], |_| Ok(()))
84 .is_ok())
85}
86
87pub(crate) fn parked_flag(conn: &Connection, year: i32) -> Result<Option<bool>, CliError> {
88 init_draft_table(conn)?;
89 match conn.query_row(
90 "SELECT parked FROM return_inputs_draft WHERE year=?1",
91 [year],
92 |r| r.get::<_, i64>(0),
93 ) {
94 Ok(p) => Ok(Some(p != 0)),
95 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
96 Err(e) => Err(e.into()),
97 }
98}
99
100pub fn save_draft(sess: &mut Session, year: i32, ri: &ReturnInputs) -> Result<(), CliError> {
108 let parked = parked_flag(sess.conn(), year)?.unwrap_or(false); set_draft_row(sess.conn(), year, ri, parked)?;
110 sess.save()?; Ok(())
112}
113
114pub enum Loaded {
120 Draft { ri: ReturnInputs, parked: bool },
121 Committed(ReturnInputs),
122 Fresh,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct StaleNote {
131 pub year: i32,
132 pub found: i64,
133 pub expected: i64,
134}
135
136impl std::fmt::Display for StaleNote {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 write!(
139 f,
140 "discarded a stale draft for {} (schema v{}, expected v{})",
141 self.year, self.found, self.expected
142 )
143 }
144}
145
146pub fn load(conn: &Connection, year: i32) -> Result<(Loaded, Option<StaleNote>), CliError> {
162 if let Some(d) = get_draft_row(conn, year)? {
163 if d.version != SCHEMA_VERSION {
164 if d.parked {
165 return Err(CliError::StaleParkedDraft {
166 year,
167 found: d.version,
168 expected: SCHEMA_VERSION,
169 });
170 }
171 delete_draft(conn, year)?;
174 let note = StaleNote {
175 year,
176 found: d.version,
177 expected: SCHEMA_VERSION,
178 };
179 return Ok((committed_or_fresh(conn, year)?, Some(note)));
180 } else {
181 return Ok((
182 Loaded::Draft {
183 ri: d.ri,
184 parked: d.parked,
185 },
186 None,
187 ));
188 }
189 }
190 Ok((committed_or_fresh(conn, year)?, None))
191}
192
193fn committed_or_fresh(conn: &Connection, year: i32) -> Result<Loaded, CliError> {
195 match crate::return_inputs::get(conn, year)? {
196 Some(ri) => Ok(Loaded::Committed(ri)),
197 None => Ok(Loaded::Fresh),
198 }
199}
200
201pub fn coherence_clear_or_refuse(conn: &Connection, year: i32) -> Result<(), CliError> {
220 match parked_flag(conn, year)? {
221 None => Ok(()),
222 Some(true) => Err(CliError::ParkedDraftBlocksWrite { year }), Some(false) => {
224 if let Some(d) = get_draft_row(conn, year)? {
225 if d.ri != ReturnInputs::default() {
226 eprintln!(
227 "note: superseding a work-in-progress draft for {year} with this write."
228 );
229 }
230 }
231 delete_draft(conn, year)?;
232 Ok(())
233 }
234 }
235}
236
237pub enum CommitOutcome {
246 Committed,
247 Refused(Refusal),
248 NoTables,
249}
250
251pub fn commit(
266 sess: &mut Session,
267 year: i32,
268 ri: &ReturnInputs,
269 table: Option<&TaxTable>,
270 params: Option<&FullReturnParams>,
271) -> Result<CommitOutcome, CliError> {
272 let (Some(table), Some(params)) = (table, params) else {
273 return Ok(CommitOutcome::NoTables); };
275 if table.year != year || params.year != year {
276 return Ok(CommitOutcome::NoTables);
279 }
280 if let Some(refusal) = screen_inputs(ri, table, params) {
281 return Ok(CommitOutcome::Refused(refusal)); }
283 let snap = sess.snapshot()?;
284 crate::return_inputs::set(sess.conn(), year, ri)?;
285 delete_draft(sess.conn(), year)?;
286 if let Err(e) = sess.save() {
287 sess.restore(&snap)?; return Err(e);
289 }
290 Ok(CommitOutcome::Committed)
291}
292
293pub fn park_to_profile(sess: &mut Session, year: i32) -> Result<(), CliError> {
314 let Some(ri) = crate::return_inputs::get(sess.conn(), year)? else {
315 return Err(CliError::Usage(format!(
316 "no committed return to park for {year}"
317 )));
318 };
319 if parked_flag(sess.conn(), year)? == Some(false) {
320 return Err(CliError::Usage(format!(
322 "year {year} has a work-in-progress draft; finish or discard it before switching to the tax-profile"
323 )));
324 }
325 let snap = sess.snapshot()?;
326 set_draft_row(sess.conn(), year, &ri, true)?; crate::return_inputs::delete(sess.conn(), year)?; if let Err(e) = sess.save() {
329 sess.restore(&snap)?; return Err(e);
331 }
332 Ok(())
333}
334
335pub enum ActiveSource {
340 FullReturn,
341 TaxProfile,
342 Neither,
343}
344
345pub fn active_source(conn: &Connection, year: i32) -> Result<ActiveSource, CliError> {
354 if crate::return_inputs::exists(conn, year)? {
355 return Ok(ActiveSource::FullReturn);
356 }
357 if crate::tax_profile::years(conn)?.contains(&year) {
358 return Ok(ActiveSource::TaxProfile);
359 }
360 Ok(ActiveSource::Neither)
361}
362
363pub fn shadows_profile(conn: &Connection, year: i32) -> Result<bool, CliError> {
370 Ok(crate::tax_profile::years(conn)?.contains(&year))
371}
372
373pub fn discard_parked_draft(sess: &mut Session, year: i32) -> Result<(), CliError> {
383 if parked_flag(sess.conn(), year)? != Some(true) {
384 return Err(CliError::Usage(format!(
386 "year {year} has no parked draft to discard"
387 )));
388 }
389 let snap = sess.snapshot()?;
390 delete_draft(sess.conn(), year)?;
391 if let Err(e) = sess.save() {
392 sess.restore(&snap)?;
393 return Err(e);
394 }
395 Ok(())
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::Session;
402 use btctax_core::tax::return_inputs::ReturnInputs;
403 use btctax_core::tax::types::FilingStatus;
404 use btctax_store::Passphrase;
405 use rusqlite::Connection;
406
407 fn pp() -> Passphrase {
408 Passphrase::new("test-pass".into())
409 }
410
411 fn tmp_vault() -> (tempfile::TempDir, std::path::PathBuf, Passphrase) {
416 let dir = tempfile::tempdir().unwrap();
417 let path = dir.path().join("vault.pgp");
418 {
419 let _ = Session::create(&path, &pp()).unwrap(); }
421 (dir, path, pp())
422 }
423
424 #[test]
425 fn save_draft_preserves_parked_and_reaches_disk() {
426 let (_dir, path, pp) = tmp_vault();
427 let ri_a = ReturnInputs {
428 filing_status: FilingStatus::Single,
429 ..Default::default()
430 };
431 {
432 let mut sess = Session::open(&path, &pp).unwrap();
433 set_draft_row(sess.conn(), 2024, &ri_a, true).unwrap();
435 let ri_b = ReturnInputs {
436 filing_status: FilingStatus::Mfj,
437 ..Default::default()
438 };
439 save_draft(&mut sess, 2024, &ri_b).unwrap();
440 assert_eq!(
441 parked_flag(sess.conn(), 2024).unwrap(),
442 Some(true),
443 "NI-1: parked survives an edit"
444 );
445 }
446 let sess2 = Session::open(&path, &pp).unwrap();
448 let row = get_draft_row(sess2.conn(), 2024).unwrap().unwrap();
449 assert_eq!(row.ri.filing_status, FilingStatus::Mfj);
450 assert!(row.parked);
451 }
452
453 #[test]
454 fn save_draft_on_fresh_year_is_unparked() {
455 let (_dir, path, pp) = tmp_vault();
456 let mut sess = Session::open(&path, &pp).unwrap();
457 save_draft(&mut sess, 2024, &ReturnInputs::default()).unwrap();
458 assert_eq!(parked_flag(sess.conn(), 2024).unwrap(), Some(false));
459 }
460
461 #[test]
462 fn load_precedence_draft_then_committed_then_fresh() {
463 let conn = Connection::open_in_memory().unwrap();
464 crate::return_inputs::init_table(&conn).unwrap();
465 init_draft_table(&conn).unwrap();
466 let (loaded, note) = load(&conn, 2024).unwrap();
468 assert!(matches!(loaded, Loaded::Fresh));
469 assert!(note.is_none(), "no stale discard on a fresh year");
470 let cri = ReturnInputs { filing_status: FilingStatus::HoH, ..Default::default() };
472 crate::return_inputs::set(&conn, 2024, &cri).unwrap();
473 let (loaded, note) = load(&conn, 2024).unwrap();
474 assert!(matches!(loaded, Loaded::Committed(r) if r.filing_status == FilingStatus::HoH));
475 assert!(note.is_none(), "no stale discard on the committed path");
476 let dri = ReturnInputs { filing_status: FilingStatus::Mfj, ..Default::default() };
478 set_draft_row(&conn, 2024, &dri, false).unwrap();
479 let (loaded, note) = load(&conn, 2024).unwrap();
480 assert!(matches!(loaded, Loaded::Draft { ri, parked: false } if ri.filing_status == FilingStatus::Mfj));
481 assert!(note.is_none(), "no stale discard on a version-current draft");
482 }
483
484 #[test]
485 fn load_discards_stale_wip_but_refuses_stale_parked() {
486 let conn = Connection::open_in_memory().unwrap();
487 init_draft_table(&conn).unwrap();
488 let ri = ReturnInputs { filing_status: FilingStatus::Single, ..Default::default() };
489 let j = serde_json::to_string(&ri).unwrap();
490 conn.execute("INSERT INTO return_inputs_draft(year,inputs_json,schema_version,parked) VALUES(2024,?1,0,0)", [&j]).unwrap();
493 let (loaded, note) = load(&conn, 2024).unwrap();
494 assert!(matches!(loaded, Loaded::Fresh));
495 assert_eq!(
496 note,
497 Some(StaleNote { year: 2024, found: 0, expected: SCHEMA_VERSION }),
498 "the stale-WIP discard returns the note (not an eprintln!)"
499 );
500 assert!(!draft_exists(&conn, 2024).unwrap(), "stale WIP is discarded");
501 conn.execute("INSERT INTO return_inputs_draft(year,inputs_json,schema_version,parked) VALUES(2025,?1,0,1)", [&j]).unwrap();
503 assert!(matches!(load(&conn, 2025), Err(CliError::StaleParkedDraft { year: 2025, found: 0, .. })));
504 assert!(draft_exists(&conn, 2025).unwrap(), "stale parked is preserved, not discarded");
505 }
506
507 fn clean_screened_ri() -> ReturnInputs {
512 let mut ri = ReturnInputs {
513 filing_status: FilingStatus::Single,
514 header: btctax_core::tax::testonly::not_a_dependent(),
515 ..Default::default()
516 };
517 btctax_core::tax::testonly::answer_all_live_declarations(&mut ri);
518 ri
519 }
520
521 #[test]
522 fn commit_non2024_is_notables_and_writes_nothing() {
523 let (_dir, path, pp) = tmp_vault();
524 let mut sess = Session::open(&path, &pp).unwrap();
525 let ri = ReturnInputs {
526 filing_status: FilingStatus::Single,
527 ..Default::default()
528 };
529 set_draft_row(sess.conn(), 2099, &ri, false).unwrap();
530 let out = commit(&mut sess, 2099, &ri, None, None).unwrap();
532 assert!(matches!(out, CommitOutcome::NoTables));
533 assert!(
534 !crate::return_inputs::exists(sess.conn(), 2099).unwrap(),
535 "NoTables writes no committed row"
536 );
537 assert!(
538 draft_exists(sess.conn(), 2099).unwrap(),
539 "NoTables leaves the draft"
540 );
541 }
542
543 #[test]
544 fn commit_clean_sets_row_and_deletes_draft_refused_writes_nothing() {
545 use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
546 use btctax_core::tax::tables::FullReturnTables;
547 use btctax_core::TaxTables;
548 let (_dir, path, pp) = tmp_vault();
549 let tables = BundledTaxTables::load(); let fr = BundledFullReturnTables::load();
551 let (t, p) = (
552 tables.table_for(2024).unwrap(),
553 fr.full_return_for(2024).unwrap(),
554 ); let mut sess = Session::open(&path, &pp).unwrap();
556 let clean = clean_screened_ri();
558 set_draft_row(sess.conn(), 2024, &clean, false).unwrap();
559 assert!(matches!(
560 commit(&mut sess, 2024, &clean, Some(t), Some(p)).unwrap(),
561 CommitOutcome::Committed
562 ));
563 assert!(
564 crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
565 "clean commit writes the row"
566 );
567 assert!(
568 !draft_exists(sess.conn(), 2024).unwrap(),
569 "clean commit deletes the draft"
570 );
571 let refused = ReturnInputs {
573 filing_status: FilingStatus::Single,
574 ..Default::default()
575 }; set_draft_row(sess.conn(), 2024, &refused, false).unwrap();
577 assert!(matches!(
578 commit(&mut sess, 2024, &refused, Some(t), Some(p)).unwrap(),
579 CommitOutcome::Refused(_)
580 ));
581 assert!(
583 crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
584 "a refused commit does not delete the earlier committed row"
585 );
586 assert!(
587 draft_exists(sess.conn(), 2024).unwrap(),
588 "a refused commit leaves the draft"
589 );
590 }
591
592 #[test]
596 fn commit_refuses_tables_for_a_different_year_and_writes_nothing() {
597 use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
598 use btctax_core::tax::tables::FullReturnTables;
599 use btctax_core::TaxTables;
600 let (_dir, path, pp) = tmp_vault();
601 let tables = BundledTaxTables::load(); let fr = BundledFullReturnTables::load();
603 let (t2024, p2024) = (
604 tables.table_for(2024).unwrap(),
605 fr.full_return_for(2024).unwrap(),
606 );
607 let mut sess = Session::open(&path, &pp).unwrap();
608 let clean = clean_screened_ri();
609 let out = commit(&mut sess, 2025, &clean, Some(t2024), Some(p2024)).unwrap();
611 assert!(
612 matches!(out, CommitOutcome::NoTables),
613 "tables for a different year → NoTables, not a committed write"
614 );
615 assert!(
616 !crate::return_inputs::exists(sess.conn(), 2025).unwrap(),
617 "the table-less year is never poisoned with a committed row"
618 );
619 }
620
621 #[test]
625 fn coherence_clears_wip_but_refuses_parked() {
626 let conn = Connection::open_in_memory().unwrap();
627 init_draft_table(&conn).unwrap();
628 let ri = ReturnInputs {
629 filing_status: FilingStatus::Single,
630 ..Default::default()
631 };
632 set_draft_row(&conn, 2024, &ri, false).unwrap();
634 coherence_clear_or_refuse(&conn, 2024).unwrap();
635 assert!(
636 !draft_exists(&conn, 2024).unwrap(),
637 "coherence clears a WIP draft"
638 );
639 set_draft_row(&conn, 2025, &ri, true).unwrap();
641 let err = coherence_clear_or_refuse(&conn, 2025).unwrap_err();
642 assert!(matches!(err, CliError::ParkedDraftBlocksWrite { year: 2025 }));
643 let msg = err.to_string();
644 assert!(
645 msg.contains("use full return") && msg.contains("discard parked draft"),
646 "M-d: names both exits"
647 );
648 assert!(
649 draft_exists(&conn, 2025).unwrap(),
650 "a parked draft is never silently destroyed"
651 );
652 coherence_clear_or_refuse(&conn, 2030).unwrap();
654 }
655
656 #[test]
657 fn park_stashes_then_deletes_committed_atomically() {
658 let (_dir, path, pp) = tmp_vault();
659 let mut sess = Session::open(&path, &pp).unwrap();
660 let ri = ReturnInputs {
661 filing_status: FilingStatus::Mfj,
662 ..Default::default()
663 };
664 crate::return_inputs::set(sess.conn(), 2024, &ri).unwrap();
665 park_to_profile(&mut sess, 2024).unwrap();
666 assert!(
668 !crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
669 "park deletes the committed row"
670 );
671 let d = get_draft_row(sess.conn(), 2024).unwrap().unwrap();
672 assert!(
673 d.parked && d.ri.filing_status == FilingStatus::Mfj,
674 "park stashes the row as parked"
675 );
676 drop(sess);
678 let s2 = Session::open(&path, &pp).unwrap();
679 assert!(get_draft_row(s2.conn(), 2024).unwrap().unwrap().parked);
680 assert!(
681 !crate::return_inputs::exists(s2.conn(), 2024).unwrap(),
682 "the committed-row DELETE also reached disk, not just the stash"
683 );
684 }
685
686 #[test]
687 fn park_refuses_without_committed_row_and_on_any_wip() {
688 let (_dir, path, pp) = tmp_vault();
689 let mut sess = Session::open(&path, &pp).unwrap();
690 assert!(park_to_profile(&mut sess, 2024).is_err(), "nothing to park");
691 let ri = ReturnInputs {
692 filing_status: FilingStatus::Single,
693 ..Default::default()
694 };
695 crate::return_inputs::set(sess.conn(), 2024, &ri).unwrap();
696 set_draft_row(sess.conn(), 2024, &ri, false).unwrap(); assert!(
698 park_to_profile(&mut sess, 2024).is_err(),
699 "clean-state gate: won't clobber a WIP draft"
700 );
701 assert!(
702 crate::return_inputs::exists(sess.conn(), 2024).unwrap(),
703 "a refused park leaves the committed row"
704 );
705 }
706
707 fn sample_profile() -> btctax_core::TaxProfile {
710 use btctax_core::{Carryforward, TaxProfile};
711 use rust_decimal_macros::dec;
712 TaxProfile {
713 filing_status: FilingStatus::Mfj,
714 ordinary_taxable_income: dec!(120000),
715 magi_excluding_crypto: dec!(130000),
716 qualified_dividends_and_other_pref_income: dec!(0),
717 other_net_capital_gain: dec!(0),
718 capital_loss_carryforward_in: Carryforward { short: dec!(0), long: dec!(0) },
719 w2_ss_wages: dec!(0),
720 w2_medicare_wages: dec!(0),
721 schedule_c_expenses: dec!(0),
722 }
723 }
724
725 #[test]
726 fn active_source_follows_resolve_precedence() {
727 let conn = Connection::open_in_memory().unwrap();
728 crate::return_inputs::init_table(&conn).unwrap();
729 crate::tax_profile::init_table(&conn).unwrap();
730 assert!(matches!(active_source(&conn, 2024).unwrap(), ActiveSource::Neither));
731 crate::tax_profile::set(&conn, 2024, &sample_profile()).unwrap();
732 assert!(matches!(active_source(&conn, 2024).unwrap(), ActiveSource::TaxProfile));
733 assert!(shadows_profile(&conn, 2024).unwrap());
734 crate::return_inputs::set(&conn, 2024, &ReturnInputs::default()).unwrap();
736 assert!(matches!(active_source(&conn, 2024).unwrap(), ActiveSource::FullReturn));
737 }
738
739 #[test]
740 fn discard_parked_draft_only_deletes_a_parked_row() {
741 let (_dir, path, pp) = tmp_vault();
742 let mut sess = Session::open(&path, &pp).unwrap();
743 let ri = ReturnInputs { filing_status: FilingStatus::Single, ..Default::default() };
744 set_draft_row(sess.conn(), 2024, &ri, false).unwrap();
746 assert!(discard_parked_draft(&mut sess, 2024).is_err(), "won't delete a WIP behind 'discard parked'");
747 assert!(draft_exists(sess.conn(), 2024).unwrap());
748 set_draft_row(sess.conn(), 2024, &ri, true).unwrap();
750 discard_parked_draft(&mut sess, 2024).unwrap();
751 assert!(!draft_exists(sess.conn(), 2024).unwrap());
752 }
753
754 #[test]
755 fn draft_row_set_get_delete_roundtrip_with_parked() {
756 let conn = Connection::open_in_memory().unwrap();
757 init_draft_table(&conn).unwrap();
758 let ri = ReturnInputs { filing_status: FilingStatus::Mfj, ..Default::default() };
759 set_draft_row(&conn, 2024, &ri, false).unwrap();
761 let got = get_draft_row(&conn, 2024).unwrap().unwrap();
762 assert_eq!(got.ri.filing_status, FilingStatus::Mfj);
763 assert_eq!(got.version, SCHEMA_VERSION);
764 assert!(!got.parked);
765 assert_eq!(parked_flag(&conn, 2024).unwrap(), Some(false));
766 set_draft_row(&conn, 2024, &ri, true).unwrap();
768 assert!(get_draft_row(&conn, 2024).unwrap().unwrap().parked);
769 assert!(delete_draft(&conn, 2024).unwrap());
771 assert!(get_draft_row(&conn, 2024).unwrap().is_none());
772 assert!(!delete_draft(&conn, 2024).unwrap()); }
774}