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`, `form8275`. Default = every
207 /// applicable form (f8949 + schedule-d always; schedule-se when SE income ≥ the $400 floor;
208 /// form8283 when there are donations; form1040 when there is reportable digital-asset activity;
209 /// form8275 when a promoted-basis disposal leg files in the year — Approach-B's Cohan-estimate
210 /// disclosure). A named form is still skipped when it does not apply. Ignored on a full-return
211 /// year (that path fills the whole packet).
212 #[arg(long, value_enum, value_delimiter = ',')]
213 forms: Vec<FormArg>,
214 /// Attestation phrase required to export while the ledger is PSEUDO-RECONCILED (a synthetic
215 /// default contributes to the projection). Pass the exact phrase `I attest this is true`
216 /// (trimmed, case-sensitive) to fill the DRAFT-watermarked forms ON PURPOSE. Omit on a
217 /// fully-real ledger (never gated). Omit on an interactive terminal to be prompted; omit when
218 /// piped (non-TTY) while pseudo-active and the export is refused.
219 #[arg(long)]
220 attest: Option<String>,
221 },
222 /// Export the passphrase-protected key.
223 BackupKey {
224 /// File to write the exported key to: an ASCII-armored, passphrase(S2K)-encrypted private
225 /// key, owner-only (mode 0600). Identical format to `init --key-backup`.
226 ///
227 /// FORMAT (structure — NOT a real key):
228 /// -----BEGIN PGP PRIVATE KEY BLOCK-----
229 /// ... base64 armor of the S2K-encrypted secret key ...
230 /// -----END PGP PRIVATE KEY BLOCK-----
231 #[arg(long, verbatim_doc_comment)]
232 out: PathBuf,
233 },
234 /// Lot-specific-identification optimizer (§C — read-only proposal or gated persistence).
235 #[command(subcommand)]
236 Optimize(Optimize),
237 /// Read-only what-if tax planning (task #43): posit a HYPOTHETICAL, NON-persisted transaction and
238 /// see its MARGINAL federal-tax effect on the current-year position. Routes through the same audited
239 /// tax engine as `report --tax-year`; invents no tax authority. Writes NOTHING — no event, no
240 /// side-table row, no vault mutation. Tax decision-support (consequences), not buy/sell/hold advice.
241 #[command(subcommand)]
242 WhatIf(WhatIf),
243 /// Full-return (v1) input surface: import, show, or clear the per-year full-return inputs (W-2s,
244 /// 1099s, deductions, household). Offline; stored in the encrypted vault.
245 #[command(subcommand)]
246 Income(IncomeCmd),
247 /// Set or show the per-tax-year tax profile (filing status, income, MAGI, etc.).
248 TaxProfile {
249 /// The tax year (e.g. 2025).
250 #[arg(long)]
251 year: i32,
252 /// IRS filing status.
253 #[arg(long, value_enum)]
254 filing_status: Option<FilingStatusArg>,
255 /// Ordinary taxable income EXCLUDING all app-computed crypto items (net ST gains,
256 /// mining/staking ordinary income). The engine adds the crypto items on top (B.1 / I5).
257 #[arg(long)]
258 ordinary_taxable_income: Option<String>,
259 /// Modified AGI excluding crypto items, for the §1411 NIIT threshold comparison.
260 ///
261 /// IMPORTANT (§1411 contract): this value MUST already include the taxpayer's qualified
262 /// dividends and non-crypto net capital gains (and any other MAGI add-backs from
263 /// §1411(d)). The engine adds ONLY the crypto AGI delta on top (ambiguity #5 in the
264 /// design). Omitting QD or non-crypto cap gains from this figure understates NIIT.
265 #[arg(
266 long,
267 long_help = "Modified AGI excluding crypto items, for the §1411 NIIT \
268 threshold comparison.\n\nIMPORTANT (§1411 contract): this value MUST already \
269 include the taxpayer's qualified dividends and non-crypto net capital gains (and \
270 any other MAGI add-backs from §1411(d)). The engine adds ONLY the crypto AGI \
271 delta on top (ambiguity #5 in the design). Omitting QD or non-crypto cap gains \
272 from this figure understates NIIT."
273 )]
274 magi_excluding_crypto: Option<String>,
275 /// Qualified dividends + other preferential-rate income sharing the §1(h) 0/15/20 LTCG
276 /// rate stack. Required when setting a profile.
277 #[arg(long)]
278 qualified_dividends: Option<String>,
279 /// Non-crypto net LT-character capital gain already in the profile (optional; defaults
280 /// to 0 when omitted).
281 #[arg(long)]
282 other_net_capital_gain: Option<String>,
283 /// §1212(b) short-term capital loss carryforward into this year (optional; defaults to 0).
284 #[arg(long)]
285 carryforward_short: Option<String>,
286 /// §1212(b) long-term capital loss carryforward into this year (optional; defaults to 0).
287 #[arg(long)]
288 carryforward_long: Option<String>,
289 /// Form W-2 Social Security wages (Box 3 + Box 7 tips; Schedule SE line 8a).
290 /// Reduces the §1401(a) SS cap: ss_cap = max(0, wage_base − w2_ss_wages). Optional;
291 /// defaults to $0. Must not be negative.
292 #[arg(long)]
293 w2_ss_wages: Option<String>,
294 /// Medicare wages (Box 5; Form 8959 line 1).
295 /// Reduces the Additional-Medicare threshold: addl_threshold = max(0, threshold − w2_medicare_wages)
296 /// (§1401(b)(2)(B)/Form 8959 Part II). Optional; defaults to $0. Must not be negative.
297 #[arg(long)]
298 w2_medicare_wages: Option<String>,
299 /// Schedule C deductible business expenses for the year — reduces net SE earnings;
300 /// the income-tax stack above is NOT adjusted (see the advisory).
301 /// Optional; defaults to $0. Must not be negative.
302 #[arg(long)]
303 schedule_c_expenses: Option<String>,
304 /// Show the stored profile for `--year` instead of setting it.
305 #[arg(long, default_value_t = false)]
306 show: bool,
307 /// Store the raw profile even when full-return inputs (`income import`) already exist for the year
308 /// (they take precedence, so the raw profile would otherwise be ignored — D-4 guard).
309 #[arg(long, default_value_t = false)]
310 force: bool,
311 },
312 /// Approach-B: read-only defensive-filing status.
313 #[command(subcommand)]
314 Defensive(Defensive),
315}
316
317/// `defensive <action>` — read-only Approach-B status reporting (Approach-B, EXPERIMENTAL — see
318/// `btctax limitations`). A sibling of `reconcile declare-tranche`/`reconcile promote-tranche`, not
319/// nested under `reconcile`: this is a plain read, never a decision event.
320#[derive(Subcommand)]
321pub enum Defensive {
322 /// Print the current defensive-filing picture: shortfalls a `declare-tranche` could cover now,
323 /// shortfalls that need another blocker resolved FIRST, every live `DeclareTranche`/`PromoteTranche`
324 /// and its state, and the tax years a prior declare/promote flags for re-export attention — each
325 /// with the exact verb to run next. Pure read over the SAME `btctax_core::defensive::journey_view`
326 /// the (now-retired) TUI dashboard rendered; no new computation, and it writes nothing. Refuses
327 /// (like `export-snapshot`/`export-irs-pdf`'s own composed-export gate) while the ledger is
328 /// pseudo-reconciled — a defensive-filing read over synthetic estimates could mask a real shortfall.
329 Status,
330}
331
332/// `optimize` subcommand tree. Task 9 adds `Run`; Task 10 adds `Accept`; Task 11 adds `Consult`.
333#[derive(Subcommand)]
334pub enum Optimize {
335 /// Mode-1 what-if: print the tax-saving lot-selection proposal. NOTHING is filed or bound.
336 Run {
337 /// The tax year to optimize (must be 2025 or later).
338 #[arg(long)]
339 tax_year: i32,
340 },
341 /// Mode-1 gated persistence: recompute the optimum and persist the proposed LotSelection(s),
342 /// gated per disposal (§1.1012-1(j)). A genuinely-contemporaneous pick (made ≤ sale) persists
343 /// freely; an already-executed disposal persists ONLY with a narrow per-disposal `--attest`
344 /// scoped to one `--disposal`; a 2027+ broker-held pick is refused. Revoke via `reconcile void`.
345 Accept {
346 /// The tax year to accept (must be 2025 or later).
347 #[arg(long)]
348 tax_year: i32,
349 /// Restrict to ONE disposal (required to carry `--attest`).
350 #[arg(long)]
351 disposal: Option<String>,
352 /// Narrow contemporaneous-ID attestation for an already-executed disposal. Requires
353 /// `--disposal` (no blanket attestation across all disposals).
354 #[arg(long)]
355 attest: Option<String>,
356 },
357 /// Mode-2 read-only pre-trade what-if (§C.3): tax-min lots + ST/LT split + federal tax + ST→LT
358 /// timing. NOTHING is written — no event, no side-table row. Tax decision-support only;
359 /// not buy/sell/hold advice.
360 Consult {
361 /// Hypothetical sale amount (required). Accepts a satoshi integer OR a BTC decimal, e.g.
362 /// `0.05` or `5000000` (a value with a `.` is BTC; a bare integer is satoshis).
363 #[arg(long)]
364 sell: String,
365 /// Wallet to sell from, e.g. `self:cold` or `exchange:coinbase:default` (required; per-wallet
366 /// pool is mandatory post-2025).
367 #[arg(long)]
368 wallet: Option<String>,
369 /// Sale date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
370 #[arg(long)]
371 at: Option<String>,
372 /// Explicit USD proceeds for the hypothetical sale. Required when `--at` is a future date
373 /// with no bundled dataset price and `--fmv` is not used. Mutually exclusive with `--fmv`.
374 #[arg(long, conflicts_with = "fmv")]
375 proceeds: Option<String>,
376 /// Use the bundled daily-close FMV for `--at` instead of an explicit proceeds amount.
377 /// A future date with no dataset price will return a ProceedsRequired error. Mutually
378 /// exclusive with `--proceeds`.
379 #[arg(long, conflicts_with = "proceeds")]
380 fmv: bool,
381 },
382}
383
384/// Full-return (v1) input subcommands (SPEC §4 / recon-04 §6). v1 ships the TOML bulk-import path +
385/// a JSON show; incremental per-field subcommands (`add-w2`, …) are a follow-on.
386#[derive(Subcommand)]
387pub enum IncomeCmd {
388 /// Import full-return inputs from an offline TOML file into the vault for a tax year.
389 Import {
390 /// The tax year (e.g. 2024).
391 #[arg(long)]
392 year: i32,
393 /// Path to the TOML file describing the full-return inputs.
394 #[arg(long)]
395 file: std::path::PathBuf,
396 },
397 /// Show the stored full-return inputs for a tax year (JSON, PII redacted), or nothing if none set.
398 Show {
399 /// The tax year (e.g. 2024).
400 #[arg(long)]
401 year: i32,
402 },
403 /// Remove the stored full-return inputs for a tax year (fall back to a raw `tax-profile`).
404 Clear {
405 /// The tax year (e.g. 2024).
406 #[arg(long)]
407 year: i32,
408 },
409 /// Answer the return's fail-loud questions interactively — the yes/no boxes that have no safe
410 /// default (can someone claim you as a dependent? Schedule B's foreign-account and foreign-trust
411 /// lines) plus the optional dates of birth.
412 ///
413 /// These questions REFUSE the return until they are answered: guessing "no" on your behalf would
414 /// understate your tax and print an unchecked box you never affirmed. This is the only way to answer
415 /// them without editing a TOML file. It never asks for a secret — SSNs and the IP PIN belong to
416 /// `set-pii`, which does not echo what you type.
417 ///
418 /// Requires an existing return for the year (create one with `income import`).
419 Answer {
420 /// The tax year (e.g. 2024).
421 #[arg(long)]
422 year: i32,
423 },
424}
425
426/// `what-if` subcommand tree (task #43). READ-ONLY hypothetical-transaction tax planning: NOTHING is
427/// filed, appended, or persisted. Mirrors the `optimize consult` shape, plus an ad-hoc `TaxProfile`
428/// (so you can plan without `tax-profile set`).
429#[derive(Subcommand)]
430pub enum WhatIf {
431 /// Posit a hypothetical, NON-persisted SALE and see its MARGINAL federal tax: the lots it would
432 /// consume, the ST/LT split, which §1(h) LTCG bracket (0/15/20) it lands in + room to the next
433 /// breakpoint, the exact marginal tax (with-hypothetical minus baseline — the sale's OWN effect,
434 /// not the whole-year figure), the effective rate, the §1212(b) carryforward carried to next year,
435 /// this year's ordinary offset, and the §1411 NIIT delta. A net loss surfaces the carryforward
436 /// disclosure (its value is NOT this-year tax). Writes NOTHING.
437 Sell {
438 /// Hypothetical sale amount (required). Accepts a satoshi integer OR a BTC decimal, e.g.
439 /// `0.05` or `5000000` (a value with a `.` is BTC; a bare integer is satoshis).
440 #[arg(long)]
441 sell: String,
442 /// Wallet to sell from, e.g. `self:cold` or `exchange:coinbase:default` (required; the
443 /// per-wallet pool is mandatory post-2025).
444 #[arg(long)]
445 wallet: Option<String>,
446 /// Sale date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
447 #[arg(long)]
448 at: Option<String>,
449 /// USD price per WHOLE BTC for the hypothetical sale (proceeds = price × sat / 1e8). Omit to
450 /// use the bundled daily-close FMV for `--at`; REQUIRED for a future/off-dataset `--at` with no
451 /// bundled price (else the what-if returns a ProceedsRequired error).
452 #[arg(long)]
453 price: Option<String>,
454 /// Lot-selection method for the hypothetical sale: fifo|lifo|hifo. Omit to consume by the
455 /// STANDING method (the account's in-force election / the default), exactly as a real disposal
456 /// on that date would.
457 #[arg(long, value_enum)]
458 method: Option<MethodLotArg>,
459 /// AD-HOC filing status (single|mfj|mfs|hoh|qss). Supplying this (with `--income`) builds a
460 /// NON-persisted profile for the plan instead of the stored `tax-profile`. Omit ALL ad-hoc
461 /// flags to use the stored profile for the sale year.
462 #[arg(long, value_enum)]
463 filing_status: Option<FilingStatusArg>,
464 /// AD-HOC ordinary taxable income EXCLUDING crypto (the base the crypto stacks on). Required
465 /// when building an ad-hoc profile.
466 #[arg(long)]
467 income: Option<String>,
468 /// AD-HOC modified AGI excluding crypto, for the §1411 NIIT threshold. DEFAULTS TO `--income`
469 /// when omitted (never $0 — a $0 MAGI would silently suppress every NIIT disclosure); a printed
470 /// caveat notes the assumption. Supply the true MAGI (incl. QD + non-crypto cap gains) to avoid
471 /// understating NIIT.
472 #[arg(long)]
473 magi: Option<String>,
474 /// AD-HOC §1212(b) LONG-TERM capital-loss carryforward INTO the sale year (optional; defaults
475 /// to $0). The dominant BTC case; short-term carryforward-in is out of scope for the ad-hoc
476 /// profile (set a stored `tax-profile` for that).
477 #[arg(long)]
478 carryforward_in: Option<String>,
479 },
480 /// Posit a hypothetical, NON-persisted HARVEST and find the MAX BTC to sell such that a target holds
481 /// on the ENTIRE prefix [0, N]: `--target zero-ltcg` (sell all that fits in the §1(h) 0% bracket),
482 /// `fifteen-ltcg` (stay at/under 15%), `gain=$X` (realize at most $X of gain WITH this sale), or
483 /// `tax=$X` (add at most $X of marginal federal tax; `tax=$0` is the flagship "zero-tax harvest").
484 /// Uses the STANDING lot method's consumption order (never re-optimized). Discloses the §1212(b)
485 /// carryforward burn, the §1411 NIIT kink (a 0%/15% answer can still cost +3.8%), and the plateau
486 /// notes. The answer is ALWAYS engine-verified. Writes NOTHING.
487 Harvest {
488 /// The harvest target: `zero-ltcg` | `fifteen-ltcg` | `gain=$X` | `tax=$X` (X >= 0). `$` and
489 /// commas are optional (e.g. `gain=25000`, `tax=$0`, `gain=$1,000`).
490 #[arg(long)]
491 target: String,
492 /// Wallet to harvest from, e.g. `self:cold` or `exchange:coinbase:default` (required; the
493 /// per-wallet pool is mandatory post-2025).
494 #[arg(long)]
495 wallet: Option<String>,
496 /// Harvest date for the what-if (YYYY-MM-DD; defaults to today UTC if omitted).
497 #[arg(long)]
498 at: Option<String>,
499 /// USD price per WHOLE BTC. Omit to use the bundled daily-close FMV for `--at`; REQUIRED for a
500 /// future/off-dataset `--at` with no bundled price.
501 #[arg(long)]
502 price: Option<String>,
503 /// AD-HOC filing status (single|mfj|mfs|hoh|qss). Supplying this (with `--income`) builds a
504 /// NON-persisted profile for the plan instead of the stored `tax-profile`. Omit ALL ad-hoc
505 /// flags to use the stored profile for the harvest year.
506 #[arg(long, value_enum)]
507 filing_status: Option<FilingStatusArg>,
508 /// AD-HOC ordinary taxable income EXCLUDING crypto (the base the crypto stacks on). Required
509 /// when building an ad-hoc profile.
510 #[arg(long)]
511 income: Option<String>,
512 /// AD-HOC modified AGI excluding crypto, for the §1411 NIIT threshold. DEFAULTS TO `--income`
513 /// when omitted (never $0 — a $0 MAGI would silently suppress every NIIT disclosure); a printed
514 /// caveat notes the assumption.
515 #[arg(long)]
516 magi: Option<String>,
517 /// AD-HOC §1212(b) LONG-TERM capital-loss carryforward INTO the harvest year (optional; defaults
518 /// to $0) — expands the harvestable-gain room (gains are absorbed before touching the pref stack).
519 #[arg(long)]
520 carryforward_in: Option<String>,
521 },
522}
523
524/// `events` subcommand tree (UX-P4-11): read-only ref discoverability.
525#[derive(Subcommand)]
526pub enum Events {
527 /// List every DECIDABLE event — the imported rows a `reconcile` verb can act on
528 /// (transfer-in, transfer-out, unclassified, import-conflict, income) — with its
529 /// reference, kind, date, amount, and decision status. Rows are in ledger (import)
530 /// order, which is not necessarily by date. Read-only; writes nothing.
531 ///
532 /// Copy a listed `ref` verbatim into a reconcile verb, e.g.
533 /// `btctax reconcile classify-inbound-self-transfer <ref>` or
534 /// `btctax reconcile reclassify-outflow <ref> --as-kind sell --amount <usd>`.
535 /// A row shown as `decided: decision|N` already carries a decision; to change it,
536 /// `btctax reconcile void decision|N` first, then re-decide.
537 List,
538}
539
540#[derive(Subcommand)]
541pub enum Reconcile {
542 /// Confirm a self-transfer (TransferLink).
543 LinkTransfer {
544 out: String,
545 #[arg(long, conflicts_with = "to_wallet")]
546 to_event: Option<String>,
547 #[arg(long)]
548 to_wallet: Option<String>,
549 },
550 /// Classify an inbound TransferIn as income.
551 ClassifyInboundIncome {
552 /// The inbound TransferIn event reference to classify (see `btctax events list`).
553 in_ref: String,
554 /// Income kind: one of `mining`, `staking`, `interest`, `airdrop`, `reward`.
555 #[arg(long)]
556 kind: String,
557 /// Fair-market value of the received BTC at receipt — USD dollars, NOT sats. On this
558 /// single-event command there is NO auto-valuation: omitting `--fmv` records a Hard
559 /// "FMV missing" blocker. To supply it, `reconcile void <decision-ref>` then re-classify with
560 /// `--fmv` (classify-inbound is first-wins — re-running without voiding first is refused). To
561 /// value automatically from the bundled daily close, use `reconcile bulk-classify-inbound-income`.
562 #[arg(long)]
563 fmv: Option<String>,
564 /// Mark this income as earned in a trade or business (routes to Schedule C / SE tax).
565 #[arg(long)]
566 business: bool,
567 },
568 /// Classify an inbound TransferIn as a received gift.
569 ClassifyInboundGift {
570 in_ref: String,
571 #[arg(long)]
572 fmv_at_gift: String,
573 #[arg(long)]
574 donor_basis: Option<String>,
575 #[arg(long)]
576 donor_acquired: Option<String>,
577 },
578 /// Classify an inbound TransferIn as an inbound self-transfer ("my own coins" returning) —
579 /// non-taxable, creates a fresh lot. `--basis` defaults to $0 (conservative; fires the honest
580 /// zero-basis advisory when omitted); `--acquired` defaults to 1 year + 1 day before receipt
581 /// (assumed long-term for a cold-storage deposit; discloses an advisory so you can correct it).
582 ClassifyInboundSelfTransfer {
583 in_ref: String,
584 #[arg(long)]
585 basis: Option<String>,
586 #[arg(long)]
587 acquired: Option<String>,
588 },
589 /// Reclassify a pending TransferOut.
590 ReclassifyOutflow {
591 out: String,
592 #[arg(long, value_enum)]
593 as_kind: OutKindArg,
594 /// USD fair-market value of the disposed BTC at the transfer date — dollars, NOT sats.
595 /// For a sell/spend this is the gross proceeds; for a gift/donation it is the FMV at the
596 /// contribution date (26 CFR 1.170A-1(c)(1)). Entering the sats amount here is a common
597 /// error and draws a non-fatal warning when it exceeds 100x the market value.
598 #[arg(long)]
599 amount: String,
600 #[arg(long)]
601 fee: Option<String>,
602 #[arg(long)]
603 appraisal: bool,
604 /// Free-form donee identifier (e.g. "Alice", "Charity X"). Carried through to
605 /// removals.csv and Form 8283; does not affect tax math.
606 #[arg(long)]
607 donee: Option<String>,
608 },
609 /// Set a manual FMV on an event.
610 SetFmv {
611 /// The event reference to set the FMV on (see `btctax events list`).
612 event: String,
613 /// Fair-market value of the BTC at the event date — USD dollars, NOT sats.
614 #[arg(long)]
615 fmv: String,
616 },
617 /// Void a revocable decision.
618 Void { target: String },
619 /// Resolve an Unclassified row from a JSON imported payload.
620 ClassifyRaw {
621 target: String,
622 /// A JSON-encoded imported EventPayload (serde externally-tagged: `{"Variant":{...}}`) to
623 /// resolve the Unclassified target as. Must be an IMPORTED variant — Acquire, Income,
624 /// Dispose, TransferOut, TransferIn, or Unclassified. USD fields (usd_cost, fee_usd, …) are
625 /// decimal STRINGS; `sat` is an integer.
626 ///
627 /// FORMAT (Acquire example):
628 /// {"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}
629 #[arg(long, verbatim_doc_comment)]
630 payload_json: String,
631 },
632 /// Accept an import conflict.
633 AcceptConflict { conflict: String },
634 /// Reject an import conflict.
635 RejectConflict { conflict: String },
636 /// Path-B safe-harbor allocate (from the actual pre-2025 position).
637 SafeHarborAllocate {
638 #[arg(long, value_enum, default_value_t = MethodArg::Actual)]
639 method: MethodArg,
640 #[arg(long)]
641 attest: bool,
642 },
643 /// Attest an existing allocation as timely.
644 SafeHarborAttest,
645 /// §A.4 Specific-ID: pick the exact lots a disposal consumes.
646 SelectLots {
647 disposal: String,
648 /// One lot pick per --from flag (repeatable). Each PICK is
649 /// `<origin_event_id>#<split_sequence>:<sat>`. The origin_event_id + split come from the
650 /// `lot` column of disposals.csv or the `origin_event`/`split` columns of lots.csv
651 /// (export-snapshot). The total sat across the picks must equal the disposal's principal
652 /// (validated in the fold).
653 ///
654 /// FORMAT (two picks):
655 /// --from import|coinbase|X#0:25000 --from import|river|Y#1:5000
656 #[arg(long = "from", required = true, verbatim_doc_comment)]
657 from: Vec<String>,
658 },
659 /// §A.4 Batch import LotSelections from a CSV (disposal_ref,origin_event_id,split_sequence,sat).
660 ImportSelections {
661 /// CSV of lot picks imported as LotSelection decisions (§A.4). The header is REQUIRED and
662 /// validated loudly; rows sharing a disposal_ref are grouped into a single decision.
663 /// disposal_ref is the disposal event's ref (disposals.csv `event` column); origin_event_id
664 /// is the lot's origin (lots.csv `origin_event` column).
665 ///
666 /// FORMAT (header + one sample row):
667 /// disposal_ref,origin_event_id,split_sequence,sat
668 /// import|gemini|trade|T-2.O-2,import|coinbase|X,0,1000000
669 #[arg(verbatim_doc_comment)]
670 csv: PathBuf,
671 },
672 /// SE-completion Chunk C: flip `business` (and optionally `kind`) on an already-imported Income event.
673 ///
674 /// Corrects the `business: false` hard-code that River (and other adapters) emit at ingest time,
675 /// enabling SE-tax treatment for professional miners / stakers. The target is validated at RECORD
676 /// TIME (UX-P4-3): a missing or non-Income event ref is REFUSED — nothing is recorded — and a
677 /// duplicate re-decide is refused too; `void` the prior decision first, then re-issue. For TransferIn
678 /// rows use `classify-inbound-income` instead.
679 ReclassifyIncome {
680 /// The Income event reference (from `report` or `income_recognized.csv` 'event' column).
681 income_event: String,
682 /// Whether this income is from a trade or business (true → SE-tax eligible).
683 /// Must be supplied explicitly: `--business true` or `--business false`.
684 #[arg(long, required = true, action = clap::ArgAction::Set)]
685 business: bool,
686 /// Optional income kind correction: mining|staking|interest|airdrop|reward.
687 /// Omit to keep the original kind (only flip `business`).
688 #[arg(long)]
689 kind: Option<String>,
690 },
691 /// Store Form 8283 Section-B donation + appraiser details for a donation event.
692 /// The event ref is the TransferOut EventId from the removals.csv 'event' column.
693 SetDonationDetails {
694 /// TransferOut event reference for the donation (from removals.csv 'event' column).
695 out_event_ref: String,
696 /// Donee organization name (Part IV; required).
697 #[arg(long, required = true)]
698 donee_name: String,
699 /// Donee mailing address (Part IV; optional).
700 #[arg(long)]
701 donee_address: Option<String>,
702 /// Donee EIN (Part IV; required for Section-B completeness).
703 #[arg(long)]
704 donee_ein: Option<String>,
705 /// Qualified appraiser name (Part III; required).
706 #[arg(long, required = true)]
707 appraiser_name: String,
708 /// Appraiser mailing address (Part III; optional).
709 #[arg(long)]
710 appraiser_address: Option<String>,
711 /// Appraiser TIN/SSN/EIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
712 #[arg(long)]
713 appraiser_tin: Option<String>,
714 /// Appraiser PTIN (Part III §6695A; satisfies the TIN-or-PTIN requirement).
715 #[arg(long)]
716 appraiser_ptin: Option<String>,
717 /// Appraiser qualifications declaration (§170(f)(11)(E)).
718 #[arg(long)]
719 appraiser_qualifications: Option<String>,
720 /// Date the qualified appraisal was made (YYYY-MM-DD).
721 #[arg(long)]
722 appraisal_date: Option<String>,
723 /// FMV determination method override (overrides the section-derived default on the
724 /// Form 8283 carrier row; resolves the Section-A fmv_method deferral when supplied).
725 #[arg(long)]
726 fmv_method: Option<String>,
727 },
728 /// Show stored Form 8283 donation details for a donation event.
729 ShowDonationDetails {
730 /// TransferOut event reference for the donation (from removals.csv 'event' column).
731 out_event_ref: String,
732 },
733 /// Bulk-confirm self-transfers: link every PENDING outbound transfer in a time frame to one
734 /// destination wallet (non-taxable). Shows a preview + requires --yes (or interactive y/N).
735 BulkLinkTransfer {
736 /// Destination wallet every selected outflow links to.
737 #[arg(long)]
738 to_wallet: String,
739 /// Restrict to a single tax year (mutually exclusive with --from/--to).
740 #[arg(long, conflicts_with_all = ["from", "to"])]
741 year: Option<i32>,
742 /// Range start (YYYY-MM-DD; requires --to).
743 #[arg(long, requires = "to")]
744 from: Option<String>,
745 /// Range end (YYYY-MM-DD, inclusive; requires --from).
746 #[arg(long, requires = "from")]
747 to: Option<String>,
748 /// Only outflows FROM this source wallet.
749 #[arg(long)]
750 from_wallet: Option<String>,
751 /// Print the preview and exit without writing.
752 #[arg(long)]
753 dry_run: bool,
754 /// Skip the interactive confirmation (non-interactive apply).
755 #[arg(long)]
756 yes: bool,
757 },
758 /// Bulk-classify unknown-basis inbound deposits as self-transfer-ins ("my own coins"): apply
759 /// Cycle A's `SelfTransferMine` ($0 conservative basis, non-taxable) to MANY pending inbounds in a
760 /// time frame at once. Shows a preview surfacing the total USD given $0 basis (the over-tax
761 /// exposure) + requires --yes (or interactive y/N). Each is a voidable decision; for a deposit
762 /// whose real cost you can substantiate, classify it single-item with `classify-inbound-self-transfer --basis`.
763 BulkClassifyInboundSelfTransfer {
764 /// Restrict to a single tax year (mutually exclusive with --from/--to).
765 #[arg(long, conflicts_with_all = ["from", "to"])]
766 year: Option<i32>,
767 /// Range start (YYYY-MM-DD; requires --to).
768 #[arg(long, requires = "to")]
769 from: Option<String>,
770 /// Range end (YYYY-MM-DD, inclusive; requires --from).
771 #[arg(long, requires = "from")]
772 to: Option<String>,
773 /// Only inbounds received INTO this wallet.
774 #[arg(long)]
775 wallet: Option<String>,
776 /// Print the preview and exit without writing.
777 #[arg(long)]
778 dry_run: bool,
779 /// Skip the interactive confirmation (non-interactive apply).
780 #[arg(long)]
781 yes: bool,
782 },
783 /// Bulk-classify unknown-basis inbound deposits as INCOME (mining|staking|interest|airdrop|reward):
784 /// recognize MANY pending inbounds as ordinary income at their auto-FMV (the daily-close market
785 /// value at receipt) in one confirmed batch, with a UNIFORM `--kind` + `--business` flag. Shows a
786 /// preview surfacing the total income recognized + the count of inbounds EXCLUDED because no price
787 /// was available for their date (those stay pending — an income row with no FMV would year-gate).
788 /// Each is a voidable decision; for a single deposit use `classify-inbound-income`.
789 BulkClassifyInboundIncome {
790 /// Income kind for the whole batch: mining|staking|interest|airdrop|reward.
791 #[arg(long)]
792 kind: String,
793 /// Whether this income is from a trade or business (true → SE-tax eligible).
794 #[arg(long)]
795 business: bool,
796 /// Restrict to a single tax year (mutually exclusive with --from/--to).
797 #[arg(long, conflicts_with_all = ["from", "to"])]
798 year: Option<i32>,
799 /// Range start (YYYY-MM-DD; requires --to).
800 #[arg(long, requires = "to")]
801 from: Option<String>,
802 /// Range end (YYYY-MM-DD, inclusive; requires --from).
803 #[arg(long, requires = "from")]
804 to: Option<String>,
805 /// Only inbounds received INTO this wallet.
806 #[arg(long)]
807 wallet: Option<String>,
808 /// Print the preview and exit without writing.
809 #[arg(long)]
810 dry_run: bool,
811 /// Skip the interactive confirmation (non-interactive apply).
812 #[arg(long)]
813 yes: bool,
814 },
815 /// Bulk-reclassify unknown pending OUTFLOWS as dispositions (Sell|Spend): reclassify MANY pending
816 /// `TransferOut`s as a `Dispose` in one confirmed batch, with the daily-close market value at the
817 /// outflow date as the ESTIMATED proceeds. Shows a preview surfacing the total ESTIMATED proceeds
818 /// AND the total ESTIMATED gain (sum(fmv) - sum(basis)) + the count of outflows EXCLUDED because no price
819 /// was available for their date (those stay pending — a Sell with fabricated proceeds would be a
820 /// SILENT misreport). `--kind` is UNIFORM and accepts ONLY sell|spend (gift/donate are out of
821 /// scope). Each is a voidable decision; for a single outflow use `reclassify-outflow`.
822 BulkReclassifyOutflow {
823 /// Disposition kind for the whole batch: sell|spend (gift/donate rejected — out of scope).
824 #[arg(long)]
825 kind: String,
826 /// Restrict to a single tax year (mutually exclusive with --from/--to).
827 #[arg(long, conflicts_with_all = ["from", "to"])]
828 year: Option<i32>,
829 /// Range start (YYYY-MM-DD; requires --to).
830 #[arg(long, requires = "to")]
831 from: Option<String>,
832 /// Range end (YYYY-MM-DD, inclusive; requires --from).
833 #[arg(long, requires = "from")]
834 to: Option<String>,
835 /// Only outflows from this SOURCE wallet.
836 #[arg(long)]
837 wallet: Option<String>,
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-resolve import conflicts: ACCEPT (adopt each new payload) or REJECT (keep each current
846 /// payload) MANY flagged `ImportConflict`s in one confirmed batch. Shows a `current → new` preview,
847 /// then requires --yes (or interactive y/N). Exactly one of --accept / --reject is required. Each
848 /// resolution is NON-REVOCABLE (`SupersedeImport`/`RejectImport` cannot be voided); to resolve a
849 /// conflict differently, exclude it and use single-item `accept-conflict`/`reject-conflict`.
850 #[command(group(clap::ArgGroup::new("resolve_action").required(true).args(["accept", "reject"])))]
851 BulkResolveConflict {
852 /// Accept every listed conflict (adopt each new payload onto its target).
853 #[arg(long)]
854 accept: bool,
855 /// Reject every listed conflict (keep each target's current payload).
856 #[arg(long)]
857 reject: bool,
858 /// Print the preview and exit without writing.
859 #[arg(long)]
860 dry_run: bool,
861 /// Skip the interactive confirmation (non-interactive apply).
862 #[arg(long)]
863 yes: bool,
864 },
865 /// Bulk-void MANY revocable reconcile decisions in one confirmed batch (bulk-void). Shows a preview
866 /// of every voidable decision (the SHARED `voidable_decisions` predicate — effective safe-harbor
867 /// allocations are OMITTED, #7), then requires --yes (or interactive y/N). Each void is
868 /// NON-REVOCABLE (a `VoidDecisionEvent` cannot itself be voided — re-apply the original decision to
869 /// restore). Voiding a `LotSelection` also re-exposes its disposal to the default method and clears
870 /// its optimizer attestation.
871 BulkVoid {
872 /// Print the preview and exit without writing.
873 #[arg(long)]
874 dry_run: bool,
875 /// Skip the interactive confirmation (non-interactive apply).
876 #[arg(long)]
877 yes: bool,
878 },
879 /// Match unreconciled inbound + outbound legs as self-transfers (self-transfer-passthrough C3).
880 /// With no --in/--out: PREVIEW the proposed pairs (read-only). With --in and --out: confirm ONE
881 /// pair (DROP for a same-wallet passthrough, RELOCATE for a cross-wallet transfer). NEVER automatic.
882 MatchSelfTransfers {
883 /// Confirm this in-leg (TransferIn eventref); requires --out.
884 #[arg(long = "in", requires = "out_ref")]
885 in_ref: Option<String>,
886 /// Confirm this out-leg (TransferOut eventref); requires --in.
887 #[arg(long = "out", requires = "in_ref")]
888 out_ref: Option<String>,
889 /// Override the suggested action (else the proposal's topology-derived action is used).
890 #[arg(long, value_enum)]
891 action: Option<SelfTransferActionArg>,
892 /// Print the preview and exit without writing (conflicts with --in/--out).
893 #[arg(long, conflicts_with_all = ["in_ref", "out_ref"])]
894 dry_run: bool,
895 },
896 /// Pseudo-reconcile MODE (sub-project 2): fill deliberately-fictional default decisions at
897 /// projection time (NEVER persisted) to clear the Hard classification blockers — a loudly-flagged
898 /// `[PSEUDO]` on-screen estimate you correct toward truth. `on`/`off` toggle the mode; `approve`
899 /// promotes chosen defaults to real (attested) decisions.
900 #[command(subcommand)]
901 Pseudo(Pseudo),
902 /// Conservative-filing (D-8): declare undocumented BTC at $0 basis — the IRS fallback for
903 /// unprovable basis. Folds to a single `EstimatedConservative` lot homed at `--window-end`
904 /// (the latest plausible acquisition → conservative holding period). Filing-ready, NOT pseudo.
905 /// v1 makes a pre-2025 tranche and a safe-harbor allocation mutually exclusive.
906 DeclareTranche {
907 /// Amount of undocumented BTC (required). A satoshi integer OR a BTC decimal, e.g. `0.5`
908 /// or `50000000` (a value with a `.` is BTC; a bare integer is satoshis). Must be > 0.
909 #[arg(long)]
910 amount: String,
911 /// Wallet holding the coins, e.g. `self:cold` or `exchange:coinbase:default`.
912 #[arg(long)]
913 wallet: String,
914 /// Earliest plausible acquisition date (YYYY-MM-DD).
915 #[arg(long)]
916 window_start: String,
917 /// Latest plausible acquisition date (YYYY-MM-DD) — the lot is homed here.
918 #[arg(long)]
919 window_end: String,
920 },
921 /// Approach-B: promote an already-declared `DeclareTranche` (D-8's $0 IRS fallback) to a filed `>$0`
922 /// basis floor (the minimum daily closing price over the attested acquisition window), behind a
923 /// mandatory purchase-provenance attestation (BG-D5), a two-sided informed-consent screen (BG-D6),
924 /// and a Form 8275 Part II narrative (BG-D7). Mitigates penalty exposure; does not eliminate it or
925 /// guarantee immunity — read the consent screen this verb prints before acknowledging.
926 PromoteTranche {
927 /// The `DeclareTranche` decision to promote (see `btctax events list` for the ref).
928 target: String,
929 /// The units' real acquisition provenance. Promotion requires `purchase`; every other value is
930 /// refused and pointed at modeling the real acquisition instead (BG-D5).
931 #[arg(long, value_enum)]
932 provenance: crate::cmd::promote::ProvenanceKind,
933 /// Path to a text file holding the Form 8275 Part II narrative (filer facts, Reg. §1.6662-4(f),
934 /// "in sufficient detail"). An empty or whitespace-only file is refused at record time (BG-D7).
935 #[arg(long)]
936 part_ii_file: PathBuf,
937 /// Non-interactive consent: the exact acknowledgment phrase shown on the consent screen. On an
938 /// interactive terminal, omitting this shows the screen and prompts for the phrase.
939 #[arg(long)]
940 i_acknowledge: Option<String>,
941 },
942}
943
944/// `reconcile pseudo <action>` — the pseudo-reconcile mode sub-verbs (sub-project 2).
945#[derive(Subcommand)]
946pub enum Pseudo {
947 /// Turn pseudo-reconcile mode ON. Projection now synthesizes non-persisted default decisions for
948 /// unresolved unknown-basis inbounds (self-transfer $0), unclassified rows, and import conflicts
949 /// (accept-first); every synthetic contribution is flagged `[PSEUDO]` on screen and BLOCKS export.
950 On,
951 /// Turn pseudo-reconcile mode OFF. Projection reverts to real-only instantly and totally (no
952 /// fictional events were ever written). Already-approved decisions REMAIN (they are real now).
953 Off,
954 /// Promote pseudo default decisions to REAL (attested) decisions in bulk. Shows a preview + requires
955 /// `--yes` (or `--dry-run` to preview only). Optional filters restrict which defaults are approved.
956 Approve {
957 /// Only approve defaults of this TYPE: `self-transfer` (unknown-basis inbound → $0 self-transfer),
958 /// `raw` (unclassified row placeholder), `conflict` (import conflict accept-first), or `fmv`
959 /// (native income FMV synthesized from the daily close). Omit = all.
960 #[arg(long, value_enum)]
961 kind: Option<PseudoKindArg>,
962 /// Only approve defaults whose target event is in this wallet (e.g. `exchange:coinbase:main`).
963 #[arg(long)]
964 wallet: Option<String>,
965 /// Only approve defaults whose target event falls in this tax year.
966 #[arg(long)]
967 year: Option<i32>,
968 /// Print the preview and exit without writing.
969 #[arg(long)]
970 dry_run: bool,
971 /// Skip the interactive confirmation (non-interactive apply).
972 #[arg(long)]
973 yes: bool,
974 },
975}
976
977/// The pseudo-default TYPE filter for `reconcile pseudo approve --kind`.
978#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
979pub enum PseudoKindArg {
980 /// Unknown-basis inbound defaulted to a $0-basis self-transfer-in.
981 SelfTransfer,
982 /// Unclassified row defaulted to a zero-value placeholder (ClassifyRaw).
983 Raw,
984 /// Import conflict defaulted to accept-first (SupersedeImport).
985 Conflict,
986 /// Native income with a missing FMV defaulted to the daily-close value (ManualFmv).
987 Fmv,
988}
989
990#[derive(Copy, Clone, ValueEnum)]
991pub enum SelfTransferActionArg {
992 /// Same-wallet passthrough → SelfTransferPassthrough (both legs skipped, non-taxable).
993 Drop,
994 /// Cross-wallet transfer → TransferLink (relocate the lots to the destination wallet).
995 Relocate,
996}
997
998/// One official form in the `export-irs-pdf` packet (the `--forms` opt-in filter).
999#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
1000pub enum FormArg {
1001 /// Form 8949 (per-disposition capital-gains rows).
1002 F8949,
1003 /// Schedule D (aggregated capital-gains totals).
1004 ScheduleD,
1005 /// Schedule SE (self-employment tax).
1006 ScheduleSe,
1007 /// Form 8283 (noncash charitable contributions).
1008 Form8283,
1009 /// Form 1040 (capital-gains cells + the digital-asset question).
1010 Form1040,
1011 /// Form 8275 (Disclosure Statement — Approach-B's Cohan-estimate promoted-basis disclosure).
1012 Form8275,
1013}
1014
1015#[derive(Copy, Clone, ValueEnum)]
1016pub enum FilingStatusArg {
1017 Single,
1018 Mfj,
1019 Mfs,
1020 Hoh,
1021 Qss,
1022}
1023
1024impl From<FilingStatusArg> for FilingStatus {
1025 fn from(a: FilingStatusArg) -> Self {
1026 match a {
1027 FilingStatusArg::Single => FilingStatus::Single,
1028 FilingStatusArg::Mfj => FilingStatus::Mfj,
1029 FilingStatusArg::Mfs => FilingStatus::Mfs,
1030 FilingStatusArg::Hoh => FilingStatus::HoH,
1031 FilingStatusArg::Qss => FilingStatus::Qss,
1032 }
1033 }
1034}
1035
1036#[derive(Copy, Clone, ValueEnum)]
1037pub enum FeeArg {
1038 C,
1039 B,
1040}
1041
1042#[derive(Copy, Clone, ValueEnum)]
1043pub enum MethodLotArg {
1044 Fifo,
1045 Lifo,
1046 Hifo,
1047}
1048
1049#[derive(Copy, Clone, ValueEnum)]
1050pub enum OutKindArg {
1051 Sell,
1052 Spend,
1053 Gift,
1054 Donate,
1055}
1056
1057impl From<MethodLotArg> for LotMethod {
1058 fn from(a: MethodLotArg) -> Self {
1059 match a {
1060 MethodLotArg::Fifo => LotMethod::Fifo,
1061 MethodLotArg::Lifo => LotMethod::Lifo,
1062 MethodLotArg::Hifo => LotMethod::Hifo,
1063 }
1064 }
1065}
1066
1067#[derive(Copy, Clone, ValueEnum)]
1068pub enum MethodArg {
1069 Actual,
1070 ProRata,
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076 use clap::CommandFactory;
1077
1078 /// Render the LONG help (`--help`) of a subcommand identified by its path, recursing into
1079 /// nested subcommands (e.g. `["reconcile", "import-selections"]`). Mirrors what a user sees at
1080 /// `btctax <path...> --help`, which includes each argument's verbatim long-help.
1081 fn long_help_of(path: &[&str]) -> String {
1082 let mut cmd = Cli::command();
1083 for name in path {
1084 cmd = cmd
1085 .find_subcommand(name)
1086 .unwrap_or_else(|| panic!("subcommand {name:?} exists"))
1087 .clone();
1088 }
1089 cmd.render_long_help().to_string()
1090 }
1091
1092 // Requirement 3, `--help` half: each file/format-taking arg's long-help carries its FORMAT +
1093 // a text EXAMPLE. Tokens are comma/brace-joined (no spaces) so help-wrapping can never break
1094 // them (verified against the real binary output). This is the single source of truth that
1095 // clap_mangen also renders into the per-subcommand man page (Task 2).
1096
1097 #[test]
1098 fn help_documents_key_backup_format() {
1099 let h = long_help_of(&["init"]);
1100 assert!(
1101 h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
1102 "init --key-backup help must document the ASCII-armored key format:\n{h}"
1103 );
1104 }
1105
1106 #[test]
1107 fn help_documents_backup_key_format() {
1108 let h = long_help_of(&["backup-key"]);
1109 assert!(
1110 h.contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
1111 "backup-key --out help must document the ASCII-armored key format:\n{h}"
1112 );
1113 }
1114
1115 #[test]
1116 fn help_documents_export_snapshot_format() {
1117 let h = long_help_of(&["export-snapshot"]);
1118 // The exact removals.csv header read from the render.rs writer.
1119 assert!(
1120 h.contains("event,kind,removed_at,lot,sat,basis,fmv_at_transfer"),
1121 "export-snapshot --out help must document the projection CSV headers:\n{h}"
1122 );
1123 }
1124
1125 #[test]
1126 fn help_documents_import_selections_format() {
1127 let h = long_help_of(&["reconcile", "import-selections"]);
1128 assert!(
1129 h.contains("disposal_ref,origin_event_id,split_sequence,sat"),
1130 "import-selections help must document the required CSV header:\n{h}"
1131 );
1132 }
1133
1134 #[test]
1135 fn help_documents_classify_raw_format() {
1136 let h = long_help_of(&["reconcile", "classify-raw"]);
1137 // The exact externally-tagged serde shape (Usd = decimal string, sat = integer).
1138 assert!(
1139 h.contains(r#"{"Acquire":{"sat":2000000,"usd_cost":"1680.00","fee_usd":"5.00","basis_source":"ExchangeProvided"}}"#),
1140 "classify-raw --payload-json help must document the JSON payload shape:\n{h}"
1141 );
1142 }
1143
1144 #[test]
1145 fn help_documents_select_lots_format() {
1146 let h = long_help_of(&["reconcile", "select-lots"]);
1147 assert!(
1148 h.contains("import|coinbase|X#0:25000"),
1149 "select-lots --from help must document the <event>#<split>:<sat> pick format:\n{h}"
1150 );
1151 }
1152}