btctax_cli/cmd/tax.rs
1//! `tax-profile` command helpers — set/show the per-year `TaxProfile` side-table entry.
2//! `report_tax_year` (Task 9) provides the standalone "tax owed / what-if" calculator.
3//! `report_tax_year` also runs the M4 carryforward-consistency advisory (Task 10).
4use crate::{return_inputs, tax_profile, CliError, Session};
5use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
6use btctax_core::tax::return_inputs::ReturnInputs;
7use btctax_core::tax::tables::FullReturnTables;
8use btctax_core::{
9 carryforward_consistency, compute_se_tax, compute_tax_year, schedule_d, se_net_income,
10 ScheduleDTotals, TaxOutcome, TaxProfile, TaxTables, Usd,
11};
12use btctax_store::Passphrase;
13use std::path::Path;
14
15/// Persist `p` as the tax profile for `year` in the vault at `vault`, then save.
16///
17/// **D-4 guard (SPEC §4.12):** when full-return `ReturnInputs` already exist for the year, a raw
18/// `tax-profile` would be IGNORED (`resolve_profile` gives `ReturnInputs` precedence). Refuse rather than
19/// silently store an unused figure — the two-sources-of-truth cardinal sin — unless `force` is set.
20pub fn set_profile(
21 vault: &Path,
22 pp: &Passphrase,
23 year: i32,
24 p: TaxProfile,
25 force: bool,
26) -> Result<(), CliError> {
27 let mut s = Session::open(vault, pp)?;
28 if !force && return_inputs::exists(s.conn(), year)? {
29 return Err(CliError::Usage(format!(
30 "tax year {year} already has full-return inputs (`income import`); a raw tax-profile would be \
31 ignored (full-return inputs take precedence). Re-run with --force to store it anyway."
32 )));
33 }
34 tax_profile::set(s.conn(), year, &p)?;
35 s.save()
36}
37
38/// Return the stored `TaxProfile` for `year` from the vault at `vault`, or `None`.
39pub fn show_profile(
40 vault: &Path,
41 pp: &Passphrase,
42 year: i32,
43) -> Result<Option<TaxProfile>, CliError> {
44 tax_profile::get(Session::open(vault, pp)?.conn(), year)
45}
46
47/// `income import` — parse a full-return [`ReturnInputs`] from a TOML file (offline; key order in the file
48/// is irrelevant to deserialization) and persist it in the `return_inputs` side-table for `year`.
49pub fn import_return_inputs(
50 vault: &Path,
51 pp: &Passphrase,
52 year: i32,
53 file: &Path,
54) -> Result<(), CliError> {
55 let text = std::fs::read_to_string(file)?;
56 let mut ri = parse_return_inputs_toml(&text)?;
57 let mut s = Session::open(vault, pp)?;
58 // ★ §6.2 (M-1): reconcile the crash-recovery draft BEFORE any committed-row read/write — clear a WIP
59 // draft (regenerable) so it can't shadow this write, or refuse a parked one (its sole copy).
60 crate::input_form_store::coherence_clear_or_refuse(s.conn(), year)?;
61 // §4 R3-M6 (Fable P4.9 r1 I2): `income import` is a whole-blob upsert, so a re-import would SILENTLY
62 // DROP a carryover that `report --write-carryover` computed onto this row. For QBI that is a fail-OPEN
63 // (losing the REIT/PTP loss carryforward OVERSTATES the QBI deduction ⇒ understates tax). So a
64 // **Computed** carryover-in SURVIVES an import that does not itself supply one; a carryover the TOML
65 // *does* supply is the user's and wins (as `User`, which the next write-back then refuses to clobber).
66 if let Some(existing) = return_inputs::get(s.conn(), year)? {
67 use btctax_core::tax::return_inputs::CarryProvenance;
68 let mut preserved: Vec<String> = Vec::new();
69 if ri.charitable_carryover_in.is_empty() {
70 let computed: Vec<_> = existing
71 .charitable_carryover_in
72 .iter()
73 .filter(|c| c.provenance == CarryProvenance::Computed)
74 .cloned()
75 .collect();
76 if !computed.is_empty() {
77 preserved.push(format!("{} charitable carryover item(s)", computed.len()));
78 ri.charitable_carryover_in = computed;
79 }
80 }
81 if ri.qbi.reit_ptp_carryforward_in.is_zero()
82 && existing.qbi.reit_ptp_carryforward_in > rust_decimal::Decimal::ZERO
83 && existing.qbi.reit_ptp_carryforward_in_provenance == CarryProvenance::Computed
84 {
85 preserved.push(format!(
86 "QBI REIT/PTP carryforward ${:.2}",
87 existing.qbi.reit_ptp_carryforward_in
88 ));
89 ri.qbi = existing.qbi.clone();
90 }
91 if !preserved.is_empty() {
92 eprintln!(
93 "note: kept the computed carryover already on the {year} row ({}) — your TOML did not \
94 supply one. To replace it, put the carryover in the TOML (it then counts as user-entered), \
95 or re-run `report --tax-year {} --write-carryover`.",
96 preserved.join("; "),
97 year - 1
98 );
99 }
100 }
101 return_inputs::set(s.conn(), year, &ri)?;
102 s.save()
103}
104
105/// Parse a `ReturnInputs` from TOML text (split out for testing).
106///
107/// ★ P9 §2.3 — REJECTS unknown keys, via `serde_ignored` rather than a hand-written key list (which would
108/// be the exact drift-prone hand-wiring P9 abolishes). `serde_ignored` reports every ignored path DURING
109/// the same deserialization, so the key set is DERIVED from the type: no list to forget, and `[[w2s]]`
110/// arrays, nested tables and comments all work for free. This binds ONLY the CLI's TOML import — the
111/// stored-JSON path (`return_inputs::get`) keeps its documented forward-compat and is untouched. Without
112/// this, a faithfully-transcribed `box13_retirement_plan` (a deleted field) or a `hsa_present` (the §2.4
113/// rename) would import CLEAN and silently vanish — no error, no trace even in `income show`.
114fn parse_return_inputs_toml(text: &str) -> Result<ReturnInputs, CliError> {
115 // Parse to the TOML tree FIRST (toml's streaming deserializer + serde_ignored mishandles arrays of
116 // tables), then run `serde_ignored` over the in-memory `Value` to collect every unknown path.
117 let value: toml::Value = toml::from_str(text)
118 .map_err(|e| CliError::Usage(format!("invalid ReturnInputs TOML: {e}")))?;
119 let mut ignored: Vec<String> = Vec::new();
120 let ri: ReturnInputs = serde_ignored::deserialize(value, |path| ignored.push(path.to_string()))
121 .map_err(|e| CliError::Usage(format!("invalid ReturnInputs TOML: {e}")))?;
122 if !ignored.is_empty() {
123 return Err(CliError::Usage(format!(
124 "unknown key(s) in the ReturnInputs TOML: {}. btctax does not honor these — likely a typo or a \
125 field removed in this version (e.g. `hsa_present` was RENAMED to `sch1.hsa_activity`; \
126 `box13_retirement_plan` and `ssn_valid_for_employment` were REMOVED). Fix or delete them, then \
127 re-run `btctax income import` — a silently-ignored key would drop data you meant to enter.",
128 ignored.join(", ")
129 )));
130 }
131 Ok(ri)
132}
133
134/// Redact an SSN/ITIN to `***-**-NNNN` (last 4 digits), or empty/`***-**-****` when too short (review I5).
135fn mask_ssn(ssn: &str) -> String {
136 if ssn.is_empty() {
137 return String::new();
138 }
139 let digits: String = ssn.chars().filter(|c| c.is_ascii_digit()).collect();
140 if digits.len() >= 4 {
141 format!("***-**-{}", &digits[digits.len() - 4..])
142 } else {
143 "***-**-****".to_string()
144 }
145}
146
147/// A DISPLAY copy of `ReturnInputs` with all SSNs and the IP-PIN redacted (the stored value is never
148/// mutated). Used by `income show` so cleartext PII never reaches stdout/scrollback/pipes (SPEC §4.2).
149fn mask_pii(ri: &ReturnInputs) -> ReturnInputs {
150 let mut m = ri.clone();
151 m.header.taxpayer.ssn = mask_ssn(&m.header.taxpayer.ssn);
152 if let Some(sp) = m.header.spouse.as_mut() {
153 sp.ssn = mask_ssn(&sp.ssn);
154 }
155 for d in &mut m.header.dependents {
156 d.ssn = mask_ssn(&d.ssn);
157 }
158 if m.header.ip_pin.is_some() {
159 m.header.ip_pin = Some("***".to_string());
160 }
161 m
162}
163
164/// `income clear` — remove the stored full-return inputs for `year` (recovery path so a year with
165/// `ReturnInputs` isn't a dead end while derivation is pending — review I3). Returns whether a row existed.
166pub fn clear_return_inputs(vault: &Path, pp: &Passphrase, year: i32) -> Result<bool, CliError> {
167 let mut s = Session::open(vault, pp)?;
168 // ★ §6.2 (M-1): a parked draft is the sole copy of a screened return — refuse rather than let this
169 // clear leave it silently orphaned; a WIP draft is cleared alongside the committed-row delete.
170 crate::input_form_store::coherence_clear_or_refuse(s.conn(), year)?;
171 let removed = return_inputs::delete(s.conn(), year)?;
172 s.save()?;
173 Ok(removed)
174}
175
176/// `income show` — the stored [`ReturnInputs`] for `year` as pretty JSON with PII redacted, or `None`.
177/// (JSON, not TOML: serde-toml requires scalar keys before nested tables, which the nested model violates;
178/// a TOML round-trip-out is a follow-on. Import accepts TOML.)
179pub fn show_return_inputs(
180 vault: &Path,
181 pp: &Passphrase,
182 year: i32,
183) -> Result<Option<String>, CliError> {
184 let ri = return_inputs::get(Session::open(vault, pp)?.conn(), year)?;
185 ri.map(|ri| {
186 let mkerr = |e: serde_json::Error| CliError::BadConfigValue {
187 key: format!("return_inputs[{year}]"),
188 value: e.to_string(),
189 };
190 // M-1 (DONE, post-v0.7.0): `serde_json` `preserve_order` is enabled workspace-wide, so routing
191 // through `to_value` to host the DOB transform now preserves the ReturnInputs struct's declared
192 // field order (curated) instead of sorting keys alphabetically. `income show` is display-only and
193 // never parsed (M8); typed serde (the STORED serialization) is field-ordered regardless, so the
194 // persisted bytes + fingerprints are unaffected by the flip.
195 let mut val = serde_json::to_value(mask_pii(&ri)).map_err(mkerr)?;
196 format_dobs_readable(&mut val); // UX-P1-5: render date_of_birth as MM/DD/YYYY, not raw [year, ordinal]
197 serde_json::to_string_pretty(&val).map_err(mkerr)
198 })
199 .transpose()
200}
201
202/// UX-P1-5: `income show`'s JSON serializes each `time::Date` as a raw `[year, ordinal-day]` array (e.g.
203/// `[2012, 106]`), which no filer reads as a calendar date. Rewrite every `date_of_birth` value in the
204/// DISPLAY tree to a human `MM/DD/YYYY` string. Display-only — the STORED serialization is untouched
205/// (`income show` is for viewing, never parsed back — M8).
206fn format_dobs_readable(v: &mut serde_json::Value) {
207 use time::macros::format_description;
208 match v {
209 serde_json::Value::Object(map) => {
210 for (k, val) in map.iter_mut() {
211 if k == "date_of_birth" {
212 // Extract MM/DD/YYYY (the closure's immutable borrow of `val` ends before the write).
213 let readable = val.as_array().filter(|a| a.len() == 2).and_then(|a| {
214 let y = a[0].as_i64()? as i32;
215 let o = a[1].as_u64()? as u16;
216 let d = time::Date::from_ordinal_date(y, o).ok()?;
217 d.format(&format_description!("[month]/[day]/[year]")).ok()
218 });
219 if let Some(s) = readable {
220 *val = serde_json::Value::String(s);
221 continue;
222 }
223 }
224 format_dobs_readable(val);
225 }
226 }
227 serde_json::Value::Array(arr) => arr.iter_mut().for_each(format_dobs_readable),
228 _ => {}
229 }
230}
231
232/// The full `report --tax-year` bundle, in print order. A NAMED STRUCT (was a 7-tuple) so a new field can
233/// never silently transpose with an existing one at a call site (Fable IMPL-P4 r1 N1, `p4-r1-n1`).
234#[derive(Debug)]
235pub struct TaxYearReport {
236 /// The frozen crypto-DELTA engine's outcome for the year.
237 pub outcome: TaxOutcome,
238 /// M4 carryforward-consistency advisory (non-gating).
239 pub advisory: Option<String>,
240 /// RAW pre-netting Schedule D part totals.
241 pub schedule_d: ScheduleDTotals,
242 /// Standalone Form 709 gift advisory.
243 pub gift_advisory: Option<String>,
244 /// Standalone Schedule SE §1401 section.
245 pub schedule_se: Option<String>,
246 /// §170(f)(11)(F) year-aggregate donation appraisal advisory.
247 pub donation_appraisal: Option<String>,
248 /// Conservative-filing (D-9) advisory: per-disposal tranche dip lines + per-wallet method-inversion
249 /// warnings. Provenance-neutral; non-gating (never affects the outcome or exit code).
250 pub tranche_advisory: Option<String>,
251 /// The §6 dual-report block (absolute filed return + crypto delta + the P5 advisories). `Some` only
252 /// for a `ReturnInputs`-provenance year; `None` on the delta-only path.
253 pub dual_report: Option<String>,
254 /// UX-P4-1: the pseudo-disclosure channel for this year's figures — the full §3.1 predicate
255 /// (`pseudo_active() OR PseudoPlaceholder`, Synthetic-wins). Drives the banner + `[PSEUDO]` suffix on
256 /// every number-bearing surface (delta report, dual-report absolute totals, TUI Tax tab) and the
257 /// fail-closed `--write-carryover` gate; `None` when the figures are not pseudo-contributed.
258 pub pseudo_contributed: crate::render::PseudoDisclosure,
259}
260
261/// Task 9 (B.5) + Task 10 (M4) + P2-D Task 2 + Chunk-1 D2 + Chunk-3a: load events + project once,
262/// read the year's `TaxProfile` + `BundledTaxTables`, call `compute_tax_year`, and assemble the
263/// standalone Schedule D / Form 709 / Schedule SE artifacts + the M4 carryforward-consistency
264/// advisory + the §170(f)(11)(F) year-aggregate donation appraisal advisory. See [`TaxYearReport`]
265/// for the returned bundle. The advisory is `Some(msg)` iff BOTH the current-year and the prior-year
266/// profiles exist AND the prior-year computes successfully AND the declared `carryforward_in` does
267/// not match the prior year's `carryforward_out`. The advisory and the Schedule SE figure are
268/// **never** hard blockers and do **not** change the exit code (non-gating).
269///
270/// `prior_taxable_gifts`: cumulative prior-year TAXABLE gifts (post-annual-exclusion Form 709
271/// amounts), not gross gifts. Default $0 (caller passes $0 when the flag is not provided).
272pub fn report_tax_year(
273 vault: &Path,
274 pp: &Passphrase,
275 year: i32,
276 prior_taxable_gifts: Usd,
277) -> Result<TaxYearReport, CliError> {
278 let s = Session::open(vault, pp)?;
279 let (events, state, cfg) = s.load_events_and_project()?;
280 // Pseudo-reconcile (sub-project 2, [R0-M6]): when the mode is ON and the year has NO stored profile,
281 // inject a CLI-layer PLACEHOLDER profile (single filer, $0 income/MAGI/qual-div) so the estimate can
282 // proceed with zero setup. This clears `TaxProfileMissing` ONLY — it is injected AFTER the projection,
283 // so it never touches `state.blockers` and thus can NEVER clear the Hard `TaxYearNotComputable` gate
284 // (compute.rs checks Hard blockers BEFORE the profile branch). A real stored profile always wins.
285 // Single resolver + BOTH refuse-guards, fail-closed (SPEC §4.12 / §4.10 / G4): ReturnInputs (derived,
286 // input- AND compute-screened) → stored TaxProfile → pseudo → missing. `resolve_and_screen` is the one
287 // entry point every computing consumer shares so the app never shows two liabilities for one year.
288 let tables = BundledTaxTables::load();
289 let fr_tables = BundledFullReturnTables::load();
290 let (profile, provenance) = match crate::resolve::resolve_and_screen(
291 s.conn(),
292 &state,
293 year,
294 cfg.pseudo_reconcile,
295 fr_tables.full_return_for(year),
296 tables.table_for(year),
297 )? {
298 crate::resolve::ProfileOutcome::Uncomputable { detail } => {
299 return Err(CliError::Usage(detail))
300 }
301 crate::resolve::ProfileOutcome::Ready {
302 profile,
303 provenance,
304 } => (profile, provenance),
305 };
306 let outcome = compute_tax_year(&events, &state, year, profile.as_ref(), &tables);
307
308 // UX-P4-1: the pseudo-disclosure channel for the figures below. `Synthetic` (a pseudo synthetic
309 // lot/FMV feeds the number) wins over `Placeholder` (computed on the all-$0 placeholder profile) — the
310 // two are mutually exclusive by precedence though the states can co-occur (SPEC §3.1). Read from the
311 // LIVE pseudo-ON projected state + provenance (NOT a pseudo-OFF view — that would zero the count and
312 // silence the banner, reinstating the answered-ness false-negative).
313 let pseudo_contributed = if state.pseudo_active() {
314 crate::render::PseudoDisclosure::Synthetic
315 } else if provenance == crate::resolve::Provenance::PseudoPlaceholder {
316 crate::render::PseudoDisclosure::Placeholder
317 } else {
318 crate::render::PseudoDisclosure::None
319 };
320
321 // §6 DUAL REPORT (SPEC §6 / §5 stages 1–9): the absolute filed return, side-by-side with the crypto
322 // delta above. Only meaningful for a `ReturnInputs`-provenance year — the input-screen + compute-
323 // dependent screen have already passed inside the resolver (else we returned `Uncomputable`), and
324 // TY2024 is the only year with `FullReturnParams` (so both `Option`s are `Some` here). The absolute
325 // path adds `screen_absolute` (QBI-over-threshold / AMT / TI≤0-with-carryforward), which — unlike the
326 // delta path — can refuse the ABSOLUTE return while the delta still computes; render that as a note.
327 let dual_report: Option<String> = if provenance == crate::resolve::Provenance::ReturnInputs {
328 match (
329 crate::return_inputs::get(s.conn(), year)?,
330 fr_tables.full_return_for(year),
331 tables.table_for(year),
332 ) {
333 (Some(ri), Some(params), Some(table)) => {
334 let ar = btctax_core::assemble_absolute(&ri, &state, params, table, year);
335 match btctax_core::screen_absolute(&ri, &ar, params) {
336 Some(refusal) => Some(format!(
337 "\n═══ Absolute filed return (Form 1040) — tax year {year} ═══\n \
338 Profile source: {}\n NOT COMPUTABLE [{:?}]: {}\n",
339 crate::render::provenance_label(provenance),
340 refusal.reason,
341 refusal.detail
342 )),
343 None => {
344 // P5: the full-return block carries the §3.4 conservative-omission advisories
345 // (CTC/ODC, EIC, forfeited §63(f) aged box) + the FBAR / charitable-donee
346 // disclosures. Non-gating: they never change a number or the exit code.
347 //
348 // ★ P6.3b: the block renders the PRINTED figures — exactly what the filed PDF
349 // carries. `assemble_printed_forms` is infallible and PII-free, so a household
350 // that has entered no identity yet still sees the real numbers (only the filable
351 // ARTIFACT needs a name and an SSN).
352 let details = s.donation_details()?;
353 let printed = btctax_core::tax::packet::assemble_printed_forms(
354 &ri, &state, &details, &ar, table, year, &events,
355 );
356 let mut block = crate::render::render_dual_report(
357 year,
358 &ar,
359 &printed,
360 &outcome,
361 provenance,
362 pseudo_contributed,
363 );
364 let advs = btctax_core::tax::advisories::advisories_for(
365 &ri, &state, &ar, params, year,
366 );
367 block.push_str(&crate::render::render_advisories(&advs));
368 Some(block)
369 }
370 }
371 }
372 _ => {
373 // ReturnInputs provenance implies the inputs + TY2024 params/table are present (else the
374 // resolver returned Uncomputable) — fail loud in debug if that invariant ever breaks.
375 debug_assert!(
376 false,
377 "ReturnInputs provenance but missing inputs/params/table for year {year}"
378 );
379 None
380 }
381 }
382 } else {
383 None
384 };
385 // P2-B: the RAW pre-netting Schedule D part totals for the same year, from the same projection.
386 let sched_d = schedule_d(&state, year);
387 // P2-C Task 3 + Chunk-3a: standalone Form 709 gift advisory + §2505 lifetime-exclusion
388 // consumption (does NOT feed engine B). prior_taxable_gifts comes from the CLI flag.
389 let gift_advisory =
390 crate::render::render_gift_advisory(&state, year, prior_taxable_gifts, &tables);
391 // P2-D Task 2: standalone Schedule SE §1401 SE-tax figure (STANDALONE — does NOT feed engine B;
392 // `total_federal_tax_attributable` is UNCHANGED by SE tax, D5). Requires the year's filing status
393 // (from the profile). Business SE income present but no bundled table → the render emits a
394 // "wage base unavailable" note (no silent drop); no business SE income → no Schedule SE section.
395 let schedule_se = match profile.as_ref() {
396 Some(p) => {
397 let gross_se = se_net_income(&state, year);
398 let table_opt = tables.table_for(year);
399 let table_present = table_opt.is_some();
400 let se_result = table_opt.and_then(|t| {
401 compute_se_tax(
402 &state,
403 year,
404 p.filing_status,
405 t,
406 p.w2_ss_wages,
407 p.w2_medicare_wages,
408 p.schedule_c_expenses,
409 )
410 });
411 crate::render::render_schedule_se(
412 year,
413 se_result.as_ref(),
414 gross_se,
415 table_present,
416 p.schedule_c_expenses,
417 p.w2_ss_wages,
418 p.w2_medicare_wages,
419 )
420 }
421 None => None,
422 };
423 // Chunk-1 D2: §170(f)(11)(F) year-aggregate donation appraisal advisory (STANDALONE — does NOT
424 // enter state.advisory / the blocker set; render-time only, consistent with the standalone-forms
425 // pattern). Non-gating; does not affect the exit code.
426 let donation_appraisal_advisory =
427 crate::render::render_donation_appraisal_advisory(&state, year);
428
429 // Conservative-filing (P3 / D-9): tranche dip + method-inversion advisory. Non-gating; render-time
430 // only, like the standalone-forms advisories above. The shared core assembler keeps the CLI + TUI
431 // surfaces identical.
432 let tranche_advisory = btctax_core::conservative::tranche_report_advisory(
433 &state,
434 &events,
435 s.prices(),
436 &cfg,
437 year,
438 profile.as_ref(),
439 &tables,
440 );
441
442 // M4 carryforward consistency advisory (Task 10): only when both this year's profile AND
443 // the prior year's profile exist AND the prior year is Computed. Never a hard blocker.
444 let advisory: Option<String> = if let Some(p) = &profile {
445 // Prior-year profile through the same resolver (ReturnInputs-derived too); the M4 advisory is
446 // non-gating, so an uncomputable/refused prior year just skips it rather than failing the report.
447 let prior_profile = match s.resolve_screened(&state, year - 1, &tables)? {
448 crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
449 crate::resolve::ProfileOutcome::Uncomputable { .. } => None,
450 };
451 if let Some(prev_p) = prior_profile {
452 let prior_out = compute_tax_year(&events, &state, year - 1, Some(&prev_p), &tables);
453 if let TaxOutcome::Computed(prev) = prior_out {
454 carryforward_consistency(
455 Some(&prev.carryforward_out),
456 &p.capital_loss_carryforward_in,
457 )
458 } else {
459 None
460 }
461 } else {
462 None
463 }
464 } else {
465 None
466 };
467
468 Ok(TaxYearReport {
469 outcome,
470 advisory,
471 schedule_d: sched_d,
472 gift_advisory,
473 schedule_se,
474 donation_appraisal: donation_appraisal_advisory,
475 tranche_advisory,
476 dual_report,
477 pseudo_contributed,
478 })
479}
480
481/// §4 R3-M6 carryover write-back — persist year `year`'s computed charitable + QBI-REIT/PTP carryover-OUT
482/// as year (`year+1`)'s carryover-IN in the side-table. Only for a `ReturnInputs`-provenance full-return
483/// year (else there is no absolute return). Errors if the absolute return refuses (`screen_absolute`) or if
484/// a user-entered next-year carryover would be overwritten without `force`. Returns a human summary.
485pub fn write_back_carryover(
486 vault: &Path,
487 pp: &Passphrase,
488 year: i32,
489 force: bool,
490) -> Result<String, CliError> {
491 let mut s = Session::open(vault, pp)?;
492 // ★ §6.2 (M-1): write-back reads AND writes the year+1 committed row, so it reconciles the year+1
493 // draft here — before the year+1 read below, which early-returns on an absent row (a parked year has
494 // none) and would otherwise shadow the parked-refuse remedy.
495 crate::input_form_store::coherence_clear_or_refuse(s.conn(), year + 1)?;
496 let (events, state, cfg) = s.load_events_and_project()?;
497 let tables = BundledTaxTables::load();
498 let fr_tables = BundledFullReturnTables::load();
499 let (Some(params), Some(table)) = (fr_tables.full_return_for(year), tables.table_for(year))
500 else {
501 return Err(CliError::Usage(format!(
502 "no full-return tables for {year} — carryover write-back needs a supported tax year (TY2024)"
503 )));
504 };
505 // Must be a ReturnInputs-provenance year with both refuse screens passed (fail-closed).
506 let (profile, provenance) = match crate::resolve::resolve_and_screen(
507 s.conn(),
508 &state,
509 year,
510 cfg.pseudo_reconcile,
511 Some(params),
512 Some(table),
513 )? {
514 crate::resolve::ProfileOutcome::Uncomputable { detail } => {
515 return Err(CliError::Usage(detail))
516 }
517 crate::resolve::ProfileOutcome::Ready {
518 profile,
519 provenance,
520 } => (profile, provenance),
521 };
522 if provenance != crate::resolve::Provenance::ReturnInputs {
523 return Err(CliError::Usage(format!(
524 "carryover write-back needs full-return inputs for {year} (`income import`); the resolved \
525 profile source is {provenance:?}"
526 )));
527 }
528 // UX-P4-1 surface 4 (SPEC §3.1 clause 4) [T-C1 + G2-NEW-4]: NEVER persist a carryover derived from a
529 // pseudo-tainted OR hard-blocked ledger into year+1's stored inputs. Next year `pseudo_active()` is
530 // false and the UX-P4-1 banner correctly does not fire — so an unflagged, deliberately-fictional (or
531 // unanswerable) figure would ride into a real input. Fail-closed, consistent with the export gate.
532 // (4a) At this gate the `PseudoPlaceholder` disjunct is structurally inert (provenance is ReturnInputs,
533 // just checked), so `pseudo_active()` is the operative half of the §3.1 predicate.
534 if state.pseudo_active() {
535 return Err(CliError::Usage(format!(
536 "carryover write-back REFUSED for {year}: pseudo-reconcile mode is contributing synthetic \
537 default(s), so the derived carryover is an ESTIMATE — persisting it as {next}'s real input \
538 would launder a deliberately-synthetic figure. Resolve the pseudo entries (or turn the mode \
539 off) first.",
540 next = year + 1
541 )));
542 }
543 // (4b) A `NotComputable` crypto-delta means the ledger carries Hard blockers the engine refuses to
544 // answer for; a carryover assembled over that state must not be persisted (the same laundering class
545 // minus the pseudo mechanism).
546 if let btctax_core::TaxOutcome::NotComputable(b) =
547 compute_tax_year(&events, &state, year, profile.as_ref(), &tables)
548 {
549 return Err(CliError::Usage(format!(
550 "carryover write-back REFUSED for {year}: the crypto-delta ledger is NOT COMPUTABLE [{:?}]: {} \
551 — a carryover from an unanswerable ledger must not be written into {next}'s inputs.",
552 b.kind,
553 b.detail,
554 next = year + 1
555 )));
556 }
557 let ri = crate::return_inputs::get(s.conn(), year)?
558 .ok_or_else(|| CliError::Usage(format!("no return_inputs stored for {year}")))?;
559 let ar = btctax_core::assemble_absolute(&ri, &state, params, table, year);
560 if let Some(refusal) = btctax_core::screen_absolute(&ri, &ar, params) {
561 return Err(CliError::Usage(format!(
562 "the {year} absolute return is not computable [{:?}]: {} — carryover not written",
563 refusal.reason, refusal.detail
564 )));
565 }
566 // SPEC §4 R3-M6 writes the carryover "as year (Y+1)'s `*_carryover_in` **on that row**" — the row must
567 // ALREADY exist. Fabricating one would put a `ReturnInputs` row at the TOP of the §4.12 precedence
568 // ladder for a year v1 has no full-return tables for (Y+1 is always 2025 in v1), which fails closed and
569 // would make that year uncomputable — shadowing a stored `TaxProfile` the user was planning with, and
570 // blocking `tax-profile --year Y+1` via the D-4 guard (Fable P4.9 r1 I1).
571 let next = crate::return_inputs::get(s.conn(), year + 1)?.ok_or_else(|| {
572 CliError::Usage(format!(
573 "year {next} has no full-return inputs yet — the carryover is written onto that row, so import \
574 it first (`income import --year {next} --file <toml>`) and then re-run `--write-carryover`. \
575 (Creating the row here would shadow any stored tax-profile for {next} and make it uncomputable \
576 in this version, which supports full returns for TY2024 only.)",
577 next = year + 1
578 ))
579 })?;
580 let updated =
581 btctax_core::apply_carryover_writeback(&ar, next, force).map_err(CliError::Usage)?;
582 crate::return_inputs::set(s.conn(), year + 1, &updated)?;
583 s.save()?;
584 Ok(format!(
585 "carryover written back to {}: {} charitable carryover item(s); QBI REIT/PTP carryforward ${:.2}",
586 year + 1,
587 updated.charitable_carryover_in.len(),
588 updated.qbi.reit_ptp_carryforward_in
589 ))
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use btctax_core::tax::return_inputs::CharitableClass;
596 use btctax_core::FilingStatus;
597 use rust_decimal_macros::dec;
598
599 /// Shared temp-vault fixture (mirrors `input_form_store.rs`'s helper, M-3): `create` + drop releases
600 /// the store single-instance lock so a later `Session::open` (here, the one inside the command under
601 /// test) can re-acquire it. The `TempDir` guard MUST be kept alive by the caller.
602 fn tmp_vault() -> (tempfile::TempDir, std::path::PathBuf, Passphrase) {
603 let dir = tempfile::tempdir().unwrap();
604 let path = dir.path().join("vault.pgp");
605 {
606 let _ = Session::create(&path, &Passphrase::new("test-pass".into())).unwrap();
607 }
608 (dir, path, Passphrase::new("test-pass".into()))
609 }
610
611 /// ★ §6.2 wiring — `income clear` REFUSES a year that holds a PARKED draft (the draft is the sole copy
612 /// of a screened return, C-1), and never destroys it. `clear_return_inputs` needs no pre-existing
613 /// committed row, so the coherence call is the cheapest reachable parked-refuse: this test pins the
614 /// wiring into a real writer. Remove the `coherence_clear_or_refuse` call from `clear_return_inputs`
615 /// and this goes red (mutation-check b).
616 #[test]
617 fn income_clear_refuses_a_parked_draft_and_preserves_it() {
618 let (_dir, path, pp) = tmp_vault();
619 let ri = ReturnInputs {
620 filing_status: FilingStatus::Single,
621 ..Default::default()
622 };
623 {
624 let mut s = Session::open(&path, &pp).unwrap();
625 crate::input_form_store::set_draft_row(s.conn(), 2024, &ri, true).unwrap(); // parked
626 s.save().unwrap();
627 }
628 let err = clear_return_inputs(&path, &pp, 2024).unwrap_err();
629 assert!(
630 matches!(err, CliError::ParkedDraftBlocksWrite { year: 2024 }),
631 "income clear must refuse a parked-draft year, got {err:?}"
632 );
633 // the parked draft is STILL present — a committed-row write never silently destroys it.
634 let s = Session::open(&path, &pp).unwrap();
635 assert!(
636 crate::input_form_store::draft_exists(s.conn(), 2024).unwrap(),
637 "a refused clear must leave the parked draft intact"
638 );
639 }
640
641 /// A representative `income import` TOML deserializes into `ReturnInputs` — exercises money-as-string
642 /// (serde-str), the FilingStatus/Owner/CharitableClass enum reprs, and nested `[[w2s]]` / charitable
643 /// arrays. This is the risky part of the import path (field-order in the file is irrelevant).
644 #[test]
645 fn return_inputs_toml_parses() {
646 let text = r#"
647 filing_status = "Mfj"
648
649 [[w2s]]
650 owner = "taxpayer"
651 employer = "ACME"
652 box1_wages = "82000"
653 box2_fed_withheld = "9100"
654 box5_medicare_wages = "82000"
655
656 [[div_1099]]
657 payer = "Vanguard"
658 box1a_ordinary = "3400"
659 box1b_qualified = "3100"
660
661 [schedule_a]
662 mortgage_interest_1098 = "11200"
663 salt_real_estate = "6800"
664
665 [[schedule_a.charitable]]
666 class = "cash60"
667 amount = "2500"
668
669 [payments]
670 estimated_tax_payments = "6000"
671 "#;
672 let ri = parse_return_inputs_toml(text).unwrap();
673 assert_eq!(ri.filing_status, FilingStatus::Mfj);
674 assert_eq!(ri.w2s.len(), 1);
675 assert_eq!(ri.w2s[0].box1_wages, dec!(82000));
676 assert_eq!(ri.w2s[0].box5_medicare_wages, dec!(82000));
677 assert_eq!(ri.div_1099[0].box1b_qualified, dec!(3100));
678 let a = ri.schedule_a.as_ref().unwrap();
679 assert_eq!(a.mortgage_interest_1098, dec!(11200));
680 assert_eq!(a.charitable[0].class, CharitableClass::Cash60);
681 assert_eq!(a.charitable[0].amount, dec!(2500));
682 assert_eq!(ri.payments.estimated_tax_payments, dec!(6000));
683 }
684
685 /// `income show` redacts SSNs and the IP-PIN in a DISPLAY copy; the stored value is untouched (I5).
686 #[test]
687 fn mask_ssn_and_pii_redacts() {
688 assert_eq!(mask_ssn("123-45-6789"), "***-**-6789");
689 assert_eq!(mask_ssn("123456789"), "***-**-6789");
690 assert_eq!(mask_ssn(""), "");
691 assert_eq!(mask_ssn("12"), "***-**-****");
692 let mut ri = ReturnInputs::default();
693 ri.header.taxpayer.ssn = "123-45-6789".into();
694 ri.header.ip_pin = Some("999999".into());
695 ri.header.spouse = Some(btctax_core::tax::return_inputs::Person {
696 ssn: "987-65-4321".into(),
697 ..Default::default()
698 });
699 ri.header.dependents = vec![btctax_core::tax::return_inputs::Dependent {
700 ssn: "111-22-3333".into(),
701 ..Default::default()
702 }];
703 let masked = mask_pii(&ri);
704 assert_eq!(masked.header.taxpayer.ssn, "***-**-6789");
705 assert_eq!(masked.header.spouse.as_ref().unwrap().ssn, "***-**-4321");
706 assert_eq!(masked.header.dependents[0].ssn, "***-**-3333");
707 assert_eq!(masked.header.ip_pin.as_deref(), Some("***"));
708 assert_eq!(ri.header.taxpayer.ssn, "123-45-6789"); // original untouched
709 assert_eq!(ri.header.spouse.as_ref().unwrap().ssn, "987-65-4321"); // original untouched
710 }
711
712 /// Malformed TOML is a typed `Usage` error, never a panic.
713 #[test]
714 fn bad_toml_is_typed_error() {
715 assert!(matches!(
716 parse_return_inputs_toml("not = = toml").unwrap_err(),
717 CliError::Usage(_)
718 ));
719 }
720
721 /// ★ P9 §2.3 / §3.5 (r7 I-2) — `income import` REJECTS unknown TOML keys via `serde_ignored`, not a
722 /// hand-written key list. A TOML carrying `hsa_present` (the §2.4 rename) AND `box13_retirement_plan`
723 /// (a deleted dead field — a real W-2 box 13 faithfully transcribed) must REFUSE naming BOTH, rather
724 /// than import clean and silently vanish (the exact hole §2.3 exists to close). Mutation: revert to a
725 /// bare `toml::from_str` ⇒ this fails.
726 #[test]
727 fn income_import_rejects_unknown_toml_keys_naming_each() {
728 let text = r#"
729 filing_status = "Single"
730 hsa_present = false
731
732 [[w2s]]
733 owner = "taxpayer"
734 employer = "ACME"
735 box1_wages = "50000"
736 box2_fed_withheld = "8000"
737 box13_retirement_plan = true
738 "#;
739 let err = parse_return_inputs_toml(text).unwrap_err();
740 let msg = format!("{err}");
741 assert!(
742 msg.contains("hsa_present"),
743 "must name the renamed key: {msg}"
744 );
745 assert!(
746 msg.contains("box13_retirement_plan"),
747 "must name the deleted dead field so a transcribed W-2 box 13 can't silently vanish: {msg}"
748 );
749 }
750}