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}