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    ///
50    /// Exit code with --tax-year (UX-P4-10): 0 = a filing-ready number was rendered; 1 = ran but the
51    /// year is NOT COMPUTABLE (no filing-ready number — a missing tax profile/table or a hard blocker;
52    /// mirrors `verify`); 2 = the command failed (any error). Scripts should key on NON-ZERO. A
53    /// pseudo-active report still exits 0 — the on-screen banner is the signal, not the exit code.
54    #[command(alias = "show")]
55    Report {
56        /// Filter realized disposals/removals/income to a specific calendar year (display path).
57        #[arg(long)]
58        year: Option<i32>,
59        /// Compute the crypto-attributable federal tax for the given tax year (B.5 / Task 9).
60        /// Requires a stored tax profile (`tax-profile --year Y ...`) and the bundled TY table.
61        /// Independent of --year; the two flags are not aliased.
62        #[arg(long)]
63        tax_year: Option<i32>,
64        /// Cumulative prior-year TAXABLE gifts (post-annual-exclusion Form 709 amounts), not
65        /// gross gifts. Used for the §2505 lifetime-exclusion consumption advisory. Defaults to
66        /// $0 when omitted (the advisory discloses this assumption). Must not be negative.
67        #[arg(long)]
68        prior_taxable_gifts: Option<String>,
69        /// §4 R3-M6: persist this year's computed charitable + QBI carryover-OUT as next year's
70        /// carryover-IN (a full-return `--tax-year` ReturnInputs year only). `report` is otherwise
71        /// read-only; this flag opts into the vault write.
72        #[arg(long, default_value_t = false)]
73        write_carryover: bool,
74        /// With `--write-carryover`: overwrite a next-year carryover-in that was user-entered
75        /// (`income import`). Without it, a user-entered value is left untouched and the write refuses.
76        #[arg(long, default_value_t = false)]
77        force: bool,
78    },
79    /// Discover the reconciliation event references (`ref`s) you pass to the `reconcile` verbs.
80    #[command(subcommand)]
81    Events(Events),
82    /// Print the LIMITATIONS & supported-forms document: what a v1 full return covers, the credits it
83    /// omits conservatively (your tax is overstated, never understated), what it refuses outright, and
84    /// what it cannot represent. Read this before you file.
85    Limitations,
86    /// Emit a reconciliation decision event.
87    #[command(subcommand)]
88    Reconcile(Reconcile),
89    /// Show or set projection config (TP8 fee treatment / pre-2025 lot method / forward method).
90    Config {
91        #[arg(long, value_enum)]
92        set_fee_treatment: Option<FeeArg>,
93        #[arg(long, value_enum)]
94        set_pre2025_method: Option<MethodLotArg>,
95        #[arg(long, default_value_t = false)]
96        attest_pre2025_method: bool,
97        /// §A.5(a): append a MethodElection decision (the forward standing order). Not a flag
98        /// mutation — this is an event in the ledger. Use --effective-from to set the date
99        /// (default: today / the decision's made-date).
100        #[arg(long, value_enum)]
101        set_forward_method: Option<MethodLotArg>,
102        /// §A.5(a) per-ACCOUNT scope for --set-forward-method (IRS 2025+ per-account rule):
103        /// exchange:PROVIDER:ACCOUNT (the canonical wallet grammar). Omit for a GLOBAL election
104        /// (the existing behavior). Only exchange accounts are electable (a method election is a
105        /// brokerage-account concept; self:LABEL is rejected). The account MUST already exist in the
106        /// vault — an unknown/typo'd account is rejected LOUDLY so it can't create a dead election.
107        #[arg(long)]
108        exchange: Option<String>,
109        /// Effective-from date for --set-forward-method (YYYY-MM-DD). Defaults to made-date.
110        #[arg(long)]
111        effective_from: Option<String>,
112    },
113    /// FR10: export decrypted SQLite + CSV (the NFR2 plaintext exception).
114    ///
115    /// WARNS (does not refuse) on unresolved Hard blockers: any Hard blocker makes every affected
116    /// tax year NOT COMPUTABLE, so the exported Form 8949 / Schedule D / figures are INFORMATIONAL,
117    /// not final. A warning is printed to stderr and the export still succeeds (exit 0). Automation
118    /// that must GATE on unresolved blockers should check `btctax verify` (which exits non-zero),
119    /// since export-snapshot itself stays exit 0.
120    ExportSnapshot {
121        /// Output DIRECTORY receiving the decrypted SQLite DB (snapshot.sqlite) + projection CSVs
122        /// (the NFR2 plaintext exception; created owner-only). ALWAYS writes: lots.csv,
123        /// disposals.csv, removals.csv, income.csv. With --tax-year it ALSO writes form8949.csv,
124        /// schedule_d.csv, form8283.csv, and schedule_se.csv (schedule_se only when there is
125        /// business self-employment income). The `event` column in disposals.csv / removals.csv /
126        /// income.csv is the event-ref that reconcile commands consume (select-lots,
127        /// set-donation-details, reclassify-income, …).
128        ///
129        /// FORMAT (removals.csv header + one sample donation row):
130        ///   event,kind,removed_at,lot,sat,basis,fmv_at_transfer,term,acquired_at,claimed_deduction,donee
131        ///   import|coinbase|X,donation,2025-03-01,import|coinbase|X#0,25000,120.00,150.00,long,2023-01-05,150.00,Charity Y
132        #[arg(long, verbatim_doc_comment)]
133        out: PathBuf,
134        /// Also emit the per-tax-year Form 8949 + Schedule D CSVs (form8949.csv / schedule_d.csv),
135        /// scoped to this calendar year. Omit to write only the all-years projection CSVs.
136        #[arg(long)]
137        tax_year: Option<i32>,
138        /// Attestation phrase required to export while the ledger is PSEUDO-RECONCILED (a synthetic
139        /// default contributes to the projection). Pass the exact phrase `I attest this is true`
140        /// (trimmed, case-sensitive) to export the fictional draft ON PURPOSE. Omit on a fully-real
141        /// ledger (never gated). Omit on an interactive terminal to be prompted; omit when piped
142        /// (non-TTY) while pseudo-active and the export is refused.
143        #[arg(long)]
144        attest: Option<String>,
145    },
146    /// Fill the OFFICIAL IRS fillable PDFs for a tax year (a whole packet).
147    ///
148    /// Writes (owner-only) into --out, populated from btctax's already-computed projection — no
149    /// capital-gains figure is recomputed:
150    ///   - f8949.pdf + schedule_d.pdf — ALWAYS. On the 2025 (1099-DA) revision Bitcoin is filed under
151    ///     Box I (short-term) / Box L (long-term) — the digital-asset boxes; on the pre-1099-DA 2024
152    ///     and 2017 revisions it is Box C / Box F ("not reported on a 1099-B"). Never the wrong pair
153    ///     for the year. More rows than a part's grid holds (11 in 2025, 14 in 2024/2017) paginate
154    ///     onto multiple copies, each with its own totals.
155    ///   - schedule_se.pdf — when there is business self-employment income and net earnings are ≥ the
156    ///     $400 floor. Line 12 (SE tax) = Social Security + regular Medicare ONLY; the 0.9% Additional
157    ///     Medicare Tax is a Form 8959 item (flagged on stderr, not put on Schedule SE). Requires a
158    ///     stored `tax-profile` for the year (filing status); missing profile ⇒ a NOTE, not a form.
159    ///   - form_8283.pdf — when there are BTC donations. Fills the donee/appraiser IDENTITY + per-
160    ///     donation property rows (Section A ≤ $5,000 or Section B > $5,000). The property-type box is
161    ///     "k Digital assets" on the Rev. 12-2023/2025 forms (2024/2025); the 2017 Rev. 12-2014 form
162    ///     has no such box, so BTC uses "j Other" + a printed note. Leaves every OTHER party's
163    ///     declaration/signature BLANK — a Section B 8283 is NOT filing-ready without those signed.
164    ///     Overflows onto additional copies.
165    ///   - form_1040_capgains.pdf — when there is reportable capital/digital-asset activity. Fills the
166    ///     capital-gain line (line 7a in 2025 / line 7 in 2024 / line 13 in 2017, when Schedule D is
167    ///     active and line 16 ≥ 0; active-and-zero → "-0-"; a net loss leaves it blank — the §1211
168    ///     line-21 cap is yours) and, on 2024/2025, the Digital-Asset question (YES iff any disposal,
169    ///     income, gift, or donation; never a "No"). The 2017 form has no Digital-Asset question, so an
170    ///     income-only 2017 year produces no 1040. 7b checkboxes are untouched.
171    ///
172    /// Every written value is read back GEOMETRICALLY against the blank PDF's own field coordinates and
173    /// the fill FAILS CLOSED on any mis-placement — a wrong tax form is never written. The engine drops
174    /// the forms' XFA layer (else Acrobat opens them blank) and sets NeedAppearances so a viewer
175    /// regenerates the visible values. Schedule D lines 17-22 (28%-rate / unrecaptured-§1250 / QDI
176    /// worksheet, incl. the line-21 loss limit) are OUT OF SCOPE. Rows on an exchange that MAY carry
177    /// broker reporting (a 1099-DA from TY2025; a 1099-B before) are flagged on stderr — btctax files
178    /// every row under the year's not-reported box (I/L from TY2025, C/F before) and says so.
179    ///
180    /// A tax year that has FULL-RETURN inputs (`income import`) DISPATCHES to the complete return packet —
181    /// the 1040 and every schedule/attachment it cites, in Attachment-Sequence order, plus a manifest
182    /// (`--forms` is ignored on that path). A crypto-only year (no `income import`) instead fills the
183    /// crypto SLICE: Schedule D carries only the ledger's crypto totals — it has no line 13 (1099-DIV
184    /// box-2a capital-gain distributions) and no lines 6/14 (capital-loss carryovers), and the 1040 fill
185    /// covers only the capital-gain cluster. For a crypto-only year those slice forms are complete and
186    /// correct; a full-return year needs the full packet, which is why the two paths are dispatched
187    /// separately (and write non-overlapping filenames). See `btctax limitations`.
188    ///
189    /// PSEUDO-RECONCILED ledgers: the same attestation gate as export-snapshot applies, AND every
190    /// page is stamped with a diagonal `DRAFT — ESTIMATE, NOT FOR FILING` watermark.
191    ExportIrsPdf {
192        /// Output DIRECTORY receiving the filled official PDFs (created owner-only): f8949.pdf,
193        /// schedule_d.pdf, and — when applicable — schedule_se.pdf, form_8283.pdf,
194        /// form_1040_capgains.pdf. These contain your unencrypted tax data — write --out OUTSIDE any
195        /// git repo.
196        #[arg(long, verbatim_doc_comment)]
197        out: PathBuf,
198        /// The tax year to fill (this build bundles TY2017, TY2024 and TY2025; other years are
199        /// refused). TY2024/TY2017 are pre-1099-DA revisions: Bitcoin is filed under Box C/F (not Box
200        /// I/L). TY2017 additionally uses the OLD forms — the §B long Schedule SE, Form 8283 Rev.
201        /// 12-2014 ("j Other", no digital-asset box), the 1040 capital gain on line 13, and NO
202        /// Digital-Asset question.
203        #[arg(long)]
204        tax_year: i32,
205        /// Restrict the crypto-slice packet to specific forms (repeat or comma-separate). Values:
206        /// `f8949`, `schedule-d`, `schedule-se`, `form8283`, `form1040`. Default = every applicable form
207        /// (f8949 + schedule-d always; schedule-se when SE income ≥ the $400 floor; form8283 when there
208        /// are donations; form1040 when there is reportable digital-asset activity). A named form is still
209        /// skipped when it does not apply. Ignored on a full-return year (that path fills the whole packet).
210        #[arg(long, value_enum, value_delimiter = ',')]
211        forms: Vec<FormArg>,
212        /// Attestation phrase required to export while the ledger is PSEUDO-RECONCILED (a synthetic
213        /// default contributes to the projection). Pass the exact phrase `I attest this is true`
214        /// (trimmed, case-sensitive) to fill the DRAFT-watermarked forms ON PURPOSE. Omit on a
215        /// fully-real ledger (never gated). Omit on an interactive terminal to be prompted; omit when
216        /// piped (non-TTY) while pseudo-active and the export is refused.
217        #[arg(long)]
218        attest: Option<String>,
219    },
220    /// Export the passphrase-protected key.
221    BackupKey {
222        /// File to write the exported key to: an ASCII-armored, passphrase(S2K)-encrypted private
223        /// key, owner-only (mode 0600). Identical format to `init --key-backup`.
224        ///
225        /// FORMAT (structure — NOT a real key):
226        ///   -----BEGIN PGP PRIVATE KEY BLOCK-----
227        ///   ... base64 armor of the S2K-encrypted secret key ...
228        ///   -----END PGP PRIVATE KEY BLOCK-----
229        #[arg(long, verbatim_doc_comment)]
230        out: PathBuf,
231    },
232    /// Lot-specific-identification optimizer (§C — read-only proposal or gated persistence).
233    #[command(subcommand)]
234    Optimize(Optimize),
235    /// Read-only what-if tax planning (task #43): posit a HYPOTHETICAL, NON-persisted transaction and
236    /// see its MARGINAL federal-tax effect on the current-year position. Routes through the same audited
237    /// tax engine as `report --tax-year`; invents no tax authority. Writes NOTHING — no event, no
238    /// side-table row, no vault mutation. Tax decision-support (consequences), not buy/sell/hold advice.
239    #[command(subcommand)]
240    WhatIf(WhatIf),
241    /// Full-return (v1) input surface: import, show, or clear the per-year full-return inputs (W-2s,
242    /// 1099s, deductions, household). Offline; stored in the encrypted vault.
243    #[command(subcommand)]
244    Income(IncomeCmd),
245    /// Set or show the per-tax-year tax profile (filing status, income, MAGI, etc.).
246    TaxProfile {
247        /// The tax year (e.g. 2025).
248        #[arg(long)]
249        year: i32,
250        /// IRS filing status.
251        #[arg(long, value_enum)]
252        filing_status: Option<FilingStatusArg>,
253        /// Ordinary taxable income EXCLUDING all app-computed crypto items (net ST gains,
254        /// mining/staking ordinary income). The engine adds the crypto items on top (B.1 / I5).
255        #[arg(long)]
256        ordinary_taxable_income: Option<String>,
257        /// Modified AGI excluding crypto items, for the §1411 NIIT threshold comparison.
258        ///
259        /// IMPORTANT (§1411 contract): this value MUST already include the taxpayer's qualified
260        /// dividends and non-crypto net capital gains (and any other MAGI add-backs from
261        /// §1411(d)). The engine adds ONLY the crypto AGI delta on top (ambiguity #5 in the
262        /// design). Omitting QD or non-crypto cap gains from this figure understates NIIT.
263        #[arg(
264            long,
265            long_help = "Modified AGI excluding crypto items, for the §1411 NIIT \
266            threshold comparison.\n\nIMPORTANT (§1411 contract): this value MUST already \
267            include the taxpayer's qualified dividends and non-crypto net capital gains (and \
268            any other MAGI add-backs from §1411(d)). The engine adds ONLY the crypto AGI \
269            delta on top (ambiguity #5 in the design). Omitting QD or non-crypto cap gains \
270            from this figure understates NIIT."
271        )]
272        magi_excluding_crypto: Option<String>,
273        /// Qualified dividends + other preferential-rate income sharing the §1(h) 0/15/20 LTCG
274        /// rate stack. Required when setting a profile.
275        #[arg(long)]
276        qualified_dividends: Option<String>,
277        /// Non-crypto net LT-character capital gain already in the profile (optional; defaults
278        /// to 0 when omitted).
279        #[arg(long)]
280        other_net_capital_gain: Option<String>,
281        /// §1212(b) short-term capital loss carryforward into this year (optional; defaults to 0).
282        #[arg(long)]
283        carryforward_short: Option<String>,
284        /// §1212(b) long-term capital loss carryforward into this year (optional; defaults to 0).
285        #[arg(long)]
286        carryforward_long: Option<String>,
287        /// Form W-2 Social Security wages (Box 3 + Box 7 tips; Schedule SE line 8a).
288        /// Reduces the §1401(a) SS cap: ss_cap = max(0, wage_base − w2_ss_wages). Optional;
289        /// defaults to $0. Must not be negative.
290        #[arg(long)]
291        w2_ss_wages: Option<String>,
292        /// Medicare wages (Box 5; Form 8959 line 1).
293        /// Reduces the Additional-Medicare threshold: addl_threshold = max(0, threshold − w2_medicare_wages)
294        /// (§1401(b)(2)(B)/Form 8959 Part II). Optional; defaults to $0. Must not be negative.
295        #[arg(long)]
296        w2_medicare_wages: Option<String>,
297        /// Schedule C deductible business expenses for the year — reduces net SE earnings;
298        /// the income-tax stack above is NOT adjusted (see the advisory).
299        /// Optional; defaults to $0. Must not be negative.
300        #[arg(long)]
301        schedule_c_expenses: Option<String>,
302        /// Show the stored profile for `--year` instead of setting it.
303        #[arg(long, default_value_t = false)]
304        show: bool,
305        /// Store the raw profile even when full-return inputs (`income import`) already exist for the year
306        /// (they take precedence, so the raw profile would otherwise be ignored — D-4 guard).
307        #[arg(long, default_value_t = false)]
308        force: bool,
309    },
310}
311
312/// `optimize` subcommand tree.  Task 9 adds `Run`; Task 10 adds `Accept`; Task 11 adds `Consult`.
313#[derive(Subcommand)]
314pub enum Optimize {
315    /// Mode-1 what-if: print the tax-saving lot-selection proposal. NOTHING is filed or bound.
316    Run {
317        /// The tax year to optimize (must be 2025 or later).
318        #[arg(long)]
319        tax_year: i32,
320    },
321    /// Mode-1 gated persistence: recompute the optimum and persist the proposed LotSelection(s),
322    /// gated per disposal (§1.1012-1(j)). A genuinely-contemporaneous pick (made ≤ sale) persists
323    /// freely; an already-executed disposal persists ONLY with a narrow per-disposal `--attest`
324    /// scoped to one `--disposal`; a 2027+ broker-held pick is refused. Revoke via `reconcile void`.
325    Accept {
326        /// The tax year to accept (must be 2025 or later).
327        #[arg(long)]
328        tax_year: i32,
329        /// Restrict to ONE disposal (required to carry `--attest`).
330        #[arg(long)]
331        disposal: Option<String>,
332        /// Narrow contemporaneous-ID attestation for an already-executed disposal. Requires
333        /// `--disposal` (no blanket attestation across all disposals).
334        #[arg(long)]
335        attest: Option<String>,
336    },
337    /// Mode-2 read-only pre-trade what-if (§C.3): tax-min lots + ST/LT split + federal tax + ST→LT
338    /// timing. NOTHING is written — no event, no side-table row. Tax decision-support only;
339    /// not buy/sell/hold advice.
340    Consult {
341        /// Hypothetical sale amount (required). Accepts a satoshi integer OR a BTC decimal, e.g.
342        /// `0.05` or `5000000` (a value with a `.` is BTC; a bare integer is satoshis).
343        #[arg(long)]
344        sell: String,
345        /// Wallet to sell from, e.g. `self:cold` or `exchange:coinbase:default` (required; per-wallet
346        /// pool is mandatory post-2025).
347        #[arg(long)]
348        wallet: Option<String>,
349        /// Sale date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
350        #[arg(long)]
351        at: Option<String>,
352        /// Explicit USD proceeds for the hypothetical sale. Required when `--at` is a future date
353        /// with no bundled dataset price and `--fmv` is not used. Mutually exclusive with `--fmv`.
354        #[arg(long, conflicts_with = "fmv")]
355        proceeds: Option<String>,
356        /// Use the bundled daily-close FMV for `--at` instead of an explicit proceeds amount.
357        /// A future date with no dataset price will return a ProceedsRequired error. Mutually
358        /// exclusive with `--proceeds`.
359        #[arg(long, conflicts_with = "proceeds")]
360        fmv: bool,
361    },
362}
363
364/// Full-return (v1) input subcommands (SPEC §4 / recon-04 §6). v1 ships the TOML bulk-import path +
365/// a JSON show; incremental per-field subcommands (`add-w2`, …) are a follow-on.
366#[derive(Subcommand)]
367pub enum IncomeCmd {
368    /// Import full-return inputs from an offline TOML file into the vault for a tax year.
369    Import {
370        /// The tax year (e.g. 2024).
371        #[arg(long)]
372        year: i32,
373        /// Path to the TOML file describing the full-return inputs.
374        #[arg(long)]
375        file: std::path::PathBuf,
376    },
377    /// Show the stored full-return inputs for a tax year (JSON, PII redacted), or nothing if none set.
378    Show {
379        /// The tax year (e.g. 2024).
380        #[arg(long)]
381        year: i32,
382    },
383    /// Remove the stored full-return inputs for a tax year (fall back to a raw `tax-profile`).
384    Clear {
385        /// The tax year (e.g. 2024).
386        #[arg(long)]
387        year: i32,
388    },
389    /// Answer the return's fail-loud questions interactively — the yes/no boxes that have no safe
390    /// default (can someone claim you as a dependent? Schedule B's foreign-account and foreign-trust
391    /// lines) plus the optional dates of birth.
392    ///
393    /// These questions REFUSE the return until they are answered: guessing "no" on your behalf would
394    /// understate your tax and print an unchecked box you never affirmed. This is the only way to answer
395    /// them without editing a TOML file. It never asks for a secret — SSNs and the IP PIN belong to
396    /// `set-pii`, which does not echo what you type.
397    ///
398    /// Requires an existing return for the year (create one with `income import`).
399    Answer {
400        /// The tax year (e.g. 2024).
401        #[arg(long)]
402        year: i32,
403    },
404}
405
406/// `what-if` subcommand tree (task #43). READ-ONLY hypothetical-transaction tax planning: NOTHING is
407/// filed, appended, or persisted. Mirrors the `optimize consult` shape, plus an ad-hoc `TaxProfile`
408/// (so you can plan without `tax-profile set`).
409#[derive(Subcommand)]
410pub enum WhatIf {
411    /// Posit a hypothetical, NON-persisted SALE and see its MARGINAL federal tax: the lots it would
412    /// consume, the ST/LT split, which §1(h) LTCG bracket (0/15/20) it lands in + room to the next
413    /// breakpoint, the exact marginal tax (with-hypothetical minus baseline — the sale's OWN effect,
414    /// not the whole-year figure), the effective rate, the §1212(b) carryforward carried to next year,
415    /// this year's ordinary offset, and the §1411 NIIT delta. A net loss surfaces the carryforward
416    /// disclosure (its value is NOT this-year tax). Writes NOTHING.
417    Sell {
418        /// Hypothetical sale amount (required). Accepts a satoshi integer OR a BTC decimal, e.g.
419        /// `0.05` or `5000000` (a value with a `.` is BTC; a bare integer is satoshis).
420        #[arg(long)]
421        sell: String,
422        /// Wallet to sell from, e.g. `self:cold` or `exchange:coinbase:default` (required; the
423        /// per-wallet pool is mandatory post-2025).
424        #[arg(long)]
425        wallet: Option<String>,
426        /// Sale date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
427        #[arg(long)]
428        at: Option<String>,
429        /// USD price per WHOLE BTC for the hypothetical sale (proceeds = price × sat / 1e8). Omit to
430        /// use the bundled daily-close FMV for `--at`; REQUIRED for a future/off-dataset `--at` with no
431        /// bundled price (else the what-if returns a ProceedsRequired error).
432        #[arg(long)]
433        price: Option<String>,
434        /// Lot-selection method for the hypothetical sale: fifo|lifo|hifo. Omit to consume by the
435        /// STANDING method (the account's in-force election / the default), exactly as a real disposal
436        /// on that date would.
437        #[arg(long, value_enum)]
438        method: Option<MethodLotArg>,
439        /// AD-HOC filing status (single|mfj|mfs|hoh|qss). Supplying this (with `--income`) builds a
440        /// NON-persisted profile for the plan instead of the stored `tax-profile`. Omit ALL ad-hoc
441        /// flags to use the stored profile for the sale year.
442        #[arg(long, value_enum)]
443        filing_status: Option<FilingStatusArg>,
444        /// AD-HOC ordinary taxable income EXCLUDING crypto (the base the crypto stacks on). Required
445        /// when building an ad-hoc profile.
446        #[arg(long)]
447        income: Option<String>,
448        /// AD-HOC modified AGI excluding crypto, for the §1411 NIIT threshold. DEFAULTS TO `--income`
449        /// when omitted (never $0 — a $0 MAGI would silently suppress every NIIT disclosure); a printed
450        /// caveat notes the assumption. Supply the true MAGI (incl. QD + non-crypto cap gains) to avoid
451        /// understating NIIT.
452        #[arg(long)]
453        magi: Option<String>,
454        /// AD-HOC §1212(b) LONG-TERM capital-loss carryforward INTO the sale year (optional; defaults
455        /// to $0). The dominant BTC case; short-term carryforward-in is out of scope for the ad-hoc
456        /// profile (set a stored `tax-profile` for that).
457        #[arg(long)]
458        carryforward_in: Option<String>,
459    },
460    /// Posit a hypothetical, NON-persisted HARVEST and find the MAX BTC to sell such that a target holds
461    /// on the ENTIRE prefix [0, N]: `--target zero-ltcg` (sell all that fits in the §1(h) 0% bracket),
462    /// `fifteen-ltcg` (stay at/under 15%), `gain=$X` (realize at most $X of gain WITH this sale), or
463    /// `tax=$X` (add at most $X of marginal federal tax; `tax=$0` is the flagship "zero-tax harvest").
464    /// Uses the STANDING lot method's consumption order (never re-optimized). Discloses the §1212(b)
465    /// carryforward burn, the §1411 NIIT kink (a 0%/15% answer can still cost +3.8%), and the plateau
466    /// notes. The answer is ALWAYS engine-verified. Writes NOTHING.
467    Harvest {
468        /// The harvest target: `zero-ltcg` | `fifteen-ltcg` | `gain=$X` | `tax=$X` (X >= 0). `$` and
469        /// commas are optional (e.g. `gain=25000`, `tax=$0`, `gain=$1,000`).
470        #[arg(long)]
471        target: String,
472        /// Wallet to harvest from, e.g. `self:cold` or `exchange:coinbase:default` (required; the
473        /// per-wallet pool is mandatory post-2025).
474        #[arg(long)]
475        wallet: Option<String>,
476        /// Harvest date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
477        #[arg(long)]
478        at: Option<String>,
479        /// USD price per WHOLE BTC. Omit to use the bundled daily-close FMV for `--at`; REQUIRED for a
480        /// future/off-dataset `--at` with no bundled price.
481        #[arg(long)]
482        price: Option<String>,
483        /// AD-HOC filing status (single|mfj|mfs|hoh|qss). Supplying this (with `--income`) builds a
484        /// NON-persisted profile for the plan instead of the stored `tax-profile`. Omit ALL ad-hoc
485        /// flags to use the stored profile for the harvest year.
486        #[arg(long, value_enum)]
487        filing_status: Option<FilingStatusArg>,
488        /// AD-HOC ordinary taxable income EXCLUDING crypto (the base the crypto stacks on). Required
489        /// when building an ad-hoc profile.
490        #[arg(long)]
491        income: Option<String>,
492        /// AD-HOC modified AGI excluding crypto, for the §1411 NIIT threshold. DEFAULTS TO `--income`
493        /// when omitted (never $0 — a $0 MAGI would silently suppress every NIIT disclosure); a printed
494        /// caveat notes the assumption.
495        #[arg(long)]
496        magi: Option<String>,
497        /// AD-HOC §1212(b) LONG-TERM capital-loss carryforward INTO the harvest year (optional; defaults
498        /// to $0) — expands the harvestable-gain room (gains are absorbed before touching the pref stack).
499        #[arg(long)]
500        carryforward_in: Option<String>,
501    },
502}
503
504/// `events` subcommand tree (UX-P4-11): read-only ref discoverability.
505#[derive(Subcommand)]
506pub enum Events {
507    /// List every DECIDABLE event — the imported rows a `reconcile` verb can act on
508    /// (transfer-in, transfer-out, unclassified, import-conflict, income) — with its
509    /// reference, kind, date, amount, and decision status. Rows are in ledger (import)
510    /// order, which is not necessarily by date. Read-only; writes nothing.
511    ///
512    /// Copy a listed `ref` verbatim into a reconcile verb, e.g.
513    /// `btctax reconcile classify-inbound-self-transfer <ref>` or
514    /// `btctax reconcile reclassify-outflow <ref> --as-kind sell --amount <usd>`.
515    /// A row shown as `decided: decision|N` already carries a decision; to change it,
516    /// `btctax reconcile void decision|N` first, then re-decide.
517    List,
518}
519
520#[derive(Subcommand)]
521pub enum Reconcile {
522    /// Confirm a self-transfer (TransferLink).
523    LinkTransfer {
524        out: String,
525        #[arg(long, conflicts_with = "to_wallet")]
526        to_event: Option<String>,
527        #[arg(long)]
528        to_wallet: Option<String>,
529    },
530    /// Classify an inbound TransferIn as income.
531    ClassifyInboundIncome {
532        /// The inbound TransferIn event reference to classify (see `btctax events list`).
533        in_ref: String,
534        /// Income kind: one of `mining`, `staking`, `interest`, `airdrop`, `reward`.
535        #[arg(long)]
536        kind: String,
537        /// Fair-market value of the received BTC at receipt — USD dollars, NOT sats. On this
538        /// single-event command there is NO auto-valuation: omitting `--fmv` records a Hard
539        /// "FMV missing" blocker. To supply it, `reconcile void <decision-ref>` then re-classify with
540        /// `--fmv` (classify-inbound is first-wins — re-running without voiding first is refused). To
541        /// value automatically from the bundled daily close, use `reconcile bulk-classify-inbound-income`.
542        #[arg(long)]
543        fmv: Option<String>,
544        /// Mark this income as earned in a trade or business (routes to Schedule C / SE tax).
545        #[arg(long)]
546        business: bool,
547    },
548    /// Classify an inbound TransferIn as a received gift.
549    ClassifyInboundGift {
550        in_ref: String,
551        #[arg(long)]
552        fmv_at_gift: String,
553        #[arg(long)]
554        donor_basis: Option<String>,
555        #[arg(long)]
556        donor_acquired: Option<String>,
557    },
558    /// Classify an inbound TransferIn as an inbound self-transfer ("my own coins" returning) —
559    /// non-taxable, creates a fresh lot. `--basis` defaults to $0 (conservative; fires the honest
560    /// zero-basis advisory when omitted); `--acquired` defaults to 1 year + 1 day before receipt
561    /// (assumed long-term for a cold-storage deposit; discloses an advisory so you can correct it).
562    ClassifyInboundSelfTransfer {
563        in_ref: String,
564        #[arg(long)]
565        basis: Option<String>,
566        #[arg(long)]
567        acquired: Option<String>,
568    },
569    /// Reclassify a pending TransferOut.
570    ReclassifyOutflow {
571        out: String,
572        #[arg(long, value_enum)]
573        as_kind: OutKindArg,
574        /// USD fair-market value of the disposed BTC at the transfer date — dollars, NOT sats.
575        /// For a sell/spend this is the gross proceeds; for a gift/donation it is the FMV at the
576        /// contribution date (26 CFR 1.170A-1(c)(1)). Entering the sats amount here is a common
577        /// error and draws a non-fatal warning when it exceeds 100x the market value.
578        #[arg(long)]
579        amount: String,
580        #[arg(long)]
581        fee: Option<String>,
582        #[arg(long)]
583        appraisal: bool,
584        /// Free-form donee identifier (e.g. "Alice", "Charity X"). Carried through to
585        /// removals.csv and Form 8283; does not affect tax math.
586        #[arg(long)]
587        donee: Option<String>,
588    },
589    /// Set a manual FMV on an event.
590    SetFmv {
591        /// The event reference to set the FMV on (see `btctax events list`).
592        event: String,
593        /// Fair-market value of the BTC at the event date — USD dollars, NOT sats.
594        #[arg(long)]
595        fmv: String,
596    },
597    /// Void a revocable decision.
598    Void { target: String },
599    /// Resolve an Unclassified row from a JSON imported payload.
600    ClassifyRaw {
601        target: String,
602        /// A JSON-encoded imported EventPayload (serde externally-tagged: `{"Variant":{...}}`) to
603        /// resolve the Unclassified target as. Must be an IMPORTED variant — Acquire, Income,
604        /// Dispose, TransferOut, TransferIn, or Unclassified. USD fields (usd_cost, fee_usd, …) are
605        /// decimal STRINGS; `sat` is an integer.
606        ///
607        /// FORMAT (Acquire example):
608        ///   {"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}
609        #[arg(long, verbatim_doc_comment)]
610        payload_json: String,
611    },
612    /// Accept an import conflict.
613    AcceptConflict { conflict: String },
614    /// Reject an import conflict.
615    RejectConflict { conflict: String },
616    /// Path-B safe-harbor allocate (from the actual pre-2025 position).
617    SafeHarborAllocate {
618        #[arg(long, value_enum, default_value_t = MethodArg::Actual)]
619        method: MethodArg,
620        #[arg(long)]
621        attest: bool,
622    },
623    /// Attest an existing allocation as timely.
624    SafeHarborAttest,
625    /// §A.4 Specific-ID: pick the exact lots a disposal consumes.
626    SelectLots {
627        disposal: String,
628        /// One lot pick per --from flag (repeatable). Each PICK is
629        /// `<origin_event_id>#<split_sequence>:<sat>`. The origin_event_id + split come from the
630        /// `lot` column of disposals.csv or the `origin_event`/`split` columns of lots.csv
631        /// (export-snapshot). The total sat across the picks must equal the disposal's principal
632        /// (validated in the fold).
633        ///
634        /// FORMAT (two picks):
635        ///   --from import|coinbase|X#0:25000 --from import|river|Y#1:5000
636        #[arg(long = "from", required = true, verbatim_doc_comment)]
637        from: Vec<String>,
638    },
639    /// §A.4 Batch import LotSelections from a CSV (disposal_ref,origin_event_id,split_sequence,sat).
640    ImportSelections {
641        /// CSV of lot picks imported as LotSelection decisions (§A.4). The header is REQUIRED and
642        /// validated loudly; rows sharing a disposal_ref are grouped into a single decision.
643        /// disposal_ref is the disposal event's ref (disposals.csv `event` column); origin_event_id
644        /// is the lot's origin (lots.csv `origin_event` column).
645        ///
646        /// FORMAT (header + one sample row):
647        ///   disposal_ref,origin_event_id,split_sequence,sat
648        ///   import|gemini|trade|T-2.O-2,import|coinbase|X,0,1000000
649        #[arg(verbatim_doc_comment)]
650        csv: PathBuf,
651    },
652    /// SE-completion Chunk C: flip `business` (and optionally `kind`) on an already-imported Income event.
653    ///
654    /// Corrects the `business: false` hard-code that River (and other adapters) emit at ingest time,
655    /// enabling SE-tax treatment for professional miners / stakers. The target is validated at RECORD
656    /// TIME (UX-P4-3): a missing or non-Income event ref is REFUSED — nothing is recorded — and a
657    /// duplicate re-decide is refused too; `void` the prior decision first, then re-issue. For TransferIn
658    /// rows use `classify-inbound-income` instead.
659    ReclassifyIncome {
660        /// The Income event reference (from `report` or `income_recognized.csv` 'event' column).
661        income_event: String,
662        /// Whether this income is from a trade or business (true → SE-tax eligible).
663        /// Must be supplied explicitly: `--business true` or `--business false`.
664        #[arg(long, required = true, action = clap::ArgAction::Set)]
665        business: bool,
666        /// Optional income kind correction: mining|staking|interest|airdrop|reward.
667        /// Omit to keep the original kind (only flip `business`).
668        #[arg(long)]
669        kind: Option<String>,
670    },
671    /// Store Form 8283 Section-B donation + appraiser details for a donation event.
672    /// The event ref is the TransferOut EventId from the removals.csv 'event' column.
673    SetDonationDetails {
674        /// TransferOut event reference for the donation (from removals.csv 'event' column).
675        out_event_ref: String,
676        /// Donee organization name (Part IV; required).
677        #[arg(long, required = true)]
678        donee_name: String,
679        /// Donee mailing address (Part IV; optional).
680        #[arg(long)]
681        donee_address: Option<String>,
682        /// Donee EIN (Part IV; required for Section-B completeness).
683        #[arg(long)]
684        donee_ein: Option<String>,
685        /// Qualified appraiser name (Part III; required).
686        #[arg(long, required = true)]
687        appraiser_name: String,
688        /// Appraiser mailing address (Part III; optional).
689        #[arg(long)]
690        appraiser_address: Option<String>,
691        /// Appraiser TIN/SSN/EIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
692        #[arg(long)]
693        appraiser_tin: Option<String>,
694        /// Appraiser PTIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
695        #[arg(long)]
696        appraiser_ptin: Option<String>,
697        /// Appraiser qualifications declaration (§170(f)(11)(E)).
698        #[arg(long)]
699        appraiser_qualifications: Option<String>,
700        /// Date the qualified appraisal was made (YYYY-MM-DD).
701        #[arg(long)]
702        appraisal_date: Option<String>,
703        /// FMV determination method override (overrides the section-derived default on the
704        /// Form 8283 carrier row; resolves the Section-A fmv_method deferral when supplied).
705        #[arg(long)]
706        fmv_method: Option<String>,
707    },
708    /// Show stored Form 8283 donation details for a donation event.
709    ShowDonationDetails {
710        /// TransferOut event reference for the donation (from removals.csv 'event' column).
711        out_event_ref: String,
712    },
713    /// Bulk-confirm self-transfers: link every PENDING outbound transfer in a time frame to one
714    /// destination wallet (non-taxable). Shows a preview + requires --yes (or interactive y/N).
715    BulkLinkTransfer {
716        /// Destination wallet every selected outflow links to.
717        #[arg(long)]
718        to_wallet: String,
719        /// Restrict to a single tax year (mutually exclusive with --from/--to).
720        #[arg(long, conflicts_with_all = ["from", "to"])]
721        year: Option<i32>,
722        /// Range start (YYYY-MM-DD; requires --to).
723        #[arg(long, requires = "to")]
724        from: Option<String>,
725        /// Range end (YYYY-MM-DD, inclusive; requires --from).
726        #[arg(long, requires = "from")]
727        to: Option<String>,
728        /// Only outflows FROM this source wallet.
729        #[arg(long)]
730        from_wallet: Option<String>,
731        /// Print the preview and exit without writing.
732        #[arg(long)]
733        dry_run: bool,
734        /// Skip the interactive confirmation (non-interactive apply).
735        #[arg(long)]
736        yes: bool,
737    },
738    /// Bulk-classify unknown-basis inbound deposits as self-transfer-ins ("my own coins"): apply
739    /// Cycle A's `SelfTransferMine` ($0 conservative basis, non-taxable) to MANY pending inbounds in a
740    /// time frame at once. Shows a preview surfacing the total USD given $0 basis (the over-tax
741    /// exposure) + requires --yes (or interactive y/N). Each is a voidable decision; for a deposit
742    /// whose real cost you can substantiate, classify it single-item with `classify-inbound-self-transfer --basis`.
743    BulkClassifyInboundSelfTransfer {
744        /// Restrict to a single tax year (mutually exclusive with --from/--to).
745        #[arg(long, conflicts_with_all = ["from", "to"])]
746        year: Option<i32>,
747        /// Range start (YYYY-MM-DD; requires --to).
748        #[arg(long, requires = "to")]
749        from: Option<String>,
750        /// Range end (YYYY-MM-DD, inclusive; requires --from).
751        #[arg(long, requires = "from")]
752        to: Option<String>,
753        /// Only inbounds received INTO this wallet.
754        #[arg(long)]
755        wallet: Option<String>,
756        /// Print the preview and exit without writing.
757        #[arg(long)]
758        dry_run: bool,
759        /// Skip the interactive confirmation (non-interactive apply).
760        #[arg(long)]
761        yes: bool,
762    },
763    /// Bulk-classify unknown-basis inbound deposits as INCOME (mining|staking|interest|airdrop|reward):
764    /// recognize MANY pending inbounds as ordinary income at their auto-FMV (the daily-close market
765    /// value at receipt) in one confirmed batch, with a UNIFORM `--kind` + `--business` flag. Shows a
766    /// preview surfacing the total income recognized + the count of inbounds EXCLUDED because no price
767    /// was available for their date (those stay pending — an income row with no FMV would year-gate).
768    /// Each is a voidable decision; for a single deposit use `classify-inbound-income`.
769    BulkClassifyInboundIncome {
770        /// Income kind for the whole batch: mining|staking|interest|airdrop|reward.
771        #[arg(long)]
772        kind: String,
773        /// Whether this income is from a trade or business (true → SE-tax eligible).
774        #[arg(long)]
775        business: bool,
776        /// Restrict to a single tax year (mutually exclusive with --from/--to).
777        #[arg(long, conflicts_with_all = ["from", "to"])]
778        year: Option<i32>,
779        /// Range start (YYYY-MM-DD; requires --to).
780        #[arg(long, requires = "to")]
781        from: Option<String>,
782        /// Range end (YYYY-MM-DD, inclusive; requires --from).
783        #[arg(long, requires = "from")]
784        to: Option<String>,
785        /// Only inbounds received INTO this wallet.
786        #[arg(long)]
787        wallet: Option<String>,
788        /// Print the preview and exit without writing.
789        #[arg(long)]
790        dry_run: bool,
791        /// Skip the interactive confirmation (non-interactive apply).
792        #[arg(long)]
793        yes: bool,
794    },
795    /// Bulk-reclassify unknown pending OUTFLOWS as dispositions (Sell|Spend): reclassify MANY pending
796    /// `TransferOut`s as a `Dispose` in one confirmed batch, with the daily-close market value at the
797    /// outflow date as the ESTIMATED proceeds. Shows a preview surfacing the total ESTIMATED proceeds
798    /// AND the total ESTIMATED gain (sum(fmv) - sum(basis)) + the count of outflows EXCLUDED because no price
799    /// was available for their date (those stay pending — a Sell with fabricated proceeds would be a
800    /// SILENT misreport). `--kind` is UNIFORM and accepts ONLY sell|spend (gift/donate are out of
801    /// scope). Each is a voidable decision; for a single outflow use `reclassify-outflow`.
802    BulkReclassifyOutflow {
803        /// Disposition kind for the whole batch: sell|spend (gift/donate rejected — out of scope).
804        #[arg(long)]
805        kind: String,
806        /// Restrict to a single tax year (mutually exclusive with --from/--to).
807        #[arg(long, conflicts_with_all = ["from", "to"])]
808        year: Option<i32>,
809        /// Range start (YYYY-MM-DD; requires --to).
810        #[arg(long, requires = "to")]
811        from: Option<String>,
812        /// Range end (YYYY-MM-DD, inclusive; requires --from).
813        #[arg(long, requires = "from")]
814        to: Option<String>,
815        /// Only outflows from this SOURCE wallet.
816        #[arg(long)]
817        wallet: Option<String>,
818        /// Print the preview and exit without writing.
819        #[arg(long)]
820        dry_run: bool,
821        /// Skip the interactive confirmation (non-interactive apply).
822        #[arg(long)]
823        yes: bool,
824    },
825    /// Bulk-resolve import conflicts: ACCEPT (adopt each new payload) or REJECT (keep each current
826    /// payload) MANY flagged `ImportConflict`s in one confirmed batch. Shows a `current → new` preview,
827    /// then requires --yes (or interactive y/N). Exactly one of --accept / --reject is required. Each
828    /// resolution is NON-REVOCABLE (`SupersedeImport`/`RejectImport` cannot be voided); to resolve a
829    /// conflict differently, exclude it and use single-item `accept-conflict`/`reject-conflict`.
830    #[command(group(clap::ArgGroup::new("resolve_action").required(true).args(["accept", "reject"])))]
831    BulkResolveConflict {
832        /// Accept every listed conflict (adopt each new payload onto its target).
833        #[arg(long)]
834        accept: bool,
835        /// Reject every listed conflict (keep each target's current payload).
836        #[arg(long)]
837        reject: bool,
838        /// Print the preview and exit without writing.
839        #[arg(long)]
840        dry_run: bool,
841        /// Skip the interactive confirmation (non-interactive apply).
842        #[arg(long)]
843        yes: bool,
844    },
845    /// Bulk-void MANY revocable reconcile decisions in one confirmed batch (bulk-void). Shows a preview
846    /// of every voidable decision (the SHARED `voidable_decisions` predicate — effective safe-harbor
847    /// allocations are OMITTED, #7), then requires --yes (or interactive y/N). Each void is
848    /// NON-REVOCABLE (a `VoidDecisionEvent` cannot itself be voided — re-apply the original decision to
849    /// restore). Voiding a `LotSelection` also re-exposes its disposal to the default method and clears
850    /// its optimizer attestation.
851    BulkVoid {
852        /// Print the preview and exit without writing.
853        #[arg(long)]
854        dry_run: bool,
855        /// Skip the interactive confirmation (non-interactive apply).
856        #[arg(long)]
857        yes: bool,
858    },
859    /// Match unreconciled inbound + outbound legs as self-transfers (self-transfer-passthrough C3).
860    /// With no --in/--out: PREVIEW the proposed pairs (read-only). With --in and --out: confirm ONE
861    /// pair (DROP for a same-wallet passthrough, RELOCATE for a cross-wallet transfer). NEVER automatic.
862    MatchSelfTransfers {
863        /// Confirm this in-leg (TransferIn eventref); requires --out.
864        #[arg(long = "in", requires = "out_ref")]
865        in_ref: Option<String>,
866        /// Confirm this out-leg (TransferOut eventref); requires --in.
867        #[arg(long = "out", requires = "in_ref")]
868        out_ref: Option<String>,
869        /// Override the suggested action (else the proposal's topology-derived action is used).
870        #[arg(long, value_enum)]
871        action: Option<SelfTransferActionArg>,
872        /// Print the preview and exit without writing (conflicts with --in/--out).
873        #[arg(long, conflicts_with_all = ["in_ref", "out_ref"])]
874        dry_run: bool,
875    },
876    /// Pseudo-reconcile MODE (sub-project 2): fill deliberately-fictional default decisions at
877    /// projection time (NEVER persisted) to clear the Hard classification blockers — a loudly-flagged
878    /// `[PSEUDO]` on-screen estimate you correct toward truth. `on`/`off` toggle the mode; `approve`
879    /// promotes chosen defaults to real (attested) decisions.
880    #[command(subcommand)]
881    Pseudo(Pseudo),
882    /// Conservative-filing (D-8): declare undocumented BTC at $0 basis — the IRS fallback for
883    /// unprovable basis. Folds to a single `EstimatedConservative` lot homed at `--window-end`
884    /// (the latest plausible acquisition → conservative holding period). Filing-ready, NOT pseudo.
885    /// v1 makes a pre-2025 tranche and a safe-harbor allocation mutually exclusive.
886    DeclareTranche {
887        /// Amount of undocumented BTC (required). A satoshi integer OR a BTC decimal, e.g. `0.5`
888        /// or `50000000` (a value with a `.` is BTC; a bare integer is satoshis). Must be > 0.
889        #[arg(long)]
890        amount: String,
891        /// Wallet holding the coins, e.g. `self:cold` or `exchange:coinbase:default`.
892        #[arg(long)]
893        wallet: String,
894        /// Earliest plausible acquisition date (YYYY-MM-DD).
895        #[arg(long)]
896        window_start: String,
897        /// Latest plausible acquisition date (YYYY-MM-DD) — the lot is homed here.
898        #[arg(long)]
899        window_end: String,
900    },
901}
902
903/// `reconcile pseudo <action>` — the pseudo-reconcile mode sub-verbs (sub-project 2).
904#[derive(Subcommand)]
905pub enum Pseudo {
906    /// Turn pseudo-reconcile mode ON. Projection now synthesizes non-persisted default decisions for
907    /// unresolved unknown-basis inbounds (self-transfer $0), unclassified rows, and import conflicts
908    /// (accept-first); every synthetic contribution is flagged `[PSEUDO]` on screen and BLOCKS export.
909    On,
910    /// Turn pseudo-reconcile mode OFF. Projection reverts to real-only instantly and totally (no
911    /// fictional events were ever written). Already-approved decisions REMAIN (they are real now).
912    Off,
913    /// Promote pseudo default decisions to REAL (attested) decisions in bulk. Shows a preview + requires
914    /// `--yes` (or `--dry-run` to preview only). Optional filters restrict which defaults are approved.
915    Approve {
916        /// Only approve defaults of this TYPE: `self-transfer` (unknown-basis inbound → $0 self-transfer),
917        /// `raw` (unclassified row placeholder), `conflict` (import conflict accept-first), or `fmv`
918        /// (native income FMV synthesized from the daily close). Omit = all.
919        #[arg(long, value_enum)]
920        kind: Option<PseudoKindArg>,
921        /// Only approve defaults whose target event is in this wallet (e.g. `exchange:coinbase:main`).
922        #[arg(long)]
923        wallet: Option<String>,
924        /// Only approve defaults whose target event falls in this tax year.
925        #[arg(long)]
926        year: Option<i32>,
927        /// Print the preview and exit without writing.
928        #[arg(long)]
929        dry_run: bool,
930        /// Skip the interactive confirmation (non-interactive apply).
931        #[arg(long)]
932        yes: bool,
933    },
934}
935
936/// The pseudo-default TYPE filter for `reconcile pseudo approve --kind`.
937#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
938pub enum PseudoKindArg {
939    /// Unknown-basis inbound defaulted to a $0-basis self-transfer-in.
940    SelfTransfer,
941    /// Unclassified row defaulted to a zero-value placeholder (ClassifyRaw).
942    Raw,
943    /// Import conflict defaulted to accept-first (SupersedeImport).
944    Conflict,
945    /// Native income with a missing FMV defaulted to the daily-close value (ManualFmv).
946    Fmv,
947}
948
949#[derive(Copy, Clone, ValueEnum)]
950pub enum SelfTransferActionArg {
951    /// Same-wallet passthrough → SelfTransferPassthrough (both legs skipped, non-taxable).
952    Drop,
953    /// Cross-wallet transfer → TransferLink (relocate the lots to the destination wallet).
954    Relocate,
955}
956
957/// One official form in the `export-irs-pdf` packet (the `--forms` opt-in filter).
958#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
959pub enum FormArg {
960    /// Form 8949 (per-disposition capital-gains rows).
961    F8949,
962    /// Schedule D (aggregated capital-gains totals).
963    ScheduleD,
964    /// Schedule SE (self-employment tax).
965    ScheduleSe,
966    /// Form 8283 (noncash charitable contributions).
967    Form8283,
968    /// Form 1040 (capital-gains cells + the digital-asset question).
969    Form1040,
970}
971
972#[derive(Copy, Clone, ValueEnum)]
973pub enum FilingStatusArg {
974    Single,
975    Mfj,
976    Mfs,
977    Hoh,
978    Qss,
979}
980
981impl From<FilingStatusArg> for FilingStatus {
982    fn from(a: FilingStatusArg) -> Self {
983        match a {
984            FilingStatusArg::Single => FilingStatus::Single,
985            FilingStatusArg::Mfj => FilingStatus::Mfj,
986            FilingStatusArg::Mfs => FilingStatus::Mfs,
987            FilingStatusArg::Hoh => FilingStatus::HoH,
988            FilingStatusArg::Qss => FilingStatus::Qss,
989        }
990    }
991}
992
993#[derive(Copy, Clone, ValueEnum)]
994pub enum FeeArg {
995    C,
996    B,
997}
998
999#[derive(Copy, Clone, ValueEnum)]
1000pub enum MethodLotArg {
1001    Fifo,
1002    Lifo,
1003    Hifo,
1004}
1005
1006#[derive(Copy, Clone, ValueEnum)]
1007pub enum OutKindArg {
1008    Sell,
1009    Spend,
1010    Gift,
1011    Donate,
1012}
1013
1014impl From<MethodLotArg> for LotMethod {
1015    fn from(a: MethodLotArg) -> Self {
1016        match a {
1017            MethodLotArg::Fifo => LotMethod::Fifo,
1018            MethodLotArg::Lifo => LotMethod::Lifo,
1019            MethodLotArg::Hifo => LotMethod::Hifo,
1020        }
1021    }
1022}
1023
1024#[derive(Copy, Clone, ValueEnum)]
1025pub enum MethodArg {
1026    Actual,
1027    ProRata,
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033    use clap::CommandFactory;
1034
1035    /// Render the LONG help (`--help`) of a subcommand identified by its path, recursing into
1036    /// nested subcommands (e.g. `["reconcile", "import-selections"]`). Mirrors what a user sees at
1037    /// `btctax <path...> --help`, which includes each argument's verbatim long-help.
1038    fn long_help_of(path: &[&str]) -> String {
1039        let mut cmd = Cli::command();
1040        for name in path {
1041            cmd = cmd
1042                .find_subcommand(name)
1043                .unwrap_or_else(|| panic!("subcommand {name:?} exists"))
1044                .clone();
1045        }
1046        cmd.render_long_help().to_string()
1047    }
1048
1049    // Requirement 3, `--help` half: each file/format-taking arg's long-help carries its FORMAT +
1050    // a text EXAMPLE. Tokens are comma/brace-joined (no spaces) so help-wrapping can never break
1051    // them (verified against the real binary output). This is the single source of truth that
1052    // clap_mangen also renders into the per-subcommand man page (Task 2).
1053
1054    #[test]
1055    fn help_documents_key_backup_format() {
1056        let h = long_help_of(&["init"]);
1057        assert!(
1058            h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
1059            "init --key-backup help must document the ASCII-armored key format:\n{h}"
1060        );
1061    }
1062
1063    #[test]
1064    fn help_documents_backup_key_format() {
1065        let h = long_help_of(&["backup-key"]);
1066        assert!(
1067            h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
1068            "backup-key --out help must document the ASCII-armored key format:\n{h}"
1069        );
1070    }
1071
1072    #[test]
1073    fn help_documents_export_snapshot_format() {
1074        let h = long_help_of(&["export-snapshot"]);
1075        // The exact removals.csv header read from the render.rs writer.
1076        assert!(
1077            h.contains("event,kind,removed_at,lot,sat,basis,fmv_at_transfer"),
1078            "export-snapshot --out help must document the projection CSV headers:\n{h}"
1079        );
1080    }
1081
1082    #[test]
1083    fn help_documents_import_selections_format() {
1084        let h = long_help_of(&["reconcile", "import-selections"]);
1085        assert!(
1086            h.contains("disposal_ref,origin_event_id,split_sequence,sat"),
1087            "import-selections help must document the required CSV header:\n{h}"
1088        );
1089    }
1090
1091    #[test]
1092    fn help_documents_classify_raw_format() {
1093        let h = long_help_of(&["reconcile", "classify-raw"]);
1094        // The exact externally-tagged serde shape (Usd = decimal string, sat = integer).
1095        assert!(
1096            h.contains(r#"{"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}"#),
1097            "classify-raw --payload-json help must document the JSON payload shape:\n{h}"
1098        );
1099    }
1100
1101    #[test]
1102    fn help_documents_select_lots_format() {
1103        let h = long_help_of(&["reconcile", "select-lots"]);
1104        assert!(
1105            h.contains("import|coinbase|X#0:25000"),
1106            "select-lots --from help must document the <event>#<split>:<sat> pick format:\n{h}"
1107        );
1108    }
1109}