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