Skip to main content

btctax_cli/
cli.rs

1//! The clap-4 command surface for `btctax`, extracted into the library so tooling
2//! (the `xtask` man-page generator) can obtain the `Command` via `Cli::command()`
3//! (`clap::CommandFactory`). The binary (`main.rs`) is a thin dispatch over these types.
4//!
5//! FILE-FORMAT DOCS — SINGLE SOURCE OF TRUTH: the long-help (`///` doc-comments with
6//! `#[arg(verbatim_doc_comment)]`) on the file/format-taking args below is rendered BOTH
7//! into `--help` (clap) AND into the per-subcommand man page (clap_mangen), zero drift.
8//! Formats were read from the writers, never from stale comments: export CSVs from
9//! `render.rs`, the classify-raw serde shape from `btctax-core::EventPayload`, the key
10//! armor from `btctax-store::Vault::backup_key`, the selections header from
11//! `cmd::reconcile::import_selections`, the lot pick from `eventref::parse_lot_pick`.
12use btctax_core::{FilingStatus, LotMethod};
13use clap::{Parser, Subcommand, ValueEnum};
14use std::path::PathBuf;
15
16#[derive(Parser)]
17#[command(name = "btctax", about = "Offline US Bitcoin tax ledger (Phase 1)")]
18pub struct Cli {
19    /// Path to the encrypted vault (vault.pgp).
20    #[arg(long, global = true, default_value = "vault.pgp")]
21    pub vault: PathBuf,
22    #[command(subcommand)]
23    pub command: Command,
24}
25
26#[derive(Subcommand)]
27pub enum Command {
28    /// Create the encrypted vault + force a key backup.
29    Init {
30        /// File to write the forced key backup to: an ASCII-armored, passphrase(S2K)-encrypted
31        /// private key, owner-only (mode 0600). Identical format to `backup-key --out`. Store it
32        /// offline — it is the only way to recover the vault if you lose `vault.key`.
33        ///
34        /// FORMAT (structure — NOT a real key):
35        ///   -----BEGIN PGP PRIVATE KEY BLOCK-----
36        ///   ... base64 armor of the S2K-encrypted secret key ...
37        ///   -----END PGP PRIVATE KEY BLOCK-----
38        #[arg(long, verbatim_doc_comment)]
39        key_backup: PathBuf,
40        /// Clear an interrupted/half-created init (orphan `vault.key`, no encrypted store) and start fresh.
41        #[arg(long, default_value_t = false)]
42        repair: bool,
43    },
44    /// Import one or more export files (auto-groups Swan).
45    Import { files: Vec<PathBuf> },
46    /// FR9 integrity check (non-zero exit on hard blockers).
47    Verify,
48    /// Show holdings + realized disposals/removals/income. With --tax-year: standalone TaxResult.
49    #[command(alias = "show")]
50    Report {
51        /// Filter realized disposals/removals/income to a specific calendar year (display path).
52        #[arg(long)]
53        year: Option<i32>,
54        /// Compute the crypto-attributable federal tax for the given tax year (B.5 / Task 9).
55        /// Requires a stored tax profile (`tax-profile --year Y ...`) and the bundled TY table.
56        /// Independent of --year; the two flags are not aliased.
57        #[arg(long)]
58        tax_year: Option<i32>,
59        /// Cumulative prior-year TAXABLE gifts (post-annual-exclusion Form 709 amounts), not
60        /// gross gifts. Used for the §2505 lifetime-exclusion consumption advisory. Defaults to
61        /// $0 when omitted (the advisory discloses this assumption). Must not be negative.
62        #[arg(long)]
63        prior_taxable_gifts: Option<String>,
64    },
65    /// Emit a reconciliation decision event.
66    #[command(subcommand)]
67    Reconcile(Reconcile),
68    /// Show or set projection config (TP8 fee treatment / pre-2025 lot method / forward method).
69    Config {
70        #[arg(long, value_enum)]
71        set_fee_treatment: Option<FeeArg>,
72        #[arg(long, value_enum)]
73        set_pre2025_method: Option<MethodLotArg>,
74        #[arg(long, default_value_t = false)]
75        attest_pre2025_method: bool,
76        /// §A.5(a): append a MethodElection decision (the forward standing order). Not a flag
77        /// mutation — this is an event in the ledger. Use --effective-from to set the date
78        /// (default: today / the decision's made-date).
79        #[arg(long, value_enum)]
80        set_forward_method: Option<MethodLotArg>,
81        /// §A.5(a) per-ACCOUNT scope for --set-forward-method (IRS 2025+ per-account rule):
82        /// exchange:PROVIDER:ACCOUNT (the canonical wallet grammar). Omit for a GLOBAL election
83        /// (the existing behavior). Only exchange accounts are electable (a method election is a
84        /// brokerage-account concept; self:LABEL is rejected). The account MUST already exist in the
85        /// vault — an unknown/typo'd account is rejected LOUDLY so it can't create a dead election.
86        #[arg(long)]
87        exchange: Option<String>,
88        /// Effective-from date for --set-forward-method (YYYY-MM-DD). Defaults to made-date.
89        #[arg(long)]
90        effective_from: Option<String>,
91    },
92    /// FR10: export decrypted SQLite + CSV (the NFR2 plaintext exception).
93    ///
94    /// WARNS (does not refuse) on unresolved Hard blockers: any Hard blocker makes every affected
95    /// tax year NOT COMPUTABLE, so the exported Form 8949 / Schedule D / figures are INFORMATIONAL,
96    /// not final. A warning is printed to stderr and the export still succeeds (exit 0). Automation
97    /// that must GATE on unresolved blockers should check `btctax verify` (which exits non-zero),
98    /// since export-snapshot itself stays exit 0.
99    ExportSnapshot {
100        /// Output DIRECTORY receiving the decrypted SQLite DB (snapshot.sqlite) + projection CSVs
101        /// (the NFR2 plaintext exception; created owner-only). ALWAYS writes: lots.csv,
102        /// disposals.csv, removals.csv, income.csv. With --tax-year it ALSO writes form8949.csv,
103        /// schedule_d.csv, form8283.csv, and schedule_se.csv (schedule_se only when there is
104        /// business self-employment income). The `event` column in disposals.csv / removals.csv /
105        /// income.csv is the event-ref that reconcile commands consume (select-lots,
106        /// set-donation-details, reclassify-income, …).
107        ///
108        /// FORMAT (removals.csv header + one sample donation row):
109        ///   event,kind,removed_at,lot,sat,basis,fmv_at_transfer,term,acquired_at,claimed_deduction,donee
110        ///   import|coinbase|X,donation,2025-03-01,import|coinbase|X#0,25000,120.00,150.00,long,2023-01-05,150.00,Charity Y
111        #[arg(long, verbatim_doc_comment)]
112        out: PathBuf,
113        /// Also emit the per-tax-year Form 8949 + Schedule D CSVs (form8949.csv / schedule_d.csv),
114        /// scoped to this calendar year. Omit to write only the all-years projection CSVs.
115        #[arg(long)]
116        tax_year: Option<i32>,
117        /// Attestation phrase required to export while the ledger is PSEUDO-RECONCILED (a synthetic
118        /// default contributes to the projection). Pass the exact phrase `I attest this is true`
119        /// (trimmed, case-sensitive) to export the fictional draft ON PURPOSE. Omit on a fully-real
120        /// ledger (never gated). Omit on an interactive terminal to be prompted; omit when piped
121        /// (non-TTY) while pseudo-active and the export is refused.
122        #[arg(long)]
123        attest: Option<String>,
124    },
125    /// Fill the OFFICIAL IRS fillable PDFs for a tax year (a whole packet).
126    ///
127    /// Writes (owner-only) into --out, populated from btctax's already-computed projection — no
128    /// capital-gains figure is recomputed:
129    ///   - f8949.pdf + schedule_d.pdf — ALWAYS. On the 2025 (1099-DA) revision Bitcoin is filed under
130    ///     Box I (short-term) / Box L (long-term) — the digital-asset boxes; on the pre-1099-DA 2024
131    ///     and 2017 revisions it is Box C / Box F ("not reported on a 1099-B"). Never the wrong pair
132    ///     for the year. More rows than a part's grid holds (11 in 2025, 14 in 2024/2017) paginate
133    ///     onto multiple copies, each with its own totals.
134    ///   - schedule_se.pdf — when there is business self-employment income and net earnings are ≥ the
135    ///     $400 floor. Line 12 (SE tax) = Social Security + regular Medicare ONLY; the 0.9% Additional
136    ///     Medicare Tax is a Form 8959 item (flagged on stderr, not put on Schedule SE). Requires a
137    ///     stored `tax-profile` for the year (filing status); missing profile ⇒ a NOTE, not a form.
138    ///   - form_8283.pdf — when there are BTC donations. Fills the donee/appraiser IDENTITY + per-
139    ///     donation property rows (Section A ≤ $5,000 or Section B > $5,000). The property-type box is
140    ///     "k Digital assets" on the Rev. 12-2023/2025 forms (2024/2025); the 2017 Rev. 12-2014 form
141    ///     has no such box, so BTC uses "j Other" + a printed note. Leaves every OTHER party's
142    ///     declaration/signature BLANK — a Section B 8283 is NOT filing-ready without those signed.
143    ///     Overflows onto additional copies.
144    ///   - form_1040_capgains.pdf — when there is reportable capital/digital-asset activity. Fills the
145    ///     capital-gain line (line 7a in 2025 / line 7 in 2024 / line 13 in 2017, when Schedule D is
146    ///     active and line 16 ≥ 0; active-and-zero → "-0-"; a net loss leaves it blank — the §1211
147    ///     line-21 cap is yours) and, on 2024/2025, the Digital-Asset question (YES iff any disposal,
148    ///     income, gift, or donation; never a "No"). The 2017 form has no Digital-Asset question, so an
149    ///     income-only 2017 year produces no 1040. 7b checkboxes are untouched.
150    ///
151    /// Every written value is read back GEOMETRICALLY against the blank PDF's own field coordinates and
152    /// the fill FAILS CLOSED on any mis-placement — a wrong tax form is never written. The engine drops
153    /// the forms' XFA layer (else Acrobat opens them blank) and sets NeedAppearances so a viewer
154    /// regenerates the visible values. Schedule D lines 17-22 (28%-rate / unrecaptured-§1250 / QDI
155    /// worksheet, incl. the line-21 loss limit) are OUT OF SCOPE. Rows on an exchange that MAY carry
156    /// 1099-DA broker reporting are flagged on stderr (btctax files them all under Box I/L and says so).
157    ///
158    /// PSEUDO-RECONCILED ledgers: the same attestation gate as export-snapshot applies, AND every
159    /// page is stamped with a diagonal `DRAFT — ESTIMATE, NOT FOR FILING` watermark.
160    ExportIrsPdf {
161        /// Output DIRECTORY receiving the filled official PDFs (created owner-only): f8949.pdf,
162        /// schedule_d.pdf, and — when applicable — schedule_se.pdf, form_8283.pdf,
163        /// form_1040_capgains.pdf. These contain your unencrypted tax data — write --out OUTSIDE any
164        /// git repo.
165        #[arg(long, verbatim_doc_comment)]
166        out: PathBuf,
167        /// The tax year to fill (this build bundles TY2017, TY2024 and TY2025; other years are
168        /// refused). TY2024/TY2017 are pre-1099-DA revisions: Bitcoin is filed under Box C/F (not Box
169        /// I/L). TY2017 additionally uses the OLD forms — the §B long Schedule SE, Form 8283 Rev.
170        /// 12-2014 ("j Other", no digital-asset box), the 1040 capital gain on line 13, and NO
171        /// Digital-Asset question.
172        #[arg(long)]
173        tax_year: i32,
174        /// Restrict the packet to specific forms (repeat or comma-separate). Default = every
175        /// applicable form (f8949 + schedule-d always; schedule-se when SE income ≥ the $400 floor;
176        /// form-8283 when there are donations; form-1040 when there is reportable digital-asset
177        /// activity). A named form is still skipped when it does not apply.
178        #[arg(long, value_enum, value_delimiter = ',')]
179        forms: Vec<FormArg>,
180        /// Attestation phrase required to export while the ledger is PSEUDO-RECONCILED (a synthetic
181        /// default contributes to the projection). Pass the exact phrase `I attest this is true`
182        /// (trimmed, case-sensitive) to fill the DRAFT-watermarked forms ON PURPOSE. Omit on a
183        /// fully-real ledger (never gated). Omit on an interactive terminal to be prompted; omit when
184        /// piped (non-TTY) while pseudo-active and the export is refused.
185        #[arg(long)]
186        attest: Option<String>,
187    },
188    /// Export the passphrase-protected key.
189    BackupKey {
190        /// File to write the exported key to: an ASCII-armored, passphrase(S2K)-encrypted private
191        /// key, owner-only (mode 0600). Identical format to `init --key-backup`.
192        ///
193        /// FORMAT (structure — NOT a real key):
194        ///   -----BEGIN PGP PRIVATE KEY BLOCK-----
195        ///   ... base64 armor of the S2K-encrypted secret key ...
196        ///   -----END PGP PRIVATE KEY BLOCK-----
197        #[arg(long, verbatim_doc_comment)]
198        out: PathBuf,
199    },
200    /// Lot-specific-identification optimizer (§C — read-only proposal or gated persistence).
201    #[command(subcommand)]
202    Optimize(Optimize),
203    /// Set or show the per-tax-year tax profile (filing status, income, MAGI, etc.).
204    TaxProfile {
205        /// The tax year (e.g. 2025).
206        #[arg(long)]
207        year: i32,
208        /// IRS filing status.
209        #[arg(long, value_enum)]
210        filing_status: Option<FilingStatusArg>,
211        /// Ordinary taxable income EXCLUDING all app-computed crypto items (net ST gains,
212        /// mining/staking ordinary income). The engine adds the crypto items on top (B.1 / I5).
213        #[arg(long)]
214        ordinary_taxable_income: Option<String>,
215        /// Modified AGI excluding crypto items, for the §1411 NIIT threshold comparison.
216        ///
217        /// IMPORTANT (§1411 contract): this value MUST already include the taxpayer's qualified
218        /// dividends and non-crypto net capital gains (and any other MAGI add-backs from
219        /// §1411(d)). The engine adds ONLY the crypto AGI delta on top (ambiguity #5 in the
220        /// design). Omitting QD or non-crypto cap gains from this figure understates NIIT.
221        #[arg(
222            long,
223            long_help = "Modified AGI excluding crypto items, for the §1411 NIIT \
224            threshold comparison.\n\nIMPORTANT (§1411 contract): this value MUST already \
225            include the taxpayer's qualified dividends and non-crypto net capital gains (and \
226            any other MAGI add-backs from §1411(d)). The engine adds ONLY the crypto AGI \
227            delta on top (ambiguity #5 in the design). Omitting QD or non-crypto cap gains \
228            from this figure understates NIIT."
229        )]
230        magi_excluding_crypto: Option<String>,
231        /// Qualified dividends + other preferential-rate income sharing the §1(h) 0/15/20 LTCG
232        /// rate stack. Required when setting a profile.
233        #[arg(long)]
234        qualified_dividends: Option<String>,
235        /// Non-crypto net LT-character capital gain already in the profile (optional; defaults
236        /// to 0 when omitted).
237        #[arg(long)]
238        other_net_capital_gain: Option<String>,
239        /// §1212(b) short-term capital loss carryforward into this year (optional; defaults to 0).
240        #[arg(long)]
241        carryforward_short: Option<String>,
242        /// §1212(b) long-term capital loss carryforward into this year (optional; defaults to 0).
243        #[arg(long)]
244        carryforward_long: Option<String>,
245        /// Form W-2 Social Security wages (Box 3 + Box 7 tips; Schedule SE line 8a).
246        /// Reduces the §1401(a) SS cap: ss_cap = max(0, wage_base − w2_ss_wages). Optional;
247        /// defaults to $0. Must not be negative.
248        #[arg(long)]
249        w2_ss_wages: Option<String>,
250        /// Medicare wages (Box 5; Form 8959 line 1).
251        /// Reduces the Additional-Medicare threshold: addl_threshold = max(0, threshold − w2_medicare_wages)
252        /// (§1401(b)(2)(B)/Form 8959 Part II). Optional; defaults to $0. Must not be negative.
253        #[arg(long)]
254        w2_medicare_wages: Option<String>,
255        /// Schedule C deductible business expenses for the year — reduces net SE earnings;
256        /// the income-tax stack above is NOT adjusted (see the advisory).
257        /// Optional; defaults to $0. Must not be negative.
258        #[arg(long)]
259        schedule_c_expenses: Option<String>,
260        /// Show the stored profile for `--year` instead of setting it.
261        #[arg(long, default_value_t = false)]
262        show: bool,
263    },
264}
265
266/// `optimize` subcommand tree.  Task 9 adds `Run`; Task 10 adds `Accept`; Task 11 adds `Consult`.
267#[derive(Subcommand)]
268pub enum Optimize {
269    /// Mode-1 what-if: print the tax-saving lot-selection proposal. NOTHING is filed or bound.
270    Run {
271        /// The tax year to optimize (must be 2025 or later).
272        #[arg(long)]
273        tax_year: i32,
274    },
275    /// Mode-1 gated persistence: recompute the optimum and persist the proposed LotSelection(s),
276    /// gated per disposal (§1.1012-1(j)). A genuinely-contemporaneous pick (made ≤ sale) persists
277    /// freely; an already-executed disposal persists ONLY with a narrow per-disposal `--attest`
278    /// scoped to one `--disposal`; a 2027+ broker-held pick is refused. Revoke via `reconcile void`.
279    Accept {
280        /// The tax year to accept (must be 2025 or later).
281        #[arg(long)]
282        tax_year: i32,
283        /// Restrict to ONE disposal (required to carry `--attest`).
284        #[arg(long)]
285        disposal: Option<String>,
286        /// Narrow contemporaneous-ID attestation for an already-executed disposal. Requires
287        /// `--disposal` (no blanket attestation across all disposals).
288        #[arg(long)]
289        attest: Option<String>,
290    },
291    /// Mode-2 read-only pre-trade what-if (§C.3): tax-min lots + ST/LT split + federal tax + ST→LT
292    /// timing. NOTHING is written — no event, no side-table row. Tax decision-support only;
293    /// not buy/sell/hold advice.
294    Consult {
295        /// Hypothetical sale amount in satoshis (required).
296        #[arg(long)]
297        sell: String,
298        /// Wallet to sell from, e.g. `self:cold` or `exchange:coinbase:default` (required; per-wallet
299        /// pool is mandatory post-2025).
300        #[arg(long)]
301        wallet: Option<String>,
302        /// Sale date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
303        #[arg(long)]
304        at: Option<String>,
305        /// Explicit USD proceeds for the hypothetical sale. Required when `--at` is a future date
306        /// with no bundled dataset price and `--fmv` is not used. Mutually exclusive with `--fmv`.
307        #[arg(long, conflicts_with = "fmv")]
308        proceeds: Option<String>,
309        /// Use the bundled daily-close FMV for `--at` instead of an explicit proceeds amount.
310        /// A future date with no dataset price will return a ProceedsRequired error. Mutually
311        /// exclusive with `--proceeds`.
312        #[arg(long, conflicts_with = "proceeds")]
313        fmv: bool,
314    },
315}
316
317#[derive(Subcommand)]
318pub enum Reconcile {
319    /// Confirm a self-transfer (TransferLink).
320    LinkTransfer {
321        out: String,
322        #[arg(long, conflicts_with = "to_wallet")]
323        to_event: Option<String>,
324        #[arg(long)]
325        to_wallet: Option<String>,
326    },
327    /// Classify an inbound TransferIn as income.
328    ClassifyInboundIncome {
329        in_ref: String,
330        #[arg(long)]
331        kind: String,
332        #[arg(long)]
333        fmv: Option<String>,
334        #[arg(long)]
335        business: bool,
336    },
337    /// Classify an inbound TransferIn as a received gift.
338    ClassifyInboundGift {
339        in_ref: String,
340        #[arg(long)]
341        fmv_at_gift: String,
342        #[arg(long)]
343        donor_basis: Option<String>,
344        #[arg(long)]
345        donor_acquired: Option<String>,
346    },
347    /// Classify an inbound TransferIn as an inbound self-transfer ("my own coins" returning) —
348    /// non-taxable, creates a fresh lot. `--basis` defaults to $0 (conservative; fires the honest
349    /// zero-basis advisory when omitted); `--acquired` defaults to 1 year + 1 day before receipt
350    /// (assumed long-term for a cold-storage deposit; discloses an advisory so you can correct it).
351    ClassifyInboundSelfTransfer {
352        in_ref: String,
353        #[arg(long)]
354        basis: Option<String>,
355        #[arg(long)]
356        acquired: Option<String>,
357    },
358    /// Reclassify a pending TransferOut.
359    ReclassifyOutflow {
360        out: String,
361        #[arg(long, value_enum)]
362        as_kind: OutKindArg,
363        #[arg(long)]
364        amount: String,
365        #[arg(long)]
366        fee: Option<String>,
367        #[arg(long)]
368        appraisal: bool,
369        /// Free-form donee identifier (e.g. "Alice", "Charity X"). Carried through to
370        /// removals.csv and Form 8283; does not affect tax math.
371        #[arg(long)]
372        donee: Option<String>,
373    },
374    /// Set a manual FMV on an event.
375    SetFmv {
376        event: String,
377        #[arg(long)]
378        fmv: String,
379    },
380    /// Void a revocable decision.
381    Void { target: String },
382    /// Resolve an Unclassified row from a JSON imported payload.
383    ClassifyRaw {
384        target: String,
385        /// A JSON-encoded imported EventPayload (serde externally-tagged: `{"Variant":{...}}`) to
386        /// resolve the Unclassified target as. Must be an IMPORTED variant — Acquire, Income,
387        /// Dispose, TransferOut, TransferIn, or Unclassified. USD fields (usd_cost, fee_usd, …) are
388        /// decimal STRINGS; `sat` is an integer.
389        ///
390        /// FORMAT (Acquire example):
391        ///   {"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}
392        #[arg(long, verbatim_doc_comment)]
393        payload_json: String,
394    },
395    /// Accept an import conflict.
396    AcceptConflict { conflict: String },
397    /// Reject an import conflict.
398    RejectConflict { conflict: String },
399    /// Path-B safe-harbor allocate (from the actual pre-2025 position).
400    SafeHarborAllocate {
401        #[arg(long, value_enum, default_value_t = MethodArg::Actual)]
402        method: MethodArg,
403        #[arg(long)]
404        attest: bool,
405    },
406    /// Attest an existing allocation as timely.
407    SafeHarborAttest,
408    /// §A.4 Specific-ID: pick the exact lots a disposal consumes.
409    SelectLots {
410        disposal: String,
411        /// One lot pick per --from flag (repeatable). Each PICK is
412        /// `<origin_event_id>#<split_sequence>:<sat>`. The origin_event_id + split come from the
413        /// `lot` column of disposals.csv or the `origin_event`/`split` columns of lots.csv
414        /// (export-snapshot). The total sat across the picks must equal the disposal's principal
415        /// (validated in the fold).
416        ///
417        /// FORMAT (two picks):
418        ///   --from import|coinbase|X#0:25000 --from import|river|Y#1:5000
419        #[arg(long = "from", required = true, verbatim_doc_comment)]
420        from: Vec<String>,
421    },
422    /// §A.4 Batch import LotSelections from a CSV (disposal_ref,origin_event_id,split_sequence,sat).
423    ImportSelections {
424        /// CSV of lot picks imported as LotSelection decisions (§A.4). The header is REQUIRED and
425        /// validated loudly; rows sharing a disposal_ref are grouped into a single decision.
426        /// disposal_ref is the disposal event's ref (disposals.csv `event` column); origin_event_id
427        /// is the lot's origin (lots.csv `origin_event` column).
428        ///
429        /// FORMAT (header + one sample row):
430        ///   disposal_ref,origin_event_id,split_sequence,sat
431        ///   import|gemini|trade|T-2.O-2,import|coinbase|X,0,1000000
432        #[arg(verbatim_doc_comment)]
433        csv: PathBuf,
434    },
435    /// SE-completion Chunk C: flip `business` (and optionally `kind`) on an already-imported Income event.
436    ///
437    /// Corrects the `business: false` hard-code that River (and other adapters) emit at ingest time,
438    /// enabling SE-tax treatment for professional miners / stakers. The engine validates that the target
439    /// event exists and is an Income event — a missing or non-Income target fires a Hard DecisionConflict
440    /// blocker (decision excluded). For TransferIn rows use `classify-inbound-income` instead.
441    ///
442    /// DecisionConflict is Hard — to re-decide, `void` the prior decision first, then re-issue.
443    ReclassifyIncome {
444        /// The Income event reference (from `report` or `income_recognized.csv` 'event' column).
445        income_event: String,
446        /// Whether this income is from a trade or business (true → SE-tax eligible).
447        /// Must be supplied explicitly: `--business true` or `--business false`.
448        #[arg(long, required = true, action = clap::ArgAction::Set)]
449        business: bool,
450        /// Optional income kind correction: mining|staking|interest|airdrop|reward.
451        /// Omit to keep the original kind (only flip `business`).
452        #[arg(long)]
453        kind: Option<String>,
454    },
455    /// Store Form 8283 Section-B donation + appraiser details for a donation event.
456    /// The event ref is the TransferOut EventId from the removals.csv 'event' column.
457    SetDonationDetails {
458        /// TransferOut event reference for the donation (from removals.csv 'event' column).
459        out_event_ref: String,
460        /// Donee organization name (Part IV; required).
461        #[arg(long, required = true)]
462        donee_name: String,
463        /// Donee mailing address (Part IV; optional).
464        #[arg(long)]
465        donee_address: Option<String>,
466        /// Donee EIN (Part IV; required for Section-B completeness).
467        #[arg(long)]
468        donee_ein: Option<String>,
469        /// Qualified appraiser name (Part III; required).
470        #[arg(long, required = true)]
471        appraiser_name: String,
472        /// Appraiser mailing address (Part III; optional).
473        #[arg(long)]
474        appraiser_address: Option<String>,
475        /// Appraiser TIN/SSN/EIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
476        #[arg(long)]
477        appraiser_tin: Option<String>,
478        /// Appraiser PTIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
479        #[arg(long)]
480        appraiser_ptin: Option<String>,
481        /// Appraiser qualifications declaration (§170(f)(11)(E)).
482        #[arg(long)]
483        appraiser_qualifications: Option<String>,
484        /// Date the qualified appraisal was made (YYYY-MM-DD).
485        #[arg(long)]
486        appraisal_date: Option<String>,
487        /// FMV determination method override (overrides the section-derived default on the
488        /// Form 8283 carrier row; resolves the Section-A fmv_method deferral when supplied).
489        #[arg(long)]
490        fmv_method: Option<String>,
491    },
492    /// Show stored Form 8283 donation details for a donation event.
493    ShowDonationDetails {
494        /// TransferOut event reference for the donation (from removals.csv 'event' column).
495        out_event_ref: String,
496    },
497    /// Bulk-confirm self-transfers: link every PENDING outbound transfer in a time frame to one
498    /// destination wallet (non-taxable). Shows a preview + requires --yes (or interactive y/N).
499    BulkLinkTransfer {
500        /// Destination wallet every selected outflow links to.
501        #[arg(long)]
502        to_wallet: String,
503        /// Restrict to a single tax year (mutually exclusive with --from/--to).
504        #[arg(long, conflicts_with_all = ["from", "to"])]
505        year: Option<i32>,
506        /// Range start (YYYY-MM-DD; requires --to).
507        #[arg(long, requires = "to")]
508        from: Option<String>,
509        /// Range end (YYYY-MM-DD, inclusive; requires --from).
510        #[arg(long, requires = "from")]
511        to: Option<String>,
512        /// Only outflows FROM this source wallet.
513        #[arg(long)]
514        from_wallet: Option<String>,
515        /// Print the preview and exit without writing.
516        #[arg(long)]
517        dry_run: bool,
518        /// Skip the interactive confirmation (non-interactive apply).
519        #[arg(long)]
520        yes: bool,
521    },
522    /// Bulk-classify unknown-basis inbound deposits as self-transfer-ins ("my own coins"): apply
523    /// Cycle A's `SelfTransferMine` ($0 conservative basis, non-taxable) to MANY pending inbounds in a
524    /// time frame at once. Shows a preview surfacing the total USD given $0 basis (the over-tax
525    /// exposure) + requires --yes (or interactive y/N). Each is a voidable decision; for a deposit
526    /// whose real cost you can substantiate, classify it single-item with `classify-inbound-self-transfer --basis`.
527    BulkClassifyInboundSelfTransfer {
528        /// Restrict to a single tax year (mutually exclusive with --from/--to).
529        #[arg(long, conflicts_with_all = ["from", "to"])]
530        year: Option<i32>,
531        /// Range start (YYYY-MM-DD; requires --to).
532        #[arg(long, requires = "to")]
533        from: Option<String>,
534        /// Range end (YYYY-MM-DD, inclusive; requires --from).
535        #[arg(long, requires = "from")]
536        to: Option<String>,
537        /// Only inbounds received INTO this wallet.
538        #[arg(long)]
539        wallet: Option<String>,
540        /// Print the preview and exit without writing.
541        #[arg(long)]
542        dry_run: bool,
543        /// Skip the interactive confirmation (non-interactive apply).
544        #[arg(long)]
545        yes: bool,
546    },
547    /// Bulk-classify unknown-basis inbound deposits as INCOME (mining|staking|interest|airdrop|reward):
548    /// recognize MANY pending inbounds as ordinary income at their auto-FMV (the daily-close market
549    /// value at receipt) in one confirmed batch, with a UNIFORM `--kind` + `--business` flag. Shows a
550    /// preview surfacing the total income recognized + the count of inbounds EXCLUDED because no price
551    /// was available for their date (those stay pending — an income row with no FMV would year-gate).
552    /// Each is a voidable decision; for a single deposit use `classify-inbound-income`.
553    BulkClassifyInboundIncome {
554        /// Income kind for the whole batch: mining|staking|interest|airdrop|reward.
555        #[arg(long)]
556        kind: String,
557        /// Whether this income is from a trade or business (true → SE-tax eligible).
558        #[arg(long)]
559        business: bool,
560        /// Restrict to a single tax year (mutually exclusive with --from/--to).
561        #[arg(long, conflicts_with_all = ["from", "to"])]
562        year: Option<i32>,
563        /// Range start (YYYY-MM-DD; requires --to).
564        #[arg(long, requires = "to")]
565        from: Option<String>,
566        /// Range end (YYYY-MM-DD, inclusive; requires --from).
567        #[arg(long, requires = "from")]
568        to: Option<String>,
569        /// Only inbounds received INTO this wallet.
570        #[arg(long)]
571        wallet: Option<String>,
572        /// Print the preview and exit without writing.
573        #[arg(long)]
574        dry_run: bool,
575        /// Skip the interactive confirmation (non-interactive apply).
576        #[arg(long)]
577        yes: bool,
578    },
579    /// Bulk-reclassify unknown pending OUTFLOWS as dispositions (Sell|Spend): reclassify MANY pending
580    /// `TransferOut`s as a `Dispose` in one confirmed batch, with the daily-close market value at the
581    /// outflow date as the ESTIMATED proceeds. Shows a preview surfacing the total ESTIMATED proceeds
582    /// AND the total ESTIMATED gain (sum(fmv) - sum(basis)) + the count of outflows EXCLUDED because no price
583    /// was available for their date (those stay pending — a Sell with fabricated proceeds would be a
584    /// SILENT misreport). `--kind` is UNIFORM and accepts ONLY sell|spend (gift/donate are out of
585    /// scope). Each is a voidable decision; for a single outflow use `reclassify-outflow`.
586    BulkReclassifyOutflow {
587        /// Disposition kind for the whole batch: sell|spend (gift/donate rejected — out of scope).
588        #[arg(long)]
589        kind: String,
590        /// Restrict to a single tax year (mutually exclusive with --from/--to).
591        #[arg(long, conflicts_with_all = ["from", "to"])]
592        year: Option<i32>,
593        /// Range start (YYYY-MM-DD; requires --to).
594        #[arg(long, requires = "to")]
595        from: Option<String>,
596        /// Range end (YYYY-MM-DD, inclusive; requires --from).
597        #[arg(long, requires = "from")]
598        to: Option<String>,
599        /// Only outflows from this SOURCE wallet.
600        #[arg(long)]
601        wallet: Option<String>,
602        /// Print the preview and exit without writing.
603        #[arg(long)]
604        dry_run: bool,
605        /// Skip the interactive confirmation (non-interactive apply).
606        #[arg(long)]
607        yes: bool,
608    },
609    /// Bulk-resolve import conflicts: ACCEPT (adopt each new payload) or REJECT (keep each current
610    /// payload) MANY flagged `ImportConflict`s in one confirmed batch. Shows a `current → new` preview,
611    /// then requires --yes (or interactive y/N). Exactly one of --accept / --reject is required. Each
612    /// resolution is NON-REVOCABLE (`SupersedeImport`/`RejectImport` cannot be voided); to resolve a
613    /// conflict differently, exclude it and use single-item `accept-conflict`/`reject-conflict`.
614    #[command(group(clap::ArgGroup::new("resolve_action").required(true).args(["accept", "reject"])))]
615    BulkResolveConflict {
616        /// Accept every listed conflict (adopt each new payload onto its target).
617        #[arg(long)]
618        accept: bool,
619        /// Reject every listed conflict (keep each target's current payload).
620        #[arg(long)]
621        reject: bool,
622        /// Print the preview and exit without writing.
623        #[arg(long)]
624        dry_run: bool,
625        /// Skip the interactive confirmation (non-interactive apply).
626        #[arg(long)]
627        yes: bool,
628    },
629    /// Bulk-void MANY revocable reconcile decisions in one confirmed batch (bulk-void). Shows a preview
630    /// of every voidable decision (the SHARED `voidable_decisions` predicate — effective safe-harbor
631    /// allocations are OMITTED, #7), then requires --yes (or interactive y/N). Each void is
632    /// NON-REVOCABLE (a `VoidDecisionEvent` cannot itself be voided — re-apply the original decision to
633    /// restore). Voiding a `LotSelection` also re-exposes its disposal to the default method and clears
634    /// its optimizer attestation.
635    BulkVoid {
636        /// Print the preview and exit without writing.
637        #[arg(long)]
638        dry_run: bool,
639        /// Skip the interactive confirmation (non-interactive apply).
640        #[arg(long)]
641        yes: bool,
642    },
643    /// Match unreconciled inbound + outbound legs as self-transfers (self-transfer-passthrough C3).
644    /// With no --in/--out: PREVIEW the proposed pairs (read-only). With --in and --out: confirm ONE
645    /// pair (DROP for a same-wallet passthrough, RELOCATE for a cross-wallet transfer). NEVER automatic.
646    MatchSelfTransfers {
647        /// Confirm this in-leg (TransferIn eventref); requires --out.
648        #[arg(long = "in", requires = "out_ref")]
649        in_ref: Option<String>,
650        /// Confirm this out-leg (TransferOut eventref); requires --in.
651        #[arg(long = "out", requires = "in_ref")]
652        out_ref: Option<String>,
653        /// Override the suggested action (else the proposal's topology-derived action is used).
654        #[arg(long, value_enum)]
655        action: Option<SelfTransferActionArg>,
656        /// Print the preview and exit without writing (conflicts with --in/--out).
657        #[arg(long, conflicts_with_all = ["in_ref", "out_ref"])]
658        dry_run: bool,
659    },
660    /// Pseudo-reconcile MODE (sub-project 2): fill deliberately-fictional default decisions at
661    /// projection time (NEVER persisted) to clear the Hard classification blockers — a loudly-flagged
662    /// `[PSEUDO]` on-screen estimate you correct toward truth. `on`/`off` toggle the mode; `approve`
663    /// promotes chosen defaults to real (attested) decisions.
664    #[command(subcommand)]
665    Pseudo(Pseudo),
666}
667
668/// `reconcile pseudo <action>` — the pseudo-reconcile mode sub-verbs (sub-project 2).
669#[derive(Subcommand)]
670pub enum Pseudo {
671    /// Turn pseudo-reconcile mode ON. Projection now synthesizes non-persisted default decisions for
672    /// unresolved unknown-basis inbounds (self-transfer $0), unclassified rows, and import conflicts
673    /// (accept-first); every synthetic contribution is flagged `[PSEUDO]` on screen and BLOCKS export.
674    On,
675    /// Turn pseudo-reconcile mode OFF. Projection reverts to real-only instantly and totally (no
676    /// fictional events were ever written). Already-approved decisions REMAIN (they are real now).
677    Off,
678    /// Promote pseudo default decisions to REAL (attested) decisions in bulk. Shows a preview + requires
679    /// `--yes` (or `--dry-run` to preview only). Optional filters restrict which defaults are approved.
680    Approve {
681        /// Only approve defaults of this TYPE: `self-transfer` (unknown-basis inbound → $0 self-transfer),
682        /// `raw` (unclassified row placeholder), `conflict` (import conflict accept-first), or `fmv`
683        /// (native income FMV synthesized from the daily close). Omit = all.
684        #[arg(long, value_enum)]
685        kind: Option<PseudoKindArg>,
686        /// Only approve defaults whose target event is in this wallet (e.g. `exchange:coinbase:main`).
687        #[arg(long)]
688        wallet: Option<String>,
689        /// Only approve defaults whose target event falls in this tax year.
690        #[arg(long)]
691        year: Option<i32>,
692        /// Print the preview and exit without writing.
693        #[arg(long)]
694        dry_run: bool,
695        /// Skip the interactive confirmation (non-interactive apply).
696        #[arg(long)]
697        yes: bool,
698    },
699}
700
701/// The pseudo-default TYPE filter for `reconcile pseudo approve --kind`.
702#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
703pub enum PseudoKindArg {
704    /// Unknown-basis inbound defaulted to a $0-basis self-transfer-in.
705    SelfTransfer,
706    /// Unclassified row defaulted to a zero-value placeholder (ClassifyRaw).
707    Raw,
708    /// Import conflict defaulted to accept-first (SupersedeImport).
709    Conflict,
710    /// Native income with a missing FMV defaulted to the daily-close value (ManualFmv).
711    Fmv,
712}
713
714#[derive(Copy, Clone, ValueEnum)]
715pub enum SelfTransferActionArg {
716    /// Same-wallet passthrough → SelfTransferPassthrough (both legs skipped, non-taxable).
717    Drop,
718    /// Cross-wallet transfer → TransferLink (relocate the lots to the destination wallet).
719    Relocate,
720}
721
722/// One official form in the `export-irs-pdf` packet (the `--forms` opt-in filter).
723#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
724pub enum FormArg {
725    /// Form 8949 (per-disposition capital-gains rows).
726    F8949,
727    /// Schedule D (aggregated capital-gains totals).
728    ScheduleD,
729    /// Schedule SE (self-employment tax).
730    ScheduleSe,
731    /// Form 8283 (noncash charitable contributions).
732    Form8283,
733    /// Form 1040 (capital-gains cells + the digital-asset question).
734    Form1040,
735}
736
737#[derive(Copy, Clone, ValueEnum)]
738pub enum FilingStatusArg {
739    Single,
740    Mfj,
741    Mfs,
742    Hoh,
743    Qss,
744}
745
746impl From<FilingStatusArg> for FilingStatus {
747    fn from(a: FilingStatusArg) -> Self {
748        match a {
749            FilingStatusArg::Single => FilingStatus::Single,
750            FilingStatusArg::Mfj => FilingStatus::Mfj,
751            FilingStatusArg::Mfs => FilingStatus::Mfs,
752            FilingStatusArg::Hoh => FilingStatus::HoH,
753            FilingStatusArg::Qss => FilingStatus::Qss,
754        }
755    }
756}
757
758#[derive(Copy, Clone, ValueEnum)]
759pub enum FeeArg {
760    C,
761    B,
762}
763
764#[derive(Copy, Clone, ValueEnum)]
765pub enum MethodLotArg {
766    Fifo,
767    Lifo,
768    Hifo,
769}
770
771#[derive(Copy, Clone, ValueEnum)]
772pub enum OutKindArg {
773    Sell,
774    Spend,
775    Gift,
776    Donate,
777}
778
779impl From<MethodLotArg> for LotMethod {
780    fn from(a: MethodLotArg) -> Self {
781        match a {
782            MethodLotArg::Fifo => LotMethod::Fifo,
783            MethodLotArg::Lifo => LotMethod::Lifo,
784            MethodLotArg::Hifo => LotMethod::Hifo,
785        }
786    }
787}
788
789#[derive(Copy, Clone, ValueEnum)]
790pub enum MethodArg {
791    Actual,
792    ProRata,
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use clap::CommandFactory;
799
800    /// Render the LONG help (`--help`) of a subcommand identified by its path, recursing into
801    /// nested subcommands (e.g. `["reconcile", "import-selections"]`). Mirrors what a user sees at
802    /// `btctax <path...> --help`, which includes each argument's verbatim long-help.
803    fn long_help_of(path: &[&str]) -> String {
804        let mut cmd = Cli::command();
805        for name in path {
806            cmd = cmd
807                .find_subcommand(name)
808                .unwrap_or_else(|| panic!("subcommand {name:?} exists"))
809                .clone();
810        }
811        cmd.render_long_help().to_string()
812    }
813
814    // Requirement 3, `--help` half: each file/format-taking arg's long-help carries its FORMAT +
815    // a text EXAMPLE. Tokens are comma/brace-joined (no spaces) so help-wrapping can never break
816    // them (verified against the real binary output). This is the single source of truth that
817    // clap_mangen also renders into the per-subcommand man page (Task 2).
818
819    #[test]
820    fn help_documents_key_backup_format() {
821        let h = long_help_of(&["init"]);
822        assert!(
823            h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
824            "init --key-backup help must document the ASCII-armored key format:\n{h}"
825        );
826    }
827
828    #[test]
829    fn help_documents_backup_key_format() {
830        let h = long_help_of(&["backup-key"]);
831        assert!(
832            h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
833            "backup-key --out help must document the ASCII-armored key format:\n{h}"
834        );
835    }
836
837    #[test]
838    fn help_documents_export_snapshot_format() {
839        let h = long_help_of(&["export-snapshot"]);
840        // The exact removals.csv header read from the render.rs writer.
841        assert!(
842            h.contains("event,kind,removed_at,lot,sat,basis,fmv_at_transfer"),
843            "export-snapshot --out help must document the projection CSV headers:\n{h}"
844        );
845    }
846
847    #[test]
848    fn help_documents_import_selections_format() {
849        let h = long_help_of(&["reconcile", "import-selections"]);
850        assert!(
851            h.contains("disposal_ref,origin_event_id,split_sequence,sat"),
852            "import-selections help must document the required CSV header:\n{h}"
853        );
854    }
855
856    #[test]
857    fn help_documents_classify_raw_format() {
858        let h = long_help_of(&["reconcile", "classify-raw"]);
859        // The exact externally-tagged serde shape (Usd = decimal string, sat = integer).
860        assert!(
861            h.contains(r#"{"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}"#),
862            "classify-raw --payload-json help must document the JSON payload shape:\n{h}"
863        );
864    }
865
866    #[test]
867    fn help_documents_select_lots_format() {
868        let h = long_help_of(&["reconcile", "select-lots"]);
869        assert!(
870            h.contains("import|coinbase|X#0:25000"),
871            "select-lots --from help must document the <event>#<split>:<sat> pick format:\n{h}"
872        );
873    }
874}