Skip to main content

btctax_cli/cmd/
admin.rs

1//! `config`, `export-snapshot` (FR10), `backup-key` — administrative commands. Config surfaces the TP8
2//! (c)/(b) treatment + the pre-2025 lot method; export/backup arrive in Task 15.
3use crate::cli::FormArg;
4use crate::config::{set_fee_treatment, set_pre2025_method as config_set_pre2025_method};
5use crate::render::write_csv_exports;
6use crate::{require_attestation, CliConfig, CliError, Session};
7use btctax_adapters::BundledTaxTables;
8use btctax_core::{
9    compute_se_tax, se_net_income, FeeTreatment, LotMethod, ScheduleDPart, Severity, TaxTables,
10    Usd, DIGITAL_ASSET_8949_FIRST_YEAR,
11};
12use btctax_forms::Form1040Inputs;
13use btctax_store::{fsperms, Passphrase};
14use std::path::{Path, PathBuf};
15
16/// Outcome of the CLI `export_snapshot` wrapper: the written snapshot path plus the count of
17/// UNRESOLVED Hard blockers (`severity() == Hard`) in the projection. Any Hard blocker gates EVERY
18/// tax year (`compute_tax_year` short-circuits on the projection-wide first Hard blocker), so
19/// `unresolved_hard > 0` means every exported Form 8949 / Schedule D / projection CSV is
20/// INFORMATIONAL, not final — the `ExportSnapshot` main.rs arm warns on stderr accordingly. A
21/// fully-resolved ledger yields `0` and no warning. Advisory blockers (incl. `PseudoReconcileActive`,
22/// `SelfTransferInboundZeroBasis`) never count.
23#[derive(Debug, Clone)]
24pub struct ExportReport {
25    pub path: PathBuf,
26    pub unresolved_hard: usize,
27}
28
29pub fn show_config(vault_path: &Path, pp: &Passphrase) -> Result<CliConfig, CliError> {
30    Session::open(vault_path, pp)?.config()
31}
32
33/// Persist a new TP8 fee treatment (None = leave unchanged), then return the resulting config.
34pub fn set_config(
35    vault_path: &Path,
36    pp: &Passphrase,
37    fee_treatment: Option<FeeTreatment>,
38) -> Result<CliConfig, CliError> {
39    let mut session = Session::open(vault_path, pp)?;
40    if let Some(t) = fee_treatment {
41        set_fee_treatment(session.conn(), t)?;
42        session.save()?;
43    }
44    session.config()
45}
46
47/// Persist the pre-2025 lot identification method and attestation flag, then return the resulting config.
48pub fn set_pre2025_method(
49    vault_path: &Path,
50    pp: &Passphrase,
51    m: LotMethod,
52    attested: bool,
53) -> Result<CliConfig, CliError> {
54    let mut session = Session::open(vault_path, pp)?;
55    config_set_pre2025_method(session.conn(), m, attested)?;
56    session.save()?;
57    session.config()
58}
59
60/// FR10 / NFR2 exception: decrypted SQLite image (via the store) + the projected ledger as CSV.
61/// When `tax_year` is `Some(y)`, the per-tax-year Form 8949 + Schedule D CSVs are also written,
62/// year-scoped to `y` (P2-B); when `None`, only the all-years CSVs are written.
63///
64/// Sub-project 3 attestation gate: when the projection is pseudo-active (a synthetic default
65/// contributes), producing any form/data file requires the exact `ATTEST_PHRASE` in `attest`
66/// (trimmed, case-sensitive). Checked FIRST — before any bytes are written — so a refused export
67/// leaves `out_dir` untouched. A fully-real (not-pseudo-active) ledger ignores `attest` entirely.
68pub fn export_snapshot(
69    vault_path: &Path,
70    pp: &Passphrase,
71    out_dir: &Path,
72    tax_year: Option<i32>,
73    attest: Option<&str>,
74) -> Result<ExportReport, CliError> {
75    let session = Session::open(vault_path, pp)?;
76    // Attestation gate: REFUSE before ANY bytes are written when a synthetic default contributes and the
77    // attestation is missing/wrong — no fictional snapshot/8949/Schedule D leaves the machine unguarded.
78    // Checked FIRST (before the vault snapshot / CSV writes), so a refused export leaves out_dir untouched.
79    let (state, _cfg) = session.project()?;
80    if state.pseudo_active() {
81        require_attestation(attest)?;
82    }
83    // UX-P4-8: name the --out path (and hint) when the export directory cannot be created (a
84    // colliding file / missing parent / permission problem), instead of a bare `io: File exists`.
85    let sqlite = session
86        .vault()
87        .export_snapshot(out_dir)
88        .map_err(|e| crate::store_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?; // writes out_dir/snapshot.sqlite
89                                                                                      // P2-D: standalone Schedule SE §1401 figure for the year-scoped export. Needs the year's filing
90                                                                                      // status (profile) + the year's ss_wage_base (bundled table); `None` when either is absent or
91                                                                                      // there is no business SE income. The "present but no table" note is a text-report concern
92                                                                                      // (render_schedule_se) — the CSV carries the computed figure only.
93    let se_result = match tax_year {
94        Some(y) => {
95            let tables = BundledTaxTables::load();
96            // Resolve (ReturnInputs-derived → stored → …); an uncomputable/refused profile just omits the
97            // SE figure — the export (a data snapshot) still proceeds, never emitting a wrong number.
98            let profile = match session.resolve_screened(&state, y, &tables)? {
99                crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
100                crate::resolve::ProfileOutcome::Uncomputable { .. } => None,
101            };
102            profile.and_then(|p| {
103                tables.table_for(y).and_then(|t| {
104                    compute_se_tax(
105                        &state,
106                        y,
107                        p.filing_status,
108                        t,
109                        p.w2_ss_wages,
110                        p.w2_medicare_wages,
111                        p.schedule_c_expenses,
112                    )
113                })
114            })
115        }
116        None => None,
117    };
118    let donation_details = session.donation_details()?;
119    // UX-P4-8: name the --out path for any I/O failure writing under out_dir — a `mkdir_owner_only`/
120    // `open_owner_only` failure (a SUBPATH collision such as `out_dir/lots.csv` being a directory)
121    // arrives as `CliError::Store(StoreError::Io)`, a mid-write `flush`/`writeln` as `CliError::Io`;
122    // `cli_io_with_path` enriches BOTH. A `csv::Error` (serialization, not a path problem) passes
123    // through.
124    write_csv_exports(
125        out_dir,
126        &state,
127        tax_year,
128        se_result.as_ref(),
129        &donation_details,
130    )
131    .map_err(|e| crate::cli_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))?;
132    // [R0-I1] Count UNRESOLVED Hard blockers only. Any Hard blocker gates every year, so the count
133    // alone (no per-year `compute_tax_year` call, no profile/tables dependency) drives the main.rs
134    // stderr "INFORMATIONAL, not final" disclosure. Advisory blockers never count.
135    let unresolved_hard = state
136        .blockers
137        .iter()
138        .filter(|b| b.kind.severity() == Severity::Hard)
139        .count();
140    Ok(ExportReport {
141        path: sqlite,
142        unresolved_hard,
143    })
144}
145
146/// Probe: would an export be gated? `true` when the projection is pseudo-active (a synthetic default
147/// contributes). Used by the `export-snapshot` CLI arm to decide whether to PROMPT for the attestation
148/// phrase; the authoritative gate lives inside `export_snapshot` itself. Kept in the library so main.rs
149/// stays a thin dispatch (no session-open / projection business logic in the binary).
150pub fn export_pseudo_active(vault_path: &Path, pp: &Passphrase) -> Result<bool, CliError> {
151    let (state, _cfg) = Session::open(vault_path, pp)?.project()?;
152    Ok(state.pseudo_active())
153}
154
155/// Outcome of `export_irs_pdf`: the written PDF paths, the unresolved-Hard-blocker count (same
156/// INFORMATIONAL disclosure as `export-snapshot`), whether the fill was watermarked (pseudo-active),
157/// the count of rows that MIGHT belong on a separate broker-reported 8949 (the [I5] advisory — the
158/// separate boxes are year-aware: A/B/D/E from a 1099-B pre-TY2025, G/H/J/K from a 1099-DA from
159/// TY2025), and the SP2 packet (Schedule SE + Form 8283 + Form 1040 cap-gains) with the advisories
160/// each one drives.
161#[derive(Debug, Clone)]
162pub struct IrsPdfReport {
163    pub f8949_path: Option<PathBuf>,
164    pub schedule_d_path: Option<PathBuf>,
165    pub tax_year: i32,
166    pub unresolved_hard: usize,
167    pub broker_reported_rows: usize,
168    pub watermarked: bool,
169    /// Schedule SE — written only when SE income ≥ the $400 floor (and selected).
170    pub schedule_se_path: Option<PathBuf>,
171    /// SE tax was computed but net earnings were below the $400 floor → SE not owed, form skipped.
172    pub se_below_floor: bool,
173    /// `Some(addl)` when the §1401(b)(2) Additional Medicare Tax is nonzero — a Form 8959 item, NOT on
174    /// Schedule SE (a loud advisory).
175    pub se_addl_medicare: Option<Usd>,
176    /// SE-eligible business income exists but no profile/table was available (`compute_se_tax` → None
177    /// for a reason other than "no SE income") → a NOTE, not a silent skip (the `se_net_income`
178    /// discriminator).
179    pub se_income_without_profile: bool,
180    /// Form 8283 — written only when there are donations (and selected).
181    pub form_8283_path: Option<PathBuf>,
182    /// Any Form 8283 row needs manual review (incomplete appraiser/donee declaration) → escalate.
183    pub form_8283_needs_review: bool,
184    /// The Form 8283 section actually written (`Some(true)` = Section B, `Some(false)` = Section A).
185    pub form_8283_section_b: Option<bool>,
186    /// Form 1040 cap-gains — written only when there is reportable digital-asset activity (and selected).
187    pub form_1040_path: Option<PathBuf>,
188    /// Line 7a received a value on the written 1040.
189    pub form_1040_filled_7a: bool,
190    /// The 1040 was skipped for a NET LOSS on line 7a (the §1211 line-21 cap is the filer's).
191    pub form_1040_loss: bool,
192    /// ★ The FULL-RETURN packet's files, in Attachment Sequence order (empty on the crypto-slice path).
193    /// The two paths write NON-OVERLAPPING names, so no two runs can be collated into a chimera return.
194    pub full_return_paths: Vec<PathBuf>,
195    /// The full-return packet's manifest (the filer's stapling order).
196    pub full_return_manifest: Option<PathBuf>,
197    /// UX-P4-5: `true` when a `--forms` SLICE was passed on a full-return year and therefore IGNORED
198    /// (the whole jointly-computed packet writes; honoring a slice of it is tax-unsound). The caller
199    /// warns on stderr. Always `false` on the crypto-slice path (there `--forms` is honored).
200    pub forms_ignored_full_return: bool,
201}
202
203/// The **[I5]** broker-reporting advisory line, year-aware — or `None` when no disposition may have
204/// been broker-reported (`broker_reported_rows == 0`).
205///
206/// The "separate 8949 / not-reported box" pairing depends on the form revision, exactly as the box
207/// assignment does (mirrors [`btctax_core::DIGITAL_ASSET_8949_FIRST_YEAR`]): pre-TY2025 an exchange
208/// disposal may have been reported on a **1099-B**, belongs on a separate 8949 under **Box A/B (ST) /
209/// D/E (LT)**, and this export files every row under **Box C/F**; from TY2025 it is the **1099-DA**,
210/// **Box G/H/J/K**, and every row files under **Box I/L**. Emitting the 2025 pairing on a pre-2025
211/// export would steer the filer to boxes that do not exist on that revision — hence the year gate.
212pub fn broker_reporting_advisory(tax_year: i32, broker_reported_rows: usize) -> Option<String> {
213    if broker_reported_rows == 0 {
214        return None;
215    }
216    let (broker_form, separate_boxes, filed_boxes) = if tax_year >= DIGITAL_ASSET_8949_FIRST_YEAR {
217        ("1099-DA", "Box G/H/J/K", "Box I/L")
218    } else {
219        ("1099-B", "Box A/B (ST) / D/E (LT)", "Box C/F")
220    };
221    Some(format!(
222        "⚠ [I5] {broker_reported_rows} disposition(s) occurred on an exchange that MAY have issued \
223         {broker_form} broker basis reporting — those would belong on a SEPARATE Form 8949 under \
224         {separate_boxes}. This export files EVERY Bitcoin row under {filed_boxes} (not-reported \
225         default) and says so; reclassify by hand if you received a {broker_form}."
226    ))
227}
228
229/// Whether a form is included: the packet is every applicable form unless `--forms` opts in to a subset.
230fn wants(selected: &[FormArg], f: FormArg) -> bool {
231    selected.is_empty() || selected.contains(&f)
232}
233
234/// A Schedule D part is "active" (worth reporting) iff it has any proceeds/cost/gain.
235fn sd_part_active(p: &ScheduleDPart) -> bool {
236    !p.proceeds.is_zero() || !p.cost_basis.is_zero() || !p.gain.is_zero()
237}
238
239/// `export-irs-pdf`: fill the OFFICIAL IRS PDFs for `tax_year` and write them (owner-only) to
240/// `out_dir`. The packet is Form 8949 + Schedule D (always applicable) plus — when applicable and
241/// selected — Schedule SE (SE income ≥ $400), Form 8283 (donations), and Form 1040 cap-gains
242/// (reportable digital-asset activity). The form data is REUSED from the projection
243/// (`form_8949`/`schedule_d`/`form_8283`/`compute_se_tax`) — nothing capital-gains is recomputed; the
244/// SE §1401 figure is computed here from the year's stored `TaxProfile`. Same pseudo-active
245/// attestation gate as `export-snapshot`: checked FIRST, so a refused export leaves `out_dir`
246/// untouched; a pseudo fill is additionally DRAFT-watermarked.
247pub fn export_irs_pdf(
248    vault_path: &Path,
249    pp: &Passphrase,
250    out_dir: &Path,
251    tax_year: i32,
252    forms: &[FormArg],
253    attest: Option<&str>,
254) -> Result<IrsPdfReport, CliError> {
255    let session = Session::open(vault_path, pp)?;
256    let (state, _cfg) = session.project()?;
257
258    // ★ THE DISPATCH (P6.5). Exactly one function decides which pipeline runs, and the two write
259    // NON-OVERLAPPING filenames, so artifacts from two runs can never be collated into a chimera
260    // return: the full packet writes `f1040.pdf`, `f1040s1.pdf`, … + a manifest; the crypto slice
261    // writes `form_1040_capgains.pdf`, `schedule_d.pdf`, `f8949.pdf`, ….
262    //
263    // This REPLACES the P5-C1 refusal (`CryptoSliceExportForFullReturnYear`). That guard existed only
264    // because the slice's Schedule D carries the crypto totals alone — no line 13 for 1099-DIV box-2a
265    // capital-gain distributions, no lines 6/14 for capital-loss carryovers — so on a full-return year
266    // it was a complete-LOOKING form with income missing. The full pipeline fills all of them, plus
267    // every attachment the forms cite, so the reason for the refusal is gone. Deleting it downgrades a
268    // type-level impossibility to a branch, which is why the branch is HERE, alone, and pinned by KATs
269    // in BOTH directions.
270    if crate::return_inputs::exists(session.conn(), tax_year)? {
271        let mut report = export_full_return(&session, &state, out_dir, tax_year, attest)?;
272        // UX-P4-5: a --forms slice cannot be honored on a full-return year (the 14-form packet is
273        // jointly computed; a slice of it is tax-unsound). The packet still writes in full; flag the
274        // ignored slice so the caller warns.
275        report.forms_ignored_full_return = !forms.is_empty();
276        return Ok(report);
277    }
278
279    // Attestation gate — no fictional tax form leaves the machine unguarded, and a refusal
280    // writes no bytes. (A fully-real ledger ignores `attest`.)
281    let watermarked = state.pseudo_active();
282    if watermarked {
283        require_attestation(attest)?;
284    }
285
286    // Reuse the projection's capital-gains data verbatim (no recompute).
287    let rows = btctax_core::form_8949(&state, tax_year);
288    let totals = btctax_core::schedule_d(&state, tax_year);
289
290    // A pseudo-active fill DRAFT-watermarks every page before it hits disk.
291    let stamp = |bytes: Vec<u8>| -> Result<Vec<u8>, CliError> {
292        Ok(if watermarked {
293            btctax_forms::stamp_draft_watermark(&bytes)?
294        } else {
295            bytes
296        })
297    };
298
299    mkdir_out(out_dir)?;
300
301    // I-3 (D-4): the MANDATORY conservative-filing methodology disclosure rides the PDF packet too, not
302    // just the CSV paths — a filer mailing the flagship filing-ready artifact must get the i8949-required
303    // basis explanation whenever a $0-basis tranche row is present. Writes nothing for a no-tranche year.
304    crate::render::write_basis_methodology_txt(out_dir, &state, tax_year)?;
305
306    // ── Form 8949 + Schedule D (always applicable). ──
307    let f8949_path = if wants(forms, FormArg::F8949) {
308        let bytes = stamp(btctax_forms::fill_form_8949(&rows, tax_year)?)?;
309        let path = out_dir.join("f8949.pdf");
310        write_bytes_owner_only(&path, &bytes)?;
311        Some(path)
312    } else {
313        None
314    };
315    let schedule_d_path = if wants(forms, FormArg::ScheduleD) {
316        let bytes = stamp(btctax_forms::fill_schedule_d(&totals, tax_year)?)?;
317        let path = out_dir.join("schedule_d.pdf");
318        write_bytes_owner_only(&path, &bytes)?;
319        Some(path)
320    } else {
321        None
322    };
323
324    // ── Schedule SE (self-employment tax). Compute the §1401 figure from the year's TaxProfile. ──
325    let se_computed = {
326        let tables = BundledTaxTables::load();
327        let profile = match session.resolve_screened(&state, tax_year, &tables)? {
328            crate::resolve::ProfileOutcome::Ready { profile, .. } => profile,
329            crate::resolve::ProfileOutcome::Uncomputable { .. } => None, // export proceeds; SE omitted
330        };
331        profile.and_then(|p| {
332            tables.table_for(tax_year).and_then(|t| {
333                compute_se_tax(
334                    &state,
335                    tax_year,
336                    p.filing_status,
337                    t,
338                    p.w2_ss_wages,
339                    p.w2_medicare_wages,
340                    p.schedule_c_expenses,
341                )
342                .map(|se| (se, p.w2_ss_wages, t.ss_wage_base))
343            })
344        })
345    };
346    // Discriminator: SE income present but `compute_se_tax` returned None (no profile / no table) → a
347    // NOTE, not a silent skip (mirrors the render layer; never a fabricated form).
348    let se_income_without_profile =
349        se_computed.is_none() && !se_net_income(&state, tax_year).is_zero();
350    let mut schedule_se_path = None;
351    let mut se_below_floor = false;
352    let mut se_addl_medicare = None;
353    if wants(forms, FormArg::ScheduleSe) {
354        if let Some((se, w2_ss_wages, ss_wage_base)) = se_computed {
355            if !se.addl.is_zero() {
356                se_addl_medicare = Some(se.addl);
357            }
358            match btctax_forms::fill_schedule_se(&se, w2_ss_wages, ss_wage_base, tax_year)? {
359                Some(bytes) => {
360                    let bytes = stamp(bytes)?;
361                    let path = out_dir.join("schedule_se.pdf");
362                    write_bytes_owner_only(&path, &bytes)?;
363                    schedule_se_path = Some(path);
364                }
365                None => se_below_floor = true, // below the $400 floor — SE not owed.
366            }
367        }
368    }
369
370    // ── Form 8283 (noncash charitable contributions). ──
371    let mut form_8283_path = None;
372    let mut form_8283_needs_review = false;
373    let mut form_8283_section_b = None;
374    if wants(forms, FormArg::Form8283) {
375        let details = session.donation_details()?;
376        let rows_8283 = btctax_core::form_8283(&state, tax_year, &details);
377        if let Some(bytes) = btctax_forms::fill_form_8283(&rows_8283, tax_year)? {
378            form_8283_needs_review = rows_8283.iter().any(|r| r.needs_review);
379            form_8283_section_b = rows_8283
380                .iter()
381                .find_map(|r| r.section)
382                .map(|s| s == btctax_core::Form8283Section::B);
383            let bytes = stamp(bytes)?;
384            let path = out_dir.join("form_8283.pdf");
385            write_bytes_owner_only(&path, &bytes)?;
386            form_8283_path = Some(path);
387        }
388    }
389
390    // ── Form 1040 cap-gains cells + the digital-asset question. ──
391    let mut form_1040_path = None;
392    let mut form_1040_filled_7a = false;
393    let mut form_1040_loss = false;
394    if wants(forms, FormArg::Form1040) {
395        let da_yes = !rows.is_empty()
396            || state
397                .income_recognized
398                .iter()
399                .any(|i| i.recognized_at.year() == tax_year)
400            || state
401                .removals
402                .iter()
403                .any(|r| r.removed_at.year() == tax_year);
404        let inputs = Form1040Inputs {
405            da_yes,
406            schedule_d_active: sd_part_active(&totals.st) || sd_part_active(&totals.lt),
407            schedule_d_line16: totals.st.gain + totals.lt.gain,
408        };
409        if let Some(fill) = btctax_forms::fill_form_1040_capgains(&inputs, tax_year)? {
410            form_1040_filled_7a = fill.filled_7a;
411            form_1040_loss = fill.loss;
412            let bytes = stamp(fill.pdf)?;
413            let path = out_dir.join("form_1040_capgains.pdf");
414            write_bytes_owner_only(&path, &bytes)?;
415            form_1040_path = Some(path);
416        }
417    }
418
419    let unresolved_hard = state
420        .blockers
421        .iter()
422        .filter(|b| b.kind.severity() == Severity::Hard)
423        .count();
424    Ok(IrsPdfReport {
425        full_return_paths: Vec::new(),
426        full_return_manifest: None,
427        forms_ignored_full_return: false, // crypto-slice path honors --forms
428        f8949_path,
429        schedule_d_path,
430        tax_year,
431        unresolved_hard,
432        broker_reported_rows: btctax_forms::rows_possibly_broker_reported(&rows),
433        watermarked,
434        schedule_se_path,
435        se_below_floor,
436        se_addl_medicare,
437        se_income_without_profile,
438        form_8283_path,
439        form_8283_needs_review,
440        form_8283_section_b,
441        form_1040_path,
442        form_1040_filled_7a,
443        form_1040_loss,
444    })
445}
446
447/// Write `bytes` to `path` with owner-only (0o600) permissions, matching the CSV export path.
448fn write_bytes_owner_only(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
449    use std::io::Write;
450    let mut f = fsperms::open_owner_only(path)?;
451    f.write_all(bytes)?;
452    f.flush()?;
453    Ok(())
454}
455
456/// UX-P4-8: create the export `--out` directory, NAMING the path + a one-clause hint when it cannot be
457/// created (a colliding file, a missing parent, a permission problem) — instead of the bare
458/// `io: File exists (os error 17)` the pathless `StoreError::Io` produces. The single choke point for
459/// every directory-producing export (`export-snapshot` via `write_csv_exports`, `export-irs-pdf`,
460/// `export-full-return`).
461fn mkdir_out(out_dir: &Path) -> Result<(), CliError> {
462    fsperms::mkdir_owner_only(out_dir)
463        .map_err(|e| crate::store_io_with_path(e, out_dir, crate::EXPORT_OUT_HINT))
464}
465
466/// §8: export the passphrase-protected key (escape hatch; HIGH-security write).
467pub fn backup_key(vault_path: &Path, pp: &Passphrase, out_path: &Path) -> Result<(), CliError> {
468    Session::open(vault_path, pp)?
469        .vault()
470        .backup_key(out_path)
471        // UX-P4-8: name the --out path (and hint) when the key file cannot be written (a colliding
472        // directory, a missing parent, a permission problem), not a bare `io: …`.
473        .map_err(|e| crate::store_io_with_path(e, out_path, crate::EXPORT_OUT_HINT))?;
474    Ok(())
475}
476
477/// ★ **The full-return export** (P6.3b / P6.5) — the whole filable packet, not the crypto slice.
478///
479/// Runs the same fail-closed screens the report runs (a return the report will not compute is a return
480/// the exporter must not print), assembles the printed packet in CORE, and fills it ALL-OR-NOTHING: if
481/// any member form refuses, zero bytes reach the disk. A 1040 whose line 2b cites a Schedule B that is
482/// not attached is a wrong return, so partial emission would be a fail-open.
483///
484/// **The packet exports CLEAN** (no DRAFT watermark, no attestation) — the user's §9 decision, folded
485/// into the SPEC. The one exception is PSEUDO-reconciled figures, which are FICTIONAL and can never be
486/// filed: those are watermarked regardless, and that gate composes with (and dominates) everything else.
487fn export_full_return(
488    session: &Session,
489    state: &btctax_core::state::LedgerState,
490    out_dir: &Path,
491    tax_year: i32,
492    attest: Option<&str>,
493) -> Result<IrsPdfReport, CliError> {
494    use btctax_adapters::{BundledFullReturnTables, BundledTaxTables};
495    use btctax_core::tax::tables::{FullReturnTables, TaxTables};
496    use std::fmt::Write as _;
497
498    let tables = BundledTaxTables::load();
499    let fr_tables = BundledFullReturnTables::load();
500    let (Some(params), Some(table)) = (
501        fr_tables.full_return_for(tax_year),
502        tables.table_for(tax_year),
503    ) else {
504        return Err(CliError::Usage(format!(
505            "no full-return tables for {tax_year} — the full-return packet needs a supported tax year \
506             (TY2024)"
507        )));
508    };
509
510    let ri = crate::return_inputs::get(session.conn(), tax_year)?
511        .ok_or_else(|| CliError::Usage(format!("no return_inputs stored for {tax_year}")))?;
512
513    // Fail-closed screens, in the same order the report runs them. A refusal writes NO bytes.
514    let refuse = |r: btctax_core::tax::return_refuse::Refusal| {
515        CliError::Usage(format!(
516            "the {tax_year} return is not computable [{:?}]: {} — no forms were written",
517            r.reason, r.detail
518        ))
519    };
520    if let Some(r) = btctax_core::tax::return_refuse::screen_inputs(&ri, table, params) {
521        return Err(refuse(r));
522    }
523    if let Some(r) =
524        btctax_core::tax::return_1040::screen_compute_dependent(&ri, state, tax_year, params)
525    {
526        return Err(refuse(r));
527    }
528    let ar = btctax_core::tax::return_1040::assemble_absolute(&ri, state, params, table, tax_year);
529    if let Some(r) = btctax_core::tax::return_1040::screen_absolute(&ri, &ar, params) {
530        return Err(refuse(r));
531    }
532
533    // Pseudo figures are FICTIONAL: they are watermarked no matter what, and the attestation gate for
534    // them is unchanged. A clean (real-ledger) packet needs no attestation — SPEC §9 as amended.
535    let watermarked = state.pseudo_active();
536    if watermarked {
537        require_attestation(attest)?;
538    }
539
540    let details = session.donation_details()?;
541    let printed = btctax_core::tax::packet::assemble_printed_return(
542        &ri, state, &details, &ar, table, tax_year,
543    )
544    .map_err(|e| {
545        // `HeaderError`'s Display carries the right remedy per variant (a malformed SSN, an unanswered
546        // declaration, or an MFJ return with no spouse) — no longer always "fix the identity" (P9 §3.2).
547        CliError::Usage(format!("the {tax_year} return cannot be printed: {e}"))
548    })?;
549
550    // ★ ALL-OR-NOTHING: every form fills BEFORE anything is written.
551    let packet = btctax_forms::fill_full_return(&printed, tax_year)?;
552
553    mkdir_out(out_dir)?;
554    // I-3 (D-4): the MANDATORY methodology disclosure rides the full-return packet too (see export_irs_pdf).
555    crate::render::write_basis_methodology_txt(out_dir, state, tax_year)?;
556    let mut manifest = String::from("# btctax full-return packet — staple in this order\n");
557    let mut paths: Vec<PathBuf> = Vec::new();
558    for form in &packet {
559        let bytes = if watermarked {
560            btctax_forms::stamp_draft_watermark(&form.bytes)?
561        } else {
562            form.bytes.clone()
563        };
564        // ★ The packet's filenames are SEQUENCE-PREFIXED (`00_f1040.pdf`, `12A_f8949.pdf`, …). Two
565        // reasons, and the first is a correctness guarantee: the crypto slice writes bare stems
566        // (`f8949.pdf`, `schedule_d.pdf`, `schedule_se.pdf`) and THREE of them collided with the
567        // packet's — so a full-return run and a slice run into one directory could silently interleave
568        // a cents Schedule SE with a whole-dollar 1040: the chimera return the dispatch mitigation
569        // exists to prevent (Fable P6 r1 I7). Second, the prefix IS the stapling order.
570        let path = out_dir.join(format!(
571            "{}_{}.pdf",
572            form.attachment_sequence.unwrap_or("00"),
573            form.name
574        ));
575        write_bytes_owner_only(&path, &bytes)?;
576        let seq = form.attachment_sequence.unwrap_or("—");
577        let _ = writeln!(
578            manifest,
579            "{seq:>4}  {}",
580            path.file_name().unwrap_or_default().to_string_lossy()
581        );
582        paths.push(path);
583    }
584    let manifest_path = out_dir.join("manifest.txt");
585    write_bytes_owner_only(&manifest_path, manifest.as_bytes())?;
586
587    let unresolved_hard = state
588        .blockers
589        .iter()
590        .filter(|b| b.kind.severity() == Severity::Hard)
591        .count();
592    Ok(IrsPdfReport {
593        watermarked,
594        tax_year,
595        unresolved_hard,
596        broker_reported_rows: 0,
597        full_return_paths: paths,
598        full_return_manifest: Some(manifest_path),
599        forms_ignored_full_return: false, // set by the dispatch (which has `forms`), not here
600        // The crypto-slice PATHS are absent (the two pipelines are disjoint), but the 8283's loud
601        // escalations are NOT slice-specific: the packet can contain a Section-B 8283 whose appraiser
602        // declaration is unsigned, and silencing that guard on the one path that announces a "clean,
603        // filable" packet would be a fail-open (Fable P6 r1 I8b).
604        f8949_path: None,
605        schedule_d_path: None,
606        schedule_se_path: None,
607        se_below_floor: false,
608        se_addl_medicare: None,
609        se_income_without_profile: false,
610        form_8283_path: None,
611        form_8283_needs_review: printed
612            .forms
613            .f8283
614            .as_ref()
615            .is_some_and(|r| r.rows().iter().any(|row| row.needs_review)),
616        form_8283_section_b: printed.forms.f8283.as_ref().map(|r| {
617            r.rows()
618                .iter()
619                .any(|row| row.section == Some(btctax_core::Form8283Section::B))
620        }),
621        form_1040_path: None,
622        form_1040_filled_7a: false,
623        form_1040_loss: false,
624    })
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    /// [I5] r2/NEW-IMPORTANT-1: the broker-reporting advisory is YEAR-AWARE. On the TY2025+
632    /// digital-asset revision it must cite the 1099-DA and the digital-asset boxes (G/H/J/K separate,
633    /// I/L filed) — never the securities boxes.
634    #[test]
635    fn broker_advisory_ty2025_cites_1099da_and_digital_asset_boxes() {
636        let msg = broker_reporting_advisory(2025, 3).expect("3 broker rows → an advisory");
637        assert!(msg.contains("1099-DA"), "TY2025 cites the 1099-DA: {msg}");
638        assert!(msg.contains("Box G/H/J/K"), "separate 8949 boxes: {msg}");
639        assert!(msg.contains("Box I/L"), "filed-under boxes: {msg}");
640        assert!(msg.contains('3'), "the row count: {msg}");
641        // The securities-era pairing must NOT leak onto a 2025 export.
642        assert!(
643            !msg.contains("1099-B"),
644            "no pre-2025 1099-B on TY2025: {msg}"
645        );
646        assert!(
647            !msg.contains("Box C/F"),
648            "no securities C/F on TY2025: {msg}"
649        );
650    }
651
652    /// [I5] r2/NEW-IMPORTANT-1: pre-TY2025 (the securities-box revisions — TY2024/TY2017 are shipped
653    /// export years) the advisory must cite the 1099-B and the securities boxes (A/B, D/E separate,
654    /// C/F filed) — boxes G–L do not exist on those form revisions.
655    #[test]
656    fn broker_advisory_pre_2025_cites_1099b_and_securities_boxes() {
657        let msg = broker_reporting_advisory(2024, 1).expect("1 broker row → an advisory");
658        assert!(msg.contains("1099-B"), "pre-2025 cites the 1099-B: {msg}");
659        assert!(msg.contains("Box A/B"), "separate ST securities box: {msg}");
660        assert!(msg.contains("D/E"), "separate LT securities box: {msg}");
661        assert!(
662            msg.contains("Box C/F"),
663            "filed-under securities boxes: {msg}"
664        );
665        // The digital-asset-era pairing must NOT leak onto a pre-2025 export.
666        assert!(!msg.contains("1099-DA"), "no 1099-DA pre-2025: {msg}");
667        assert!(!msg.contains("G/H/J/K"), "no digital boxes pre-2025: {msg}");
668        assert!(
669            !msg.contains("Box I/L"),
670            "no digital filed-boxes pre-2025: {msg}"
671        );
672    }
673
674    /// [I5]: no exchange disposition → no advisory, in either era.
675    #[test]
676    fn broker_advisory_is_none_without_broker_rows() {
677        assert!(broker_reporting_advisory(2025, 0).is_none());
678        assert!(broker_reporting_advisory(2024, 0).is_none());
679    }
680
681    /// UX-P4-8 (fold I2): `mkdir_out` — the shared export-`--out` directory creator — names the
682    /// offending PATH and the remedy HINT when the directory cannot be created (here: an `--out`
683    /// that collides with a plain file), instead of a bare `io: File exists`.
684    #[test]
685    fn mkdir_out_collision_names_path_and_hint() {
686        let dir = tempfile::tempdir().unwrap();
687        let collide = dir.path().join("collide");
688        std::fs::write(&collide, b"i am a file, not a directory").unwrap();
689        let err = mkdir_out(&collide).expect_err("a file collision must error");
690        match &err {
691            CliError::PathIo { path, hint, .. } => {
692                assert!(path.contains("collide"), "names the path: {path}");
693                assert_eq!(hint, crate::EXPORT_OUT_HINT, "carries the export-out hint");
694            }
695            other => panic!("expected PathIo, got {other:?}"),
696        }
697        let msg = err.to_string();
698        assert!(msg.contains("collide"), "Display names the path: {msg}");
699        // Literal (not self-referential to the const, which `contains("")` would trivially satisfy if
700        // the const were emptied) — pins the hint's CONTENT (fold r2-N2).
701        assert!(
702            msg.contains("does not already exist as a file"),
703            "Display carries the hint content: {msg}"
704        );
705    }
706}