Skip to main content

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}