1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Provenance {
25 ReturnInputs,
27 StoredProfile,
29 PseudoPlaceholder,
31 Missing,
33}
34
35#[derive(Debug, Clone)]
39pub struct Resolved {
40 pub profile: Option<TaxProfile>,
41 pub provenance: Provenance,
42 pub refusal: Option<Refusal>,
44}
45
46impl Resolved {
47 pub fn is_return_inputs_uncomputable(&self) -> bool {
51 self.provenance == Provenance::ReturnInputs && self.profile.is_none()
52 }
53}
54
55pub 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
72fn 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 if let Some(ri) = return_inputs::get(conn, year)? {
86 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 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 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 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 let r = Resolved {
131 profile: None,
132 provenance: Provenance::Missing,
133 refusal: None,
134 };
135 Ok((r, None))
136}
137
138pub 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
153pub enum ProfileOutcome {
157 Ready {
159 profile: Option<TaxProfile>,
160 provenance: Provenance,
161 },
162 Uncomputable { detail: String },
165}
166
167pub 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 let (resolved, ri) = resolve_core(conn, year, pseudo_reconcile, full_return, tax_table)?;
182 if resolved.is_return_inputs_uncomputable() {
184 return Ok(ProfileOutcome::Uncomputable {
185 detail: uncomputable_detail(year, resolved.refusal.as_ref()),
186 });
187 }
188 if resolved.provenance == Provenance::ReturnInputs {
190 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
211fn 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 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); 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); return_inputs::set(&c, 2024, &ri).unwrap();
315 let r = resolve(&c, 2024, true, &fr, &tt);
316 assert_eq!(r.provenance, Provenance::ReturnInputs); assert!(!r.is_return_inputs_uncomputable());
318 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 #[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(); 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); 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); let p = profile.unwrap();
362 assert_eq!(p.filing_status, FilingStatus::Single); 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 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()); }
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 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()); }
404}