Skip to main content

btctax_cli/
resolve.rs

1//! **Single profile-source resolver** (full-return v1, SPEC §4.12 / G4).
2//!
3//! Every consumer (`report`, TUI, `optimize`, `what-if` defaults, `export`) must resolve the tax profile
4//! through ONE function so the app never shows two different liabilities for one year (the cardinal sin).
5//! Precedence (SPEC §4.12): `ReturnInputs` (full return) → stored `TaxProfile` (raw override) →
6//! pseudo-reconcile placeholder → missing.
7//!
8//! **P2 (task 5):** the `ReturnInputs` arm now DERIVES the frozen [`TaxProfile`] via
9//! [`btctax_core::tax::derive_tax_profile`], gated **fail-closed** by the [`screen_inputs`] refuse-guard:
10//! an input-screenable refusal — or a year without full-return tables (v1 = TY2024) — yields
11//! `profile: None` rather than a wrong number, carrying the [`Refusal`] so the caller can surface it.
12use crate::{return_inputs, tax_profile, CliError};
13use btctax_core::state::LedgerState;
14use btctax_core::tax::derive_tax_profile;
15use btctax_core::tax::return_1040::screen_compute_dependent;
16use btctax_core::tax::return_inputs::ReturnInputs;
17use btctax_core::tax::return_refuse::{screen_inputs, Refusal};
18use btctax_core::tax::tables::FullReturnParams;
19use btctax_core::{Carryforward, FilingStatus, TaxProfile, TaxTable, Usd};
20use rusqlite::Connection;
21
22/// Which source produced the resolved profile (printed on every output so a reviewer can audit — G4).
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Provenance {
25    /// A full-return `ReturnInputs` blob, derived to a `TaxProfile` (or `None` if refused/unsupported).
26    ReturnInputs,
27    /// A raw hand-entered `TaxProfile` (the escape hatch).
28    StoredProfile,
29    /// The pseudo-reconcile all-$0 placeholder (mode on, nothing stored).
30    PseudoPlaceholder,
31    /// No profile source for the year.
32    Missing,
33}
34
35/// The resolved profile + its provenance (+ any refusal). `profile` is `None` for [`Provenance::Missing`],
36/// and for [`Provenance::ReturnInputs`] when the inputs were refused by the fail-closed guard or the year
37/// lacks full-return tables — `refusal` distinguishes those two.
38#[derive(Debug, Clone)]
39pub struct Resolved {
40    pub profile: Option<TaxProfile>,
41    pub provenance: Provenance,
42    /// Set (with `profile: None`) when `ReturnInputs` were present but the refuse-guard refused them.
43    pub refusal: Option<Refusal>,
44}
45
46impl Resolved {
47    /// `ReturnInputs` were present but no profile could be produced — either the refuse-guard refused them
48    /// (`refusal` is `Some`) or the year has no full-return tables (v1 = TY2024; `refusal` is `None`).
49    /// Callers MUST surface this, never treat the year as profile-less (which would be a wrong number).
50    pub fn is_return_inputs_uncomputable(&self) -> bool {
51        self.provenance == Provenance::ReturnInputs && self.profile.is_none()
52    }
53}
54
55/// The pseudo-reconcile PLACEHOLDER profile: Single, $0 income / MAGI / qualified-dividends / carryforward.
56/// Injected (never persisted) only when the mode is on and nothing else resolves; clears
57/// `TaxProfileMissing` ONLY (it is applied after the projection, so it can never clear a Hard gate).
58pub fn placeholder_tax_profile() -> TaxProfile {
59    TaxProfile {
60        filing_status: FilingStatus::Single,
61        ordinary_taxable_income: Usd::ZERO,
62        magi_excluding_crypto: Usd::ZERO,
63        qualified_dividends_and_other_pref_income: Usd::ZERO,
64        other_net_capital_gain: Usd::ZERO,
65        capital_loss_carryforward_in: Carryforward::default(),
66        w2_ss_wages: Usd::ZERO,
67        w2_medicare_wages: Usd::ZERO,
68        schedule_c_expenses: Usd::ZERO,
69    }
70}
71
72/// The SINGLE SPEC §4.12 precedence ladder — `ReturnInputs` (input-screened + derived) → stored
73/// `TaxProfile` → pseudo placeholder → missing. Returns the [`Resolved`] **and the fetched
74/// `ReturnInputs`** so [`resolve_and_screen`] can run the compute-dependent screen on the SAME bytes (one
75/// fetch — M3) WITHOUT a second copy of the ladder: both public entry points share this, so the
76/// precedence invariant lives in exactly one place and its KAT pins the live code (review N2). Not public.
77fn resolve_core(
78    conn: &Connection,
79    year: i32,
80    pseudo_reconcile: bool,
81    full_return: Option<&FullReturnParams>,
82    tax_table: Option<&TaxTable>,
83) -> Result<(Resolved, Option<ReturnInputs>), CliError> {
84    // 1. Full return (highest precedence): derive the frozen profile, gated fail-closed by the guard.
85    if let Some(ri) = return_inputs::get(conn, year)? {
86        // A year without full-return tables (v1 = TY2024) cannot be derived — fail closed, no refusal.
87        let (Some(params), Some(table)) = (full_return, tax_table) else {
88            let r = Resolved {
89                profile: None,
90                provenance: Provenance::ReturnInputs,
91                refusal: None,
92            };
93            return Ok((r, Some(ri)));
94        };
95        // Fail-closed: an input-screenable refusal blocks derivation (never a silently-wrong number).
96        if let Some(refusal) = screen_inputs(&ri, table, params) {
97            let r = Resolved {
98                profile: None,
99                provenance: Provenance::ReturnInputs,
100                refusal: Some(refusal),
101            };
102            return Ok((r, Some(ri)));
103        }
104        let r = Resolved {
105            profile: Some(derive_tax_profile(&ri, params, year)),
106            provenance: Provenance::ReturnInputs,
107            refusal: None,
108        };
109        return Ok((r, Some(ri)));
110    }
111    // 2. Raw hand-entered profile (the escape hatch).
112    if let Some(p) = tax_profile::get(conn, year)? {
113        let r = Resolved {
114            profile: Some(p),
115            provenance: Provenance::StoredProfile,
116            refusal: None,
117        };
118        return Ok((r, None));
119    }
120    // 3. Pseudo-reconcile placeholder (mode on).
121    if pseudo_reconcile {
122        let r = Resolved {
123            profile: Some(placeholder_tax_profile()),
124            provenance: Provenance::PseudoPlaceholder,
125            refusal: None,
126        };
127        return Ok((r, None));
128    }
129    // 4. Nothing.
130    let r = Resolved {
131        profile: None,
132        provenance: Provenance::Missing,
133        refusal: None,
134    };
135    Ok((r, None))
136}
137
138/// Resolve the tax profile for `year` in SPEC §4.12 precedence order (ReturnInputs → stored → pseudo →
139/// missing), screening only the input-screenable refuse rows. A COMPUTING consumer that has the ledger
140/// `state` should use [`resolve_and_screen`] instead — it shares this exact ladder (`resolve_core`) and
141/// additionally runs the compute-dependent refuse-guard. `full_return`/`tax_table` are `None` for a year
142/// v1 doesn't support (fails the ReturnInputs arm closed).
143pub fn resolve_profile(
144    conn: &Connection,
145    year: i32,
146    pseudo_reconcile: bool,
147    full_return: Option<&FullReturnParams>,
148    tax_table: Option<&TaxTable>,
149) -> Result<Resolved, CliError> {
150    Ok(resolve_core(conn, year, pseudo_reconcile, full_return, tax_table)?.0)
151}
152
153/// The result of resolving AND screening a year's profile for a COMPUTING consumer (report / optimize /
154/// what-if / export). Unlike [`resolve_profile`] (which screens only the input-screenable rows), this also
155/// runs the **compute-dependent** refuse-guard ([`screen_compute_dependent`]) that needs the ledger `state`.
156pub enum ProfileOutcome {
157    /// Ready to compute with. `profile` is `None` only for a genuinely profile-less year (missing).
158    Ready {
159        profile: Option<TaxProfile>,
160        provenance: Provenance,
161    },
162    /// The year's full-return inputs cannot be computed — refused by a guard, or the year is unsupported.
163    /// The caller MUST surface `detail` and NOT compute (fail-closed). `detail` is user-facing.
164    Uncomputable { detail: String },
165}
166
167/// Resolve `year`'s profile through the single resolver AND apply BOTH refuse-guards (input-screenable +
168/// compute-dependent) fail-closed — the one entry point every computing consumer should use so the app
169/// never shows two different liabilities, or a wrong number, for one year (SPEC §4.12 / §4.10 / G4).
170pub fn resolve_and_screen(
171    conn: &Connection,
172    state: &LedgerState,
173    year: i32,
174    pseudo_reconcile: bool,
175    full_return: Option<&FullReturnParams>,
176    tax_table: Option<&TaxTable>,
177) -> Result<ProfileOutcome, CliError> {
178    // ONE precedence ladder (shared with `resolve_profile`): `resolve_core` fetches the ReturnInputs once
179    // and hands them back so the compute-dependent screen runs on the SAME bytes (M3), with no second copy
180    // of the precedence logic (N2).
181    let (resolved, ri) = resolve_core(conn, year, pseudo_reconcile, full_return, tax_table)?;
182    // Input-screenable refusal / unsupported year (resolve_core already screened those).
183    if resolved.is_return_inputs_uncomputable() {
184        return Ok(ProfileOutcome::Uncomputable {
185            detail: uncomputable_detail(year, resolved.refusal.as_ref()),
186        });
187    }
188    // Compute-dependent refuse rows (need `state`) — on the SAME ReturnInputs `resolve_core` fetched.
189    if resolved.provenance == Provenance::ReturnInputs {
190        // `provenance == ReturnInputs` ⇒ `resolve_core` returned the fetched `ri` (all three of its RI
191        // exits do) on a supported year (`params` is `Some`, else it would have been unsupported/uncomputable
192        // and returned above). If that invariant is ever broken by a refactor, fail CLOSED — never silently
193        // skip the compute-dependent screen and hand back a number (review M-r3-3).
194        let (Some(ri), Some(params)) = (ri.as_ref(), full_return) else {
195            return Ok(ProfileOutcome::Uncomputable {
196                detail: uncomputable_detail(year, None),
197            });
198        };
199        if let Some(refusal) = screen_compute_dependent(ri, state, year, params) {
200            return Ok(ProfileOutcome::Uncomputable {
201                detail: uncomputable_detail(year, Some(&refusal)),
202            });
203        }
204    }
205    Ok(ProfileOutcome::Ready {
206        profile: resolved.profile,
207        provenance: resolved.provenance,
208    })
209}
210
211/// The user-facing message for a `ReturnInputs` year that cannot be computed — a refusal (with its reason)
212/// or an unsupported year — both pointing at the `income clear` recovery.
213fn uncomputable_detail(year: i32, refusal: Option<&Refusal>) -> String {
214    match refusal {
215        Some(r) => format!(
216            "tax year {year} cannot be computed from its full-return inputs: {}; run \
217             `income clear --year {year}` to remove them and use a raw `tax-profile`",
218            r.detail
219        ),
220        None => format!(
221            "tax year {year} has full-return inputs, but full-return computation is not supported for \
222             {year} in this version (v1 supports TY2024); run `income clear --year {year}` to remove \
223             them and use a raw `tax-profile`"
224        ),
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
232    use btctax_core::tax::return_inputs::{ReturnInputs, W2};
233    use btctax_core::tax::tables::FullReturnTables;
234    use btctax_core::TaxTables;
235    use rust_decimal_macros::dec;
236
237    fn mem() -> Connection {
238        let c = Connection::open_in_memory().unwrap();
239        tax_profile::init_table(&c).unwrap();
240        return_inputs::init_table(&c).unwrap();
241        c
242    }
243    fn prof() -> TaxProfile {
244        let mut p = placeholder_tax_profile();
245        p.filing_status = FilingStatus::Mfj;
246        p.ordinary_taxable_income = dec!(120000);
247        p
248    }
249    // The bundled TY2024 full-return params + tax table (v1 supports TY2024 only).
250    fn ty2024() -> (BundledFullReturnTables, BundledTaxTables) {
251        (BundledFullReturnTables::load(), BundledTaxTables::load())
252    }
253    fn resolve(
254        c: &Connection,
255        year: i32,
256        pseudo: bool,
257        fr: &BundledFullReturnTables,
258        tt: &BundledTaxTables,
259    ) -> Resolved {
260        resolve_profile(
261            c,
262            year,
263            pseudo,
264            fr.full_return_for(year),
265            tt.table_for(year),
266        )
267        .unwrap()
268    }
269
270    #[test]
271    fn missing_when_nothing_stored_and_mode_off() {
272        let c = mem();
273        let (fr, tt) = ty2024();
274        let r = resolve(&c, 2024, false, &fr, &tt);
275        assert_eq!(r.provenance, Provenance::Missing);
276        assert!(r.profile.is_none());
277    }
278
279    #[test]
280    fn pseudo_placeholder_when_mode_on_and_nothing_stored() {
281        let c = mem();
282        let (fr, tt) = ty2024();
283        let r = resolve(&c, 2024, true, &fr, &tt);
284        assert_eq!(r.provenance, Provenance::PseudoPlaceholder);
285        assert_eq!(r.profile.unwrap(), placeholder_tax_profile());
286    }
287
288    #[test]
289    fn stored_profile_beats_pseudo() {
290        let c = mem();
291        let (fr, tt) = ty2024();
292        tax_profile::set(&c, 2024, &prof()).unwrap();
293        let r = resolve(&c, 2024, true, &fr, &tt); // mode ON, but a stored profile wins
294        assert_eq!(r.provenance, Provenance::StoredProfile);
295        assert_eq!(r.profile.unwrap(), prof());
296    }
297
298    #[test]
299    fn return_inputs_beats_stored_profile_and_derives_a_profile() {
300        let c = mem();
301        let (fr, tt) = ty2024();
302        tax_profile::set(&c, 2024, &prof()).unwrap();
303        let mut ri = ReturnInputs {
304            filing_status: FilingStatus::Single,
305            header: btctax_core::tax::testonly::not_a_dependent(),
306            w2s: vec![W2 {
307                box1_wages: dec!(100000),
308                box5_medicare_wages: dec!(100000),
309                ..Default::default()
310            }],
311            ..Default::default()
312        };
313        btctax_core::tax::testonly::answer_all_live_declarations(&mut ri); // P9: answer the always-live declarations
314        return_inputs::set(&c, 2024, &ri).unwrap();
315        let r = resolve(&c, 2024, true, &fr, &tt);
316        assert_eq!(r.provenance, Provenance::ReturnInputs); // highest precedence
317        assert!(!r.is_return_inputs_uncomputable());
318        // Derived (not the stored raw profile): Single, AGI = $100k − nothing.
319        let p = r.profile.unwrap();
320        assert_eq!(p.filing_status, FilingStatus::Single);
321        assert_eq!(p.magi_excluding_crypto, dec!(100000));
322        assert_ne!(p, prof());
323    }
324
325    /// [N2] The SPEC §4.12 precedence invariant on the LIVE path every consumer uses (`resolve_and_screen`),
326    /// not just `resolve_profile`: with BOTH a stored profile and `ReturnInputs` for one year, the DERIVED
327    /// profile wins — the two-liabilities cardinal sin (C1) must be impossible on the production ladder.
328    #[test]
329    fn resolve_and_screen_gives_return_inputs_precedence_over_stored() {
330        let c = mem();
331        let (fr, tt) = ty2024();
332        tax_profile::set(&c, 2024, &prof()).unwrap(); // a raw MFJ/$120k stored profile
333        let mut ri = ReturnInputs {
334            filing_status: FilingStatus::Single,
335            header: btctax_core::tax::testonly::not_a_dependent(),
336            w2s: vec![W2 {
337                box1_wages: dec!(100000),
338                box5_medicare_wages: dec!(100000),
339                ..Default::default()
340            }],
341            ..Default::default()
342        };
343        btctax_core::tax::testonly::answer_all_live_declarations(&mut ri); // P9: answer the always-live declarations
344        return_inputs::set(&c, 2024, &ri).unwrap();
345        let state = btctax_core::LedgerState::default();
346        match resolve_and_screen(
347            &c,
348            &state,
349            2024,
350            true,
351            fr.full_return_for(2024),
352            tt.table_for(2024),
353        )
354        .unwrap()
355        {
356            ProfileOutcome::Ready {
357                profile,
358                provenance,
359            } => {
360                assert_eq!(provenance, Provenance::ReturnInputs); // RI beats the stored profile
361                let p = profile.unwrap();
362                assert_eq!(p.filing_status, FilingStatus::Single); // DERIVED, not the MFJ stored one
363                assert_eq!(p.magi_excluding_crypto, dec!(100000));
364                assert_ne!(p, prof());
365            }
366            ProfileOutcome::Uncomputable { detail } => panic!("expected Ready, got: {detail}"),
367        }
368    }
369
370    #[test]
371    fn return_inputs_refused_by_guard_is_uncomputable_with_reason() {
372        let c = mem();
373        let (fr, tt) = ty2024();
374        // An affirmed HSA activity ⇒ the value-refusal refuses (Form 8889); derivation must NOT proceed.
375        // Answer the OTHER always-live declarations so the HSA refusal is the SOLE defect (else the
376        // registry's unanswered loop would refuse first, and this test would pass for a different reason —
377        // IMPL r1 Nit-2).
378        let mut ri = ReturnInputs {
379            filing_status: FilingStatus::Single,
380            header: btctax_core::tax::testonly::not_a_dependent(),
381            ..Default::default()
382        };
383        btctax_core::tax::testonly::answer_all_live_declarations(&mut ri);
384        ri.sch1.hsa_activity = Some(true);
385        return_inputs::set(&c, 2024, &ri).unwrap();
386        let r = resolve(&c, 2024, false, &fr, &tt);
387        assert_eq!(r.provenance, Provenance::ReturnInputs);
388        assert!(r.is_return_inputs_uncomputable());
389        assert!(r.profile.is_none());
390        assert!(r.refusal.is_some()); // carries WHY
391    }
392
393    #[test]
394    fn return_inputs_for_unsupported_year_is_uncomputable_without_refusal() {
395        let c = mem();
396        let (fr, tt) = ty2024();
397        return_inputs::set(&c, 2025, &ReturnInputs::default()).unwrap();
398        // 2025 has no bundled full-return tables (v1 = TY2024) → params/table are None → fail closed.
399        let r = resolve(&c, 2025, true, &fr, &tt);
400        assert_eq!(r.provenance, Provenance::ReturnInputs);
401        assert!(r.is_return_inputs_uncomputable());
402        assert!(r.refusal.is_none()); // not a refusal — an unsupported year
403    }
404}