Skip to main content

btctax_cli/
bulk_estimated.rs

1//! `bulk_estimated_proceeds` side-table — flags each disposal whose proceeds were derived from an
2//! auto-FMV (daily-close market value) by the bulk-reclassify-outflow path, rather than a real
3//! user-entered sale price. Keyed by the `EventId::canonical()` string of the `transfer_out_event`,
4//! which IS the eventual `Disposal.event` (fold pushes `Disposal{event: eff.id.clone()}` using the
5//! ORIGINAL TransferOut id — so the Disposals-tab join lands). Modeled on `donation_details.rs` /
6//! `optimize_attest.rs` discipline (idempotent DDL, defensive `init_table` guard on every accessor).
7//!
8//! **Stores ONLY the flag + a `date_marked` provenance stamp — NEVER the estimate numbers** [R0-M3].
9//! The Disposals tab renders the EXACT fold-computed `leg.proceeds/basis/gain` plus an `[est]` marker;
10//! a stored preview figure must never override the exact numbers. The flag lives OUTSIDE the
11//! append-only event log entirely (btctax-core is unchanged; no serde risk).
12use crate::CliError;
13use btctax_core::EventId;
14use rusqlite::Connection;
15use std::collections::BTreeMap;
16
17/// Create the `bulk_estimated_proceeds` side-table if it does not exist (idempotent).
18///
19/// `CREATE TABLE IF NOT EXISTS` makes this safe to call on any vault — new, old, or restored from
20/// snapshot — without errors. Called by `Session::from_fresh_vault`; also called at the top of every
21/// `mark`/`clear`/`all` as a defensive ensure-table-then-read guard (robust to older vaults that
22/// predate this table, mirroring the `donation_details`/`optimize_attest` back-compat pattern).
23pub fn init_table(conn: &Connection) -> Result<(), CliError> {
24    conn.execute_batch(
25        "CREATE TABLE IF NOT EXISTS bulk_estimated_proceeds \
26         (out_event TEXT PRIMARY KEY, date_marked TEXT NOT NULL);",
27    )?;
28    Ok(())
29}
30
31/// Flag `out_event`'s disposal as estimated-FMV proceeds (upsert — last-write-wins). `date_marked`
32/// is an ISO-8601 provenance stamp (the batch made-date). Idempotent DDL guard first.
33pub fn mark(conn: &Connection, out_event: &EventId, date_marked: &str) -> Result<(), CliError> {
34    init_table(conn)?;
35    conn.execute(
36        "INSERT INTO bulk_estimated_proceeds(out_event,date_marked) VALUES(?1,?2) \
37         ON CONFLICT(out_event) DO UPDATE SET date_marked=excluded.date_marked",
38        rusqlite::params![out_event.canonical(), date_marked],
39    )?;
40    Ok(())
41}
42
43/// Delete the `bulk_estimated_proceeds` row for `out_event` (idempotent — no-op if absent).
44///
45/// Called when the `ReclassifyOutflow` decision for `out_event` is VOIDED, so a re-reclassify via
46/// single `o` (which may carry a REAL price) does not inherit a stale `[est]` marker. Clearing an
47/// absent row is `Ok`, so single-`o` reclassifies that were never flagged are unaffected.
48pub fn clear(conn: &Connection, out_event: &EventId) -> Result<(), CliError> {
49    init_table(conn)?;
50    conn.execute(
51        "DELETE FROM bulk_estimated_proceeds WHERE out_event=?1",
52        [out_event.canonical()],
53    )?;
54    Ok(())
55}
56
57/// Return all flagged disposals as a `BTreeMap<EventId, String>` (value = `date_marked`), keyed by the
58/// `transfer_out_event` / `Disposal.event` (NFR4-stable deterministic order). Defensive DDL guard first.
59pub fn all(conn: &Connection) -> Result<BTreeMap<EventId, String>, CliError> {
60    init_table(conn)?;
61    let mut stmt = conn
62        .prepare("SELECT out_event, date_marked FROM bulk_estimated_proceeds ORDER BY out_event")?;
63    let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
64    let mut out = BTreeMap::new();
65    for row in rows {
66        let (ev_str, date_marked) = row?;
67        out.insert(crate::eventref::parse_event_id(&ev_str)?, date_marked);
68    }
69    Ok(out)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use btctax_core::{EventId, Source, SourceRef};
76
77    fn mem() -> rusqlite::Connection {
78        let c = rusqlite::Connection::open_in_memory().unwrap();
79        init_table(&c).unwrap();
80        c
81    }
82
83    fn eid(label: &str) -> EventId {
84        EventId::import(Source::Coinbase, SourceRef::new(label.to_string()))
85    }
86
87    #[test]
88    fn mark_then_all_contains_the_key() {
89        let c = mem();
90        let e = eid("out|bulk-est-1");
91        mark(&c, &e, "2026-07-03").unwrap();
92        let m = all(&c).unwrap();
93        assert_eq!(m.get(&e).map(String::as_str), Some("2026-07-03"));
94    }
95
96    #[test]
97    fn clear_removes_the_flag_and_is_idempotent() {
98        let c = mem();
99        let e = eid("out|bulk-est-clear");
100        mark(&c, &e, "2026-07-03").unwrap();
101        assert!(all(&c).unwrap().contains_key(&e));
102        clear(&c, &e).unwrap();
103        assert!(!all(&c).unwrap().contains_key(&e));
104        // Idempotent: clearing an absent row is Ok.
105        clear(&c, &e).unwrap();
106        assert!(!all(&c).unwrap().contains_key(&e));
107    }
108
109    #[test]
110    fn clear_absent_key_is_ok() {
111        let c = mem();
112        // Never marked — clear is a harmless no-op (covers the single-`o`-never-flagged path).
113        clear(&c, &eid("out|never-marked")).unwrap();
114        assert!(all(&c).unwrap().is_empty());
115    }
116
117    #[test]
118    fn mark_upserts_last_write_wins() {
119        let c = mem();
120        let e = eid("out|bulk-est-upsert");
121        mark(&c, &e, "2026-01-01").unwrap();
122        mark(&c, &e, "2026-07-03").unwrap();
123        assert_eq!(
124            all(&c).unwrap().get(&e).map(String::as_str),
125            Some("2026-07-03")
126        );
127    }
128
129    #[test]
130    fn all_on_tableless_vault_returns_empty() {
131        let c = rusqlite::Connection::open_in_memory().unwrap(); // no init_table
132        assert!(all(&c).unwrap().is_empty());
133    }
134
135    #[test]
136    fn defensive_guard_in_mark_creates_table() {
137        let c = rusqlite::Connection::open_in_memory().unwrap(); // no init_table
138        let e = eid("out|guard");
139        mark(&c, &e, "2026-07-03").unwrap();
140        assert!(all(&c).unwrap().contains_key(&e));
141    }
142
143    #[test]
144    fn all_returns_btreemap_in_deterministic_order() {
145        let c = mem();
146        let e1 = eid("out|alpha");
147        let e2 = eid("out|beta");
148        mark(&c, &e2, "2026-07-03").unwrap();
149        mark(&c, &e1, "2026-07-02").unwrap();
150        let m = all(&c).unwrap();
151        assert_eq!(m.len(), 2);
152        assert!(m.contains_key(&e1) && m.contains_key(&e2));
153    }
154}