Skip to main content

btctax_cli/cmd/
whatif.rs

1//! `what-if sell` (task #43) — §43 READ-ONLY hypothetical-sale tax planning. Opens the vault, runs the
2//! pure deterministic projection, and calls `btctax_core::whatif::sell` with the synthetic sale.
3//! Appends NOTHING, writes NO side-table row, calls no `session.save()` (the vault is byte-identical
4//! after — the `whatif_never_persists` KAT). Tax decision-support (consequences), NOT buy/sell advice.
5//!
6//! **Ad-hoc profile.** With `--filing-status` + `--income` (+ optional `--magi`/`--carryforward-in`)
7//! the plan runs against a NON-persisted `TaxProfile` so a user can plan without `tax-profile set`;
8//! with no ad-hoc flags it falls back to the stored `Session::tax_profile(year)`. **[R0-M4] `--magi`
9//! defaults to `--income`** (a floor — NEVER $0, which would silently suppress every NIIT disclosure);
10//! the caller prints the caveat when the default is used.
11use crate::{CliError, Session};
12use btctax_adapters::BundledTaxTables;
13use btctax_core::whatif::{
14    self, HarvestReport, HarvestRequest, HarvestTarget, SellMethod, SellReport, SellRequest,
15};
16use btctax_core::{
17    Carryforward, EvaluateError, FilingStatus, LotMethod, TaxDate, TaxProfile, Usd, WalletId,
18    WhatIfError,
19};
20use btctax_store::Passphrase;
21use std::path::Path;
22
23/// A validated ad-hoc (non-persisted) profile spec built from the `what-if sell` flags. `filing_status`
24/// + `income` are mandatory to enter ad-hoc mode; `magi` is optional (defaults to `income`).
25#[derive(Debug, Clone)]
26pub struct AdhocProfile {
27    pub filing_status: FilingStatus,
28    pub income: Usd,
29    pub magi: Option<Usd>,
30    pub cf_long: Usd,
31}
32
33/// Build a NON-persisted `TaxProfile` from the ad-hoc spec (mirrors `placeholder_tax_profile`, but
34/// user-supplied). **[R0-M4]** `magi_excluding_crypto` defaults to `income` when `--magi` is omitted —
35/// NEVER $0 (a $0 floor would silently suppress every NIIT disclosure).
36pub fn adhoc_profile(a: &AdhocProfile) -> TaxProfile {
37    TaxProfile {
38        filing_status: a.filing_status,
39        ordinary_taxable_income: a.income,
40        magi_excluding_crypto: a.magi.unwrap_or(a.income), // [R0-M4] floor = income, never $0
41        qualified_dividends_and_other_pref_income: Usd::ZERO,
42        other_net_capital_gain: Usd::ZERO,
43        capital_loss_carryforward_in: Carryforward {
44            short: Usd::ZERO,
45            long: a.cf_long,
46        },
47        w2_ss_wages: Usd::ZERO,
48        w2_medicare_wages: Usd::ZERO,
49        schedule_c_expenses: Usd::ZERO,
50    }
51}
52
53/// The outcome of `what-if sell`: the report + whether the ad-hoc `--magi` defaulted to `--income`
54/// (so the renderer/caller can print the "MAGI assumed = ordinary income" caveat).
55#[derive(Debug)]
56pub struct SellOutcome {
57    pub report: SellReport,
58    pub magi_caveat: bool,
59}
60
61/// `what-if sell` — READ-ONLY hypothetical sale. Resolves the profile (ad-hoc if supplied, else the
62/// stored one for the sale year), builds the `SellRequest`, and calls the core engine. No `save()`.
63#[allow(clippy::too_many_arguments)]
64pub fn sell(
65    vault: &Path,
66    pp: &Passphrase,
67    sell_sat: i64,
68    wallet: WalletId,
69    at: TaxDate,
70    price: Option<Usd>,
71    method: Option<LotMethod>,
72    adhoc: Option<AdhocProfile>,
73) -> Result<SellOutcome, CliError> {
74    let s = Session::open(vault, pp)?;
75    let (events, state, cfg) = s.load_events_and_project()?;
76    let year = at.year();
77    let prices = s.prices();
78    let tables = BundledTaxTables::load();
79
80    // Profile: ad-hoc (non-persisted) when supplied; otherwise resolve (ReturnInputs-derived → stored → …),
81    // fail-closed screened — the same source `report`/`optimize` use, so plans match the filed number.
82    let (profile, magi_caveat) = match &adhoc {
83        Some(a) => (Some(adhoc_profile(a)), a.magi.is_none()),
84        None => (s.resolve_screened_profile(&state, year, &tables)?, false),
85    };
86
87    let req = SellRequest {
88        sell_sat,
89        wallet,
90        at,
91        price,
92        method: method.map(SellMethod::Method),
93    };
94    // whatif::sell is READ-ONLY (clone-fold-discard); no save() is ever called.
95    let report = whatif::sell(&events, prices, &cfg, profile.as_ref(), &tables, &req)
96        .map_err(map_whatif_err)?;
97    Ok(SellOutcome {
98        report,
99        magi_caveat,
100    })
101}
102
103/// The outcome of `what-if harvest`: the report + whether `--magi` defaulted to `--income`.
104#[derive(Debug)]
105pub struct HarvestOutcome {
106    pub report: HarvestReport,
107    pub magi_caveat: bool,
108}
109
110/// Parse `--target`: `zero-ltcg` | `fifteen-ltcg` | `gain=$X` | `tax=$X` (X >= 0; `$`/commas optional).
111///
112/// A thin wrapper over the single source of truth, [`HarvestTarget`]'s `FromStr` in `btctax-core`
113/// (shared with the TUI panel). The `Display` of the core error reproduces the historical `--target`
114/// messages; we only re-wrap it in the same `CliError::Usage` variant this parser has always used, so
115/// CLI error output stays stable.
116pub fn parse_harvest_target(s: &str) -> Result<HarvestTarget, CliError> {
117    s.parse::<HarvestTarget>()
118        .map_err(|e| CliError::Usage(e.to_string()))
119}
120
121/// `what-if harvest` — READ-ONLY harvest optimizer. Resolves the profile (ad-hoc if supplied, else the
122/// stored one for the harvest year), builds the `HarvestRequest`, and calls the core segment-walk
123/// optimizer. No `save()`.
124#[allow(clippy::too_many_arguments)]
125pub fn harvest(
126    vault: &Path,
127    pp: &Passphrase,
128    wallet: WalletId,
129    at: TaxDate,
130    price: Option<Usd>,
131    target: HarvestTarget,
132    adhoc: Option<AdhocProfile>,
133) -> Result<HarvestOutcome, CliError> {
134    let s = Session::open(vault, pp)?;
135    let (events, state, cfg) = s.load_events_and_project()?;
136    let year = at.year();
137    let prices = s.prices();
138    let tables = BundledTaxTables::load();
139
140    let (profile, magi_caveat) = match &adhoc {
141        Some(a) => (Some(adhoc_profile(a)), a.magi.is_none()),
142        None => (s.resolve_screened_profile(&state, year, &tables)?, false),
143    };
144
145    let req = HarvestRequest {
146        wallet,
147        at,
148        price,
149        target,
150    };
151    // whatif::harvest is READ-ONLY (clone-fold-discard); no save() is ever called.
152    let report = whatif::harvest(&events, prices, &cfg, profile.as_ref(), &tables, &req)
153        .map_err(map_whatif_err)?;
154    Ok(HarvestOutcome {
155        report,
156        magi_caveat,
157    })
158}
159
160/// Map a `WhatIfError` to a user-facing `CliError` (mirrors `cmd::optimize::map_opt_err`).
161pub fn map_whatif_err(e: WhatIfError) -> CliError {
162    match e {
163        WhatIfError::YearNotComputable(b) => CliError::Usage(format!(
164            "year not computable — resolve the blocker or set a tax profile first: [{:?}] {}",
165            b.kind, b.detail
166        )),
167        WhatIfError::PreTransitionYear(y) => CliError::Usage(format!(
168            "{y} is pre-2025: a pre-2025 sale restates a closed year — not a plan"
169        )),
170        WhatIfError::NoLots {
171            wallet,
172            at,
173            available,
174            requested,
175        } => CliError::Usage(crate::render::no_lots_message(
176            &wallet, at, available, requested,
177        )),
178        WhatIfError::Evaluate(EvaluateError::ProceedsRequired) => CliError::Usage(
179            "--price <usd-per-btc> is required for a future/off-dataset date with no bundled price"
180                .into(),
181        ),
182        WhatIfError::Evaluate(ev) => CliError::Usage(format!("evaluate error: {ev:?}")),
183        WhatIfError::InvalidTarget(msg) => CliError::Usage(msg),
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use rust_decimal_macros::dec;
191
192    /// [R0-M4] `--magi` defaults to `--income` (NEVER $0) when omitted; supplying it wins.
193    #[test]
194    fn adhoc_magi_defaults_to_income() {
195        let defaulted = adhoc_profile(&AdhocProfile {
196            filing_status: FilingStatus::Single,
197            income: dec!(100000),
198            magi: None,
199            cf_long: Usd::ZERO,
200        });
201        assert_eq!(
202            defaulted.magi_excluding_crypto,
203            dec!(100000),
204            "MAGI must FLOOR at income, never $0"
205        );
206        assert_ne!(defaulted.magi_excluding_crypto, Usd::ZERO);
207
208        let explicit = adhoc_profile(&AdhocProfile {
209            filing_status: FilingStatus::Single,
210            income: dec!(100000),
211            magi: Some(dec!(130000)),
212            cf_long: Usd::ZERO,
213        });
214        assert_eq!(explicit.magi_excluding_crypto, dec!(130000));
215    }
216
217    /// `--target` parses the four forms; `$`/commas are optional; an unrecognized form / bad amount is
218    /// rejected. (Negatives are NOT rejected here — the pure lexer passes `gain=-1` through and the
219    /// ENGINE refuses it as `InvalidTarget`; see `cmd_and_panel_share_fromstr` + the core KAT.)
220    #[test]
221    fn parse_harvest_target_forms() {
222        assert_eq!(
223            parse_harvest_target("zero-ltcg").unwrap(),
224            HarvestTarget::ZeroLtcg
225        );
226        assert_eq!(
227            parse_harvest_target("FIFTEEN-LTCG").unwrap(),
228            HarvestTarget::FifteenLtcg
229        );
230        assert_eq!(
231            parse_harvest_target("gain=$25,000").unwrap(),
232            HarvestTarget::Gain(dec!(25000))
233        );
234        assert_eq!(
235            parse_harvest_target("tax=$0").unwrap(),
236            HarvestTarget::Tax(dec!(0))
237        );
238        assert_eq!(
239            parse_harvest_target("tax=1500.50").unwrap(),
240            HarvestTarget::Tax(dec!(1500.50))
241        );
242        assert!(parse_harvest_target("nonsense").is_err());
243        assert!(parse_harvest_target("gain=abc").is_err());
244    }
245
246    /// The CLI `--target` parse is now a thin wrapper over the shared core `HarvestTarget: FromStr`
247    /// (the same parser the TUI panel calls — dedup, task #48). Prove the wrapper delegates: for every
248    /// representative form the cmd parse's Ok value matches `s.parse::<HarvestTarget>()` and they agree
249    /// on success/failure — including the negative the lexer passes through (NOT a parse error) and the
250    /// `gain=1_000` underscore case (`Gain(1000)`, parity with the legacy lexer). (The cmd re-wraps any
251    /// Err in `CliError::Usage`, which adds a `usage: ` prefix — so we compare the Ok VALUE + the
252    /// err-ness, not the CLI-decorated message.) The panel shares the identical `from_str` (see
253    /// `whatif_panel::parse_harvest_target`); KAT-E10 keeps the panel free of `cmd::` tokens.
254    #[test]
255    fn cmd_and_panel_share_fromstr() {
256        for s in [
257            "zero-ltcg",
258            "FIFTEEN-LTCG",
259            "gain=$25,000",
260            "gain=1000",
261            "tax=$0",
262            "tax=1500.50",
263            "gain=-1",    // the lexer passes negatives through; the engine refuses them
264            "gain=1_000", // `_` accepted by rust_decimal → Gain(1000), parity with the legacy lexer
265            "nonsense",
266            "gain=abc",
267        ] {
268            let via_cmd = parse_harvest_target(s);
269            let via_core = s.parse::<HarvestTarget>();
270            assert_eq!(
271                via_cmd.as_ref().ok(),
272                via_core.as_ref().ok(),
273                "cmd wrapper must yield FromStr's Ok value: {s:?}"
274            );
275            assert_eq!(
276                via_cmd.is_err(),
277                via_core.is_err(),
278                "cmd wrapper must agree with FromStr on failure: {s:?}"
279            );
280        }
281    }
282
283    /// UX-P4-9: an INSUFFICIENT pool (lots exist but do not cover the sale) names the available
284    /// balance, the wallet, the as-of date, and the requested amount — NOT the false "no lots
285    /// available" wording (which reads as an empty wallet).
286    #[test]
287    fn no_lots_message_insufficient_names_available_wallet_date_requested() {
288        use time::macros::date;
289        let err = map_whatif_err(WhatIfError::NoLots {
290            wallet: WalletId::SelfCustody {
291                label: "cold".into(),
292            },
293            at: date!(2025 - 08 - 01),
294            available: 50_000_000, // 0.5 BTC held
295            requested: 60_000_000, // 0.6 BTC requested
296        });
297        let msg = err.to_string();
298        assert!(msg.contains("only 0.50000000 BTC available"), "{msg}");
299        assert!(msg.contains("self:cold"), "names the wallet: {msg}");
300        assert!(msg.contains("2025-08-01"), "names the as-of date: {msg}");
301        assert!(
302            msg.contains("(requested 0.60000000 BTC)"),
303            "names the request: {msg}"
304        );
305        assert!(
306            !msg.contains("no lots available"),
307            "the false 'no lots available' wording is gone when lots DO exist: {msg}"
308        );
309    }
310
311    /// UX-P4-9: a GENUINELY EMPTY pool (available == 0) says "no BTC available" — the honest
312    /// zero case, distinct from mere insufficiency, and never "only 0 BTC".
313    #[test]
314    fn no_lots_message_empty_pool_says_no_btc() {
315        use time::macros::date;
316        let err = map_whatif_err(WhatIfError::NoLots {
317            wallet: WalletId::SelfCustody {
318                label: "hot".into(),
319            },
320            at: date!(2025 - 08 - 01),
321            available: 0,
322            requested: 60_000_000,
323        });
324        let msg = err.to_string();
325        assert!(
326            msg.contains("no BTC available in self:hot as of 2025-08-01"),
327            "empty pool names the wallet + date: {msg}"
328        );
329        assert!(
330            !msg.contains("only"),
331            "an empty pool must not say 'only <X>': {msg}"
332        );
333    }
334
335    /// The ad-hoc long-term carryforward-in flows into the profile (short stays $0).
336    #[test]
337    fn adhoc_carryforward_in_is_long_term() {
338        let p = adhoc_profile(&AdhocProfile {
339            filing_status: FilingStatus::Mfj,
340            income: dec!(80000),
341            magi: Some(dec!(90000)),
342            cf_long: dec!(25000),
343        });
344        assert_eq!(p.capital_loss_carryforward_in.long, dec!(25000));
345        assert_eq!(p.capital_loss_carryforward_in.short, Usd::ZERO);
346    }
347}