Skip to main content

btctax_cli/
return_inputs.rs

1//! Per-year `return_inputs(year, inputs_json)` side-table — the full-return v1 input surface (line
2//! items + PII + payments). A projection input, **not** ledger state (NFR6). Mirrors `tax_profile.rs`
3//! exactly (idempotent DDL, robust-to-older-vaults guard, typed error on bad JSON) — one JSON-encoded
4//! [`ReturnInputs`] per tax year, stored inside the encrypted vault.
5use crate::CliError;
6use btctax_core::tax::return_inputs::ReturnInputs;
7use rusqlite::{Connection, OptionalExtension};
8use std::collections::BTreeMap;
9
10/// The current row schema.
11///
12/// - **0** — pre-D-8. `can_be_claimed_as_dependent_*` were bare `bool`s (unanswered indistinguishable
13///   from "No").
14/// - **1** — those flags became tri-state `Option<bool>`.
15/// - **2** — P9: `Person.blind` and `ScheduleAInputs.salt_use_sales_tax` became tri-state; `hsa_present`
16///   was renamed `hsa_activity` (a *different* question); `dual_status_alien` and the mixed-use-mortgage
17///   box were added.
18///
19/// ★ **P9 §2.6 — there is no migration.** The owner confirmed no real tax data has ever been entered, so a
20/// row at any version other than the current one **REFUSES** (`row_to_inputs`) rather than being read or
21/// per-key unlaundered. A version check cannot forget a key; a hand-written unlaunder list can, and did
22/// (Fable r4 I-4). Retire this the moment real data exists — the first real return needs true migrations,
23/// and prior-year carryforwards are exactly what a filer cannot reconstruct (FOLLOWUPS, release gate).
24pub const SCHEMA_VERSION: i64 = 2;
25
26/// Create the `return_inputs` side-table if it does not exist, and bring an OLDER vault's table up to the
27/// current schema. Idempotent — it runs on every `get`/`set`, so it must be safe to call repeatedly.
28pub fn init_table(conn: &Connection) -> Result<(), CliError> {
29    conn.execute_batch(
30        "CREATE TABLE IF NOT EXISTS return_inputs \
31         (year INTEGER PRIMARY KEY, inputs_json TEXT NOT NULL, schema_version INTEGER NOT NULL DEFAULT 0);",
32    )?;
33    // A vault created BEFORE this column existed still has the 2-column table — `CREATE TABLE IF NOT
34    // EXISTS` is a no-op on it and adds nothing. SQLite has no `ADD COLUMN IF NOT EXISTS`, and this runs
35    // on every command, so: attempt the ALTER and tolerate EXACTLY the already-applied error. Any other
36    // error is real and propagates.
37    if let Err(e) = conn.execute_batch(
38        "ALTER TABLE return_inputs ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 0;",
39    ) {
40        let msg = e.to_string();
41        if !msg.contains("duplicate column name") {
42            return Err(e.into());
43        }
44    }
45    Ok(())
46}
47
48/// ★ The ONE read boundary. Every path that turns a stored blob into a [`ReturnInputs`] goes through
49/// here — `get` AND `all` — so the version gate cannot be applied on one path and forgotten on the other.
50///
51/// **P9 §2.6 — refuse-and-reimport, not migrate.** A row whose `version` is anything other than the
52/// current [`SCHEMA_VERSION`] REFUSES (`StaleReturnInputs`). This is fail-closed in both directions: an
53/// OLDER row would deserialize its now-`Option` fields' stored `false` as `Some(false)` — a never-asked
54/// default ratified as the filer's answer, the D-8 laundering — and a NEWER row would be half-read. The
55/// remedy (named in the error) is `income clear` → `income import` → `report --write-carryover`; `clear`
56/// discards the row's computed carryover, so the rebuild step is not optional. There is no per-key
57/// migration to forget a key (Fable r4 I-4), because there is no real data to migrate yet.
58fn row_to_inputs(year: i32, json: &str, version: i64) -> Result<ReturnInputs, CliError> {
59    if version != SCHEMA_VERSION {
60        return Err(CliError::StaleReturnInputs {
61            year,
62            found: version,
63            expected: SCHEMA_VERSION,
64        });
65    }
66    serde_json::from_str(json).map_err(|e| CliError::BadConfigValue {
67        key: format!("return_inputs[{year}]"),
68        value: format!("invalid JSON: {e}"),
69    })
70}
71
72/// Return the stored [`ReturnInputs`] for `year`, or `None` if none has been set.
73/// Robust to older vaults (ensures the table first); typed error on malformed JSON.
74pub fn get(conn: &Connection, year: i32) -> Result<Option<ReturnInputs>, CliError> {
75    init_table(conn)?;
76    let json: Option<(String, i64)> = conn
77        .query_row(
78            "SELECT inputs_json, schema_version FROM return_inputs WHERE year=?1",
79            [year],
80            |r| Ok((r.get(0)?, r.get(1)?)),
81        )
82        .optional()?;
83    match json {
84        None => Ok(None),
85        Some((j, v)) => Ok(Some(row_to_inputs(year, &j, v)?)),
86    }
87}
88
89/// Persist `ri` as the [`ReturnInputs`] for `year` (upsert — replaces any prior value).
90pub fn set(conn: &Connection, year: i32, ri: &ReturnInputs) -> Result<(), CliError> {
91    init_table(conn)?;
92    let j = serde_json::to_string(ri).map_err(|e| CliError::BadConfigValue {
93        key: format!("return_inputs[{year}]"),
94        value: e.to_string(),
95    })?;
96    // ★ The DO-UPDATE branch MUST stamp the version too. The shipped upsert named `inputs_json` alone —
97    // so writing an answer onto a version-0 row would have left it at version 0, the read-time fixup would
98    // RE-FIRE on the very next `get`, and the filer's answered `false` would be laundered straight back to
99    // `None`. The bug would have reconstituted itself out of its own fix.
100    conn.execute(
101        "INSERT INTO return_inputs(year,inputs_json,schema_version) VALUES(?1,?2,?3) \
102         ON CONFLICT(year) DO UPDATE SET inputs_json=excluded.inputs_json, \
103                                         schema_version=excluded.schema_version",
104        rusqlite::params![year, j, SCHEMA_VERSION],
105    )?;
106    Ok(())
107}
108
109/// Whether a `ReturnInputs` exists for `year` (used by the `tax-profile set` guard — SPEC §4.12/D-4).
110/// A `SELECT 1` existence probe (does not deserialize the blob — review M5).
111pub fn exists(conn: &Connection, year: i32) -> Result<bool, CliError> {
112    init_table(conn)?;
113    let found: Option<i64> = conn
114        .query_row("SELECT 1 FROM return_inputs WHERE year=?1", [year], |r| {
115            r.get(0)
116        })
117        .optional()?;
118    Ok(found.is_some())
119}
120
121/// Delete the stored `ReturnInputs` for `year` (used by `income clear`). Returns `true` if a row existed.
122pub fn delete(conn: &Connection, year: i32) -> Result<bool, CliError> {
123    init_table(conn)?;
124    let n = conn.execute("DELETE FROM return_inputs WHERE year=?1", [year])?;
125    Ok(n > 0)
126}
127
128/// The years that have stored inputs, ascending — WITHOUT deserializing any blob, so a single corrupt
129/// row cannot break enumeration (review N3: one bad blob must not brick the read-only viewer).
130pub fn years(conn: &Connection) -> Result<Vec<i32>, CliError> {
131    init_table(conn)?;
132    let mut stmt = conn.prepare("SELECT year FROM return_inputs ORDER BY year")?;
133    let rows = stmt.query_map([], |r| r.get::<_, i32>(0))?;
134    Ok(rows.collect::<Result<Vec<_>, _>>()?)
135}
136
137/// Return all stored inputs, sorted by year ascending.
138pub fn all(conn: &Connection) -> Result<BTreeMap<i32, ReturnInputs>, CliError> {
139    init_table(conn)?;
140    let mut stmt =
141        conn.prepare("SELECT year, inputs_json, schema_version FROM return_inputs ORDER BY year")?;
142    let rows = stmt.query_map([], |r| {
143        Ok((
144            r.get::<_, i32>(0)?,
145            r.get::<_, String>(1)?,
146            r.get::<_, i64>(2)?,
147        ))
148    })?;
149    let mut out = BTreeMap::new();
150    for row in rows {
151        let (y, j, v) = row?;
152        // Same read boundary as `get` — NOT a second `from_str` (that is how the two paths drift apart).
153        out.insert(y, row_to_inputs(y, &j, v)?);
154    }
155    Ok(out)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use btctax_core::tax::return_inputs::{Owner, W2};
162    use btctax_core::FilingStatus;
163    use rust_decimal_macros::dec;
164
165    fn mem() -> Connection {
166        let c = Connection::open_in_memory().unwrap();
167        init_table(&c).unwrap();
168        c
169    }
170
171    fn inputs() -> ReturnInputs {
172        ReturnInputs {
173            filing_status: FilingStatus::Mfj,
174            w2s: vec![W2 {
175                owner: Owner::Taxpayer,
176                employer: "ACME".into(),
177                box1_wages: dec!(82000),
178                box2_fed_withheld: dec!(9100),
179                ..Default::default()
180            }],
181            ..Default::default()
182        }
183    }
184
185    #[test]
186    fn set_then_get_round_trips() {
187        let c = mem();
188        set(&c, 2024, &inputs()).unwrap();
189        assert_eq!(get(&c, 2024).unwrap().unwrap(), inputs());
190        assert_eq!(get(&c, 2025).unwrap(), None);
191        assert!(exists(&c, 2024).unwrap());
192        assert!(!exists(&c, 2025).unwrap());
193    }
194
195    #[test]
196    fn get_on_tableless_vault_is_ok_none() {
197        let c = Connection::open_in_memory().unwrap(); // no init_table
198        assert_eq!(get(&c, 2024).unwrap(), None);
199    }
200
201    #[test]
202    fn bad_json_is_a_typed_error_not_a_panic() {
203        let c = mem();
204        // At the CURRENT schema version (so the stale-row gate passes and we reach the JSON parse): a
205        // malformed blob must be a typed error, not a panic. (A row omitting `schema_version` defaults to
206        // 0 and would refuse as stale — a different, correct path tested in `p9_stale_row_refuses`.)
207        c.execute(
208            "INSERT INTO return_inputs(year,inputs_json,schema_version) VALUES(2024,'not json',?1)",
209            [SCHEMA_VERSION],
210        )
211        .unwrap();
212        assert!(matches!(
213            get(&c, 2024).unwrap_err(),
214            CliError::BadConfigValue { .. }
215        ));
216    }
217
218    #[test]
219    fn all_returns_sorted_by_year() {
220        let c = mem();
221        set(&c, 2025, &inputs()).unwrap();
222        set(&c, 2024, &inputs()).unwrap();
223        assert_eq!(
224            all(&c).unwrap().keys().copied().collect::<Vec<_>>(),
225            vec![2024, 2025]
226        );
227    }
228
229    #[test]
230    fn delete_removes_the_row() {
231        let c = mem();
232        set(&c, 2024, &inputs()).unwrap();
233        assert!(exists(&c, 2024).unwrap());
234        assert!(delete(&c, 2024).unwrap()); // existed
235        assert!(!exists(&c, 2024).unwrap());
236        assert!(!delete(&c, 2024).unwrap()); // idempotent — nothing to remove
237    }
238}
239
240#[cfg(test)]
241mod p9_stale_row_refuses {
242    //! ★ P9 §2.6 — there is NO migration. A stored row whose `schema_version` is not the current one
243    //! REFUSES (`StaleReturnInputs`) rather than being silently read or per-key unlaundered. The owner
244    //! confirmed no real data has ever been entered, so refuse-and-reimport is lawful — and a version check
245    //! cannot forget a key, unlike the hand-written unlaunder list this replaces (whose `blind ×2`
246    //! mutation-check went vacuous — Fable r4 I-4). This module replaces the old `p8a_migration_tests`,
247    //! which tested the now-deleted v0→v1 unlaunder.
248    use super::*;
249    use rusqlite::Connection;
250
251    /// A vault holding a row at an OLD schema version, in the current 3-column table.
252    fn vault_with_row_at_version(year: i32, version: i64) -> Connection {
253        let conn = Connection::open_in_memory().unwrap();
254        init_table(&conn).unwrap();
255        // A well-formed blob (valid JSON) — the point is the VERSION, not a parse failure.
256        let blob = serde_json::to_string(&ReturnInputs::default()).unwrap();
257        conn.execute(
258            "INSERT INTO return_inputs(year,inputs_json,schema_version) VALUES(?1,?2,?3)",
259            rusqlite::params![year, blob, version],
260        )
261        .unwrap();
262        conn
263    }
264
265    /// ★ THE POINT. A pre-P9 row (v0, the pre-D-8 schema) must REFUSE — never be read. Its `blind`/`salt`
266    /// bools would deserialize to `Some(false)` (a never-asked default ratified as an answer), which is the
267    /// D-8 laundering; refusing is the fail-closed reading.
268    #[test]
269    fn a_version_0_row_refuses_stale() {
270        let conn = vault_with_row_at_version(2024, 0);
271        assert!(
272            matches!(get(&conn, 2024), Err(CliError::StaleReturnInputs { year: 2024, found: 0, expected }) if expected == SCHEMA_VERSION),
273            "a v0 row must refuse as stale, naming the version"
274        );
275    }
276
277    /// A v1 row (the post-D-8, pre-P9 schema) is equally stale — the `blind`/`salt` type flips landed in P9.
278    #[test]
279    fn a_version_1_row_refuses_stale() {
280        let conn = vault_with_row_at_version(2024, 1);
281        assert!(
282            matches!(
283                get(&conn, 2024),
284                Err(CliError::StaleReturnInputs { found: 1, .. })
285            ),
286            "a v1 row must refuse as stale"
287        );
288    }
289
290    /// ★ `all()` is the module's OTHER deserializer — it must refuse identically, or a reader (the TUI's
291    /// per-year resolution, `income show --all`) sees a laundered row `get` would have refused.
292    #[test]
293    fn all_refuses_a_stale_row_identically_to_get() {
294        let conn = vault_with_row_at_version(2024, 1);
295        assert!(
296            matches!(
297                all(&conn),
298                Err(CliError::StaleReturnInputs { found: 1, .. })
299            ),
300            "`all()` must apply the same version gate as `get()`"
301        );
302    }
303
304    /// ★ FORWARD guard (r3 Nit-2): a row written by a NEWER build (version > current) is also stale — the
305    /// same `!=` covers it. P9 creates the first-ever version skew, and a half-read future row is exactly
306    /// the class this spec closes.
307    #[test]
308    fn a_future_version_row_refuses_too() {
309        let conn = vault_with_row_at_version(2024, SCHEMA_VERSION + 1);
310        assert!(
311            matches!(get(&conn, 2024), Err(CliError::StaleReturnInputs { .. })),
312            "a future-version row must refuse, not be half-read"
313        );
314    }
315
316    /// A row at the CURRENT version reads normally — the gate is exact, not a blanket refusal.
317    #[test]
318    fn a_current_version_row_reads() {
319        let conn = vault_with_row_at_version(2024, SCHEMA_VERSION);
320        assert!(
321            get(&conn, 2024).unwrap().is_some(),
322            "a current-version row must read"
323        );
324    }
325
326    /// The stale-row refusal's message names all THREE remedy commands, in order (§2.6 / r6 I-1): `clear`
327    /// discards the computed carryover, so `import` alone is not a complete recovery. Mutation: drop the
328    /// rebuild clause from the `#[error(...)]` string ⇒ this fails.
329    #[test]
330    fn the_stale_message_names_the_full_three_command_remedy() {
331        let msg = CliError::StaleReturnInputs {
332            year: 2024,
333            found: 1,
334            expected: 2,
335        }
336        .to_string();
337        assert!(msg.contains("income clear 2024"), "names clear");
338        assert!(msg.contains("income import"), "names import");
339        assert!(
340            msg.contains("--write-carryover"),
341            "names the rebuild — disclosure is not restoration (r6 I-1)"
342        );
343        assert!(
344            msg.contains("2023"),
345            "the rebuild targets the PRIOR year (year-1)"
346        );
347    }
348}