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, Usd,
10};
11use btctax_forms::Form1040Inputs;
12use btctax_store::{fsperms, Passphrase};
13use std::path::{Path, PathBuf};
14
15/// Outcome of the CLI `export_snapshot` wrapper: the written snapshot path plus the count of
16/// UNRESOLVED Hard blockers (`severity() == Hard`) in the projection. Any Hard blocker gates EVERY
17/// tax year (`compute_tax_year` short-circuits on the projection-wide first Hard blocker), so
18/// `unresolved_hard > 0` means every exported Form 8949 / Schedule D / projection CSV is
19/// INFORMATIONAL, not final — the `ExportSnapshot` main.rs arm warns on stderr accordingly. A
20/// fully-resolved ledger yields `0` and no warning. Advisory blockers (incl. `PseudoReconcileActive`,
21/// `SelfTransferInboundZeroBasis`) never count.
22#[derive(Debug, Clone)]
23pub struct ExportReport {
24    pub path: PathBuf,
25    pub unresolved_hard: usize,
26}
27
28pub fn show_config(vault_path: &Path, pp: &Passphrase) -> Result<CliConfig, CliError> {
29    Session::open(vault_path, pp)?.config()
30}
31
32/// Persist a new TP8 fee treatment (None = leave unchanged), then return the resulting config.
33pub fn set_config(
34    vault_path: &Path,
35    pp: &Passphrase,
36    fee_treatment: Option<FeeTreatment>,
37) -> Result<CliConfig, CliError> {
38    let mut session = Session::open(vault_path, pp)?;
39    if let Some(t) = fee_treatment {
40        set_fee_treatment(session.conn(), t)?;
41        session.save()?;
42    }
43    session.config()
44}
45
46/// Persist the pre-2025 lot identification method and attestation flag, then return the resulting config.
47pub fn set_pre2025_method(
48    vault_path: &Path,
49    pp: &Passphrase,
50    m: LotMethod,
51    attested: bool,
52) -> Result<CliConfig, CliError> {
53    let mut session = Session::open(vault_path, pp)?;
54    config_set_pre2025_method(session.conn(), m, attested)?;
55    session.save()?;
56    session.config()
57}
58
59/// FR10 / NFR2 exception: decrypted SQLite image (via the store) + the projected ledger as CSV.
60/// When `tax_year` is `Some(y)`, the per-tax-year Form 8949 + Schedule D CSVs are also written,
61/// year-scoped to `y` (P2-B); when `None`, only the all-years CSVs are written.
62///
63/// Sub-project 3 attestation gate: when the projection is pseudo-active (a synthetic default
64/// contributes), producing any form/data file requires the exact `ATTEST_PHRASE` in `attest`
65/// (trimmed, case-sensitive). Checked FIRST — before any bytes are written — so a refused export
66/// leaves `out_dir` untouched. A fully-real (not-pseudo-active) ledger ignores `attest` entirely.
67pub fn export_snapshot(
68    vault_path: &Path,
69    pp: &Passphrase,
70    out_dir: &Path,
71    tax_year: Option<i32>,
72    attest: Option<&str>,
73) -> Result<ExportReport, CliError> {
74    let session = Session::open(vault_path, pp)?;
75    // Attestation gate: REFUSE before ANY bytes are written when a synthetic default contributes and the
76    // attestation is missing/wrong — no fictional snapshot/8949/Schedule D leaves the machine unguarded.
77    // Checked FIRST (before the vault snapshot / CSV writes), so a refused export leaves out_dir untouched.
78    let (state, _cfg) = session.project()?;
79    if state.pseudo_active() {
80        require_attestation(attest)?;
81    }
82    let sqlite = session.vault().export_snapshot(out_dir)?; // writes out_dir/snapshot.sqlite
83                                                            // P2-D: standalone Schedule SE §1401 figure for the year-scoped export. Needs the year's filing
84                                                            // status (profile) + the year's ss_wage_base (bundled table); `None` when either is absent or
85                                                            // there is no business SE income. The "present but no table" note is a text-report concern
86                                                            // (render_schedule_se) — the CSV carries the computed figure only.
87    let se_result = match tax_year {
88        Some(y) => {
89            let tables = BundledTaxTables::load();
90            session.tax_profile(y)?.and_then(|p| {
91                tables.table_for(y).and_then(|t| {
92                    compute_se_tax(
93                        &state,
94                        y,
95                        p.filing_status,
96                        t,
97                        p.w2_ss_wages,
98                        p.w2_medicare_wages,
99                        p.schedule_c_expenses,
100                    )
101                })
102            })
103        }
104        None => None,
105    };
106    let donation_details = session.donation_details()?;
107    write_csv_exports(
108        out_dir,
109        &state,
110        tax_year,
111        se_result.as_ref(),
112        &donation_details,
113    )?;
114    // [R0-I1] Count UNRESOLVED Hard blockers only. Any Hard blocker gates every year, so the count
115    // alone (no per-year `compute_tax_year` call, no profile/tables dependency) drives the main.rs
116    // stderr "INFORMATIONAL, not final" disclosure. Advisory blockers never count.
117    let unresolved_hard = state
118        .blockers
119        .iter()
120        .filter(|b| b.kind.severity() == Severity::Hard)
121        .count();
122    Ok(ExportReport {
123        path: sqlite,
124        unresolved_hard,
125    })
126}
127
128/// Probe: would an export be gated? `true` when the projection is pseudo-active (a synthetic default
129/// contributes). Used by the `export-snapshot` CLI arm to decide whether to PROMPT for the attestation
130/// phrase; the authoritative gate lives inside `export_snapshot` itself. Kept in the library so main.rs
131/// stays a thin dispatch (no session-open / projection business logic in the binary).
132pub fn export_pseudo_active(vault_path: &Path, pp: &Passphrase) -> Result<bool, CliError> {
133    let (state, _cfg) = Session::open(vault_path, pp)?.project()?;
134    Ok(state.pseudo_active())
135}
136
137/// Outcome of `export_irs_pdf`: the written PDF paths, the unresolved-Hard-blocker count (same
138/// INFORMATIONAL disclosure as `export-snapshot`), whether the fill was watermarked (pseudo-active),
139/// the count of rows that MIGHT belong on a separate 1099-DA-reported 8949 (Box G/H/J/K — the [I5]
140/// advisory), and the SP2 packet (Schedule SE + Form 8283 + Form 1040 cap-gains) with the advisories
141/// each one drives.
142#[derive(Debug, Clone)]
143pub struct IrsPdfReport {
144    pub f8949_path: Option<PathBuf>,
145    pub schedule_d_path: Option<PathBuf>,
146    pub tax_year: i32,
147    pub unresolved_hard: usize,
148    pub broker_reported_rows: usize,
149    pub watermarked: bool,
150    /// Schedule SE — written only when SE income ≥ the $400 floor (and selected).
151    pub schedule_se_path: Option<PathBuf>,
152    /// SE tax was computed but net earnings were below the $400 floor → SE not owed, form skipped.
153    pub se_below_floor: bool,
154    /// `Some(addl)` when the §1401(b)(2) Additional Medicare Tax is nonzero — a Form 8959 item, NOT on
155    /// Schedule SE (a loud advisory).
156    pub se_addl_medicare: Option<Usd>,
157    /// SE-eligible business income exists but no profile/table was available (`compute_se_tax` → None
158    /// for a reason other than "no SE income") → a NOTE, not a silent skip (the `se_net_income`
159    /// discriminator).
160    pub se_income_without_profile: bool,
161    /// Form 8283 — written only when there are donations (and selected).
162    pub form_8283_path: Option<PathBuf>,
163    /// Any Form 8283 row needs manual review (incomplete appraiser/donee declaration) → escalate.
164    pub form_8283_needs_review: bool,
165    /// The Form 8283 section actually written (`Some(true)` = Section B, `Some(false)` = Section A).
166    pub form_8283_section_b: Option<bool>,
167    /// Form 1040 cap-gains — written only when there is reportable digital-asset activity (and selected).
168    pub form_1040_path: Option<PathBuf>,
169    /// Line 7a received a value on the written 1040.
170    pub form_1040_filled_7a: bool,
171    /// The 1040 was skipped for a NET LOSS on line 7a (the §1211 line-21 cap is the filer's).
172    pub form_1040_loss: bool,
173}
174
175/// Whether a form is included: the packet is every applicable form unless `--forms` opts in to a subset.
176fn wants(selected: &[FormArg], f: FormArg) -> bool {
177    selected.is_empty() || selected.contains(&f)
178}
179
180/// A Schedule D part is "active" (worth reporting) iff it has any proceeds/cost/gain.
181fn sd_part_active(p: &ScheduleDPart) -> bool {
182    !p.proceeds.is_zero() || !p.cost_basis.is_zero() || !p.gain.is_zero()
183}
184
185/// `export-irs-pdf`: fill the OFFICIAL IRS PDFs for `tax_year` and write them (owner-only) to
186/// `out_dir`. The packet is Form 8949 + Schedule D (always applicable) plus — when applicable and
187/// selected — Schedule SE (SE income ≥ $400), Form 8283 (donations), and Form 1040 cap-gains
188/// (reportable digital-asset activity). The form data is REUSED from the projection
189/// (`form_8949`/`schedule_d`/`form_8283`/`compute_se_tax`) — nothing capital-gains is recomputed; the
190/// SE §1401 figure is computed here from the year's stored `TaxProfile`. Same pseudo-active
191/// attestation gate as `export-snapshot`: checked FIRST, so a refused export leaves `out_dir`
192/// untouched; a pseudo fill is additionally DRAFT-watermarked.
193pub fn export_irs_pdf(
194    vault_path: &Path,
195    pp: &Passphrase,
196    out_dir: &Path,
197    tax_year: i32,
198    forms: &[FormArg],
199    attest: Option<&str>,
200) -> Result<IrsPdfReport, CliError> {
201    let session = Session::open(vault_path, pp)?;
202    let (state, _cfg) = session.project()?;
203    // Attestation gate FIRST — no fictional tax form leaves the machine unguarded, and a refusal
204    // writes no bytes. (A fully-real ledger ignores `attest`.)
205    let watermarked = state.pseudo_active();
206    if watermarked {
207        require_attestation(attest)?;
208    }
209
210    // Reuse the projection's capital-gains data verbatim (no recompute).
211    let rows = btctax_core::form_8949(&state, tax_year);
212    let totals = btctax_core::schedule_d(&state, tax_year);
213
214    // A pseudo-active fill DRAFT-watermarks every page before it hits disk.
215    let stamp = |bytes: Vec<u8>| -> Result<Vec<u8>, CliError> {
216        Ok(if watermarked {
217            btctax_forms::stamp_draft_watermark(&bytes)?
218        } else {
219            bytes
220        })
221    };
222
223    fsperms::mkdir_owner_only(out_dir)?;
224
225    // ── Form 8949 + Schedule D (always applicable). ──
226    let f8949_path = if wants(forms, FormArg::F8949) {
227        let bytes = stamp(btctax_forms::fill_form_8949(&rows, tax_year)?)?;
228        let path = out_dir.join("f8949.pdf");
229        write_bytes_owner_only(&path, &bytes)?;
230        Some(path)
231    } else {
232        None
233    };
234    let schedule_d_path = if wants(forms, FormArg::ScheduleD) {
235        let bytes = stamp(btctax_forms::fill_schedule_d(&totals, tax_year)?)?;
236        let path = out_dir.join("schedule_d.pdf");
237        write_bytes_owner_only(&path, &bytes)?;
238        Some(path)
239    } else {
240        None
241    };
242
243    // ── Schedule SE (self-employment tax). Compute the §1401 figure from the year's TaxProfile. ──
244    let se_computed = {
245        let tables = BundledTaxTables::load();
246        session.tax_profile(tax_year)?.and_then(|p| {
247            tables.table_for(tax_year).and_then(|t| {
248                compute_se_tax(
249                    &state,
250                    tax_year,
251                    p.filing_status,
252                    t,
253                    p.w2_ss_wages,
254                    p.w2_medicare_wages,
255                    p.schedule_c_expenses,
256                )
257                .map(|se| (se, p.w2_ss_wages, t.ss_wage_base))
258            })
259        })
260    };
261    // Discriminator: SE income present but `compute_se_tax` returned None (no profile / no table) → a
262    // NOTE, not a silent skip (mirrors the render layer; never a fabricated form).
263    let se_income_without_profile =
264        se_computed.is_none() && !se_net_income(&state, tax_year).is_zero();
265    let mut schedule_se_path = None;
266    let mut se_below_floor = false;
267    let mut se_addl_medicare = None;
268    if wants(forms, FormArg::ScheduleSe) {
269        if let Some((se, w2_ss_wages, ss_wage_base)) = se_computed {
270            if !se.addl.is_zero() {
271                se_addl_medicare = Some(se.addl);
272            }
273            match btctax_forms::fill_schedule_se(&se, w2_ss_wages, ss_wage_base, tax_year)? {
274                Some(bytes) => {
275                    let bytes = stamp(bytes)?;
276                    let path = out_dir.join("schedule_se.pdf");
277                    write_bytes_owner_only(&path, &bytes)?;
278                    schedule_se_path = Some(path);
279                }
280                None => se_below_floor = true, // below the $400 floor — SE not owed.
281            }
282        }
283    }
284
285    // ── Form 8283 (noncash charitable contributions). ──
286    let mut form_8283_path = None;
287    let mut form_8283_needs_review = false;
288    let mut form_8283_section_b = None;
289    if wants(forms, FormArg::Form8283) {
290        let details = session.donation_details()?;
291        let rows_8283 = btctax_core::form_8283(&state, tax_year, &details);
292        if let Some(bytes) = btctax_forms::fill_form_8283(&rows_8283, tax_year)? {
293            form_8283_needs_review = rows_8283.iter().any(|r| r.needs_review);
294            form_8283_section_b = rows_8283
295                .iter()
296                .find_map(|r| r.section)
297                .map(|s| s == btctax_core::Form8283Section::B);
298            let bytes = stamp(bytes)?;
299            let path = out_dir.join("form_8283.pdf");
300            write_bytes_owner_only(&path, &bytes)?;
301            form_8283_path = Some(path);
302        }
303    }
304
305    // ── Form 1040 cap-gains cells + the digital-asset question. ──
306    let mut form_1040_path = None;
307    let mut form_1040_filled_7a = false;
308    let mut form_1040_loss = false;
309    if wants(forms, FormArg::Form1040) {
310        let da_yes = !rows.is_empty()
311            || state
312                .income_recognized
313                .iter()
314                .any(|i| i.recognized_at.year() == tax_year)
315            || state
316                .removals
317                .iter()
318                .any(|r| r.removed_at.year() == tax_year);
319        let inputs = Form1040Inputs {
320            da_yes,
321            schedule_d_active: sd_part_active(&totals.st) || sd_part_active(&totals.lt),
322            schedule_d_line16: totals.st.gain + totals.lt.gain,
323        };
324        if let Some(fill) = btctax_forms::fill_form_1040_capgains(&inputs, tax_year)? {
325            form_1040_filled_7a = fill.filled_7a;
326            form_1040_loss = fill.loss;
327            let bytes = stamp(fill.pdf)?;
328            let path = out_dir.join("form_1040_capgains.pdf");
329            write_bytes_owner_only(&path, &bytes)?;
330            form_1040_path = Some(path);
331        }
332    }
333
334    let unresolved_hard = state
335        .blockers
336        .iter()
337        .filter(|b| b.kind.severity() == Severity::Hard)
338        .count();
339    Ok(IrsPdfReport {
340        f8949_path,
341        schedule_d_path,
342        tax_year,
343        unresolved_hard,
344        broker_reported_rows: btctax_forms::rows_possibly_broker_reported(&rows),
345        watermarked,
346        schedule_se_path,
347        se_below_floor,
348        se_addl_medicare,
349        se_income_without_profile,
350        form_8283_path,
351        form_8283_needs_review,
352        form_8283_section_b,
353        form_1040_path,
354        form_1040_filled_7a,
355        form_1040_loss,
356    })
357}
358
359/// Write `bytes` to `path` with owner-only (0o600) permissions, matching the CSV export path.
360fn write_bytes_owner_only(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
361    use std::io::Write;
362    let mut f = fsperms::open_owner_only(path)?;
363    f.write_all(bytes)?;
364    f.flush()?;
365    Ok(())
366}
367
368/// §8: export the passphrase-protected key (escape hatch; HIGH-security write).
369pub fn backup_key(vault_path: &Path, pp: &Passphrase, out_path: &Path) -> Result<(), CliError> {
370    Session::open(vault_path, pp)?
371        .vault()
372        .backup_key(out_path)?;
373    Ok(())
374}