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;
22use std::str::FromStr;
23
24/// A validated ad-hoc (non-persisted) profile spec built from the `what-if sell` flags. `filing_status`
25/// + `income` are mandatory to enter ad-hoc mode; `magi` is optional (defaults to `income`).
26#[derive(Debug, Clone)]
27pub struct AdhocProfile {
28    pub filing_status: FilingStatus,
29    pub income: Usd,
30    pub magi: Option<Usd>,
31    pub cf_long: Usd,
32}
33
34/// Build a NON-persisted `TaxProfile` from the ad-hoc spec (mirrors `placeholder_tax_profile`, but
35/// user-supplied). **[R0-M4]** `magi_excluding_crypto` defaults to `income` when `--magi` is omitted —
36/// NEVER $0 (a $0 floor would silently suppress every NIIT disclosure).
37pub fn adhoc_profile(a: &AdhocProfile) -> TaxProfile {
38    TaxProfile {
39        filing_status: a.filing_status,
40        ordinary_taxable_income: a.income,
41        magi_excluding_crypto: a.magi.unwrap_or(a.income), // [R0-M4] floor = income, never $0
42        qualified_dividends_and_other_pref_income: Usd::ZERO,
43        other_net_capital_gain: Usd::ZERO,
44        capital_loss_carryforward_in: Carryforward {
45            short: Usd::ZERO,
46            long: a.cf_long,
47        },
48        w2_ss_wages: Usd::ZERO,
49        w2_medicare_wages: Usd::ZERO,
50        schedule_c_expenses: Usd::ZERO,
51    }
52}
53
54/// The outcome of `what-if sell`: the report + whether the ad-hoc `--magi` defaulted to `--income`
55/// (so the renderer/caller can print the "MAGI assumed = ordinary income" caveat).
56#[derive(Debug)]
57pub struct SellOutcome {
58    pub report: SellReport,
59    pub magi_caveat: bool,
60}
61
62/// `what-if sell` — READ-ONLY hypothetical sale. Resolves the profile (ad-hoc if supplied, else the
63/// stored one for the sale year), builds the `SellRequest`, and calls the core engine. No `save()`.
64#[allow(clippy::too_many_arguments)]
65pub fn sell(
66    vault: &Path,
67    pp: &Passphrase,
68    sell_sat: i64,
69    wallet: WalletId,
70    at: TaxDate,
71    price: Option<Usd>,
72    method: Option<LotMethod>,
73    adhoc: Option<AdhocProfile>,
74) -> Result<SellOutcome, CliError> {
75    let s = Session::open(vault, pp)?;
76    let (events, _state, cfg) = s.load_events_and_project()?;
77    let year = at.year();
78
79    // Profile: ad-hoc (non-persisted) when supplied; otherwise the stored profile for the year.
80    let (profile, magi_caveat) = match &adhoc {
81        Some(a) => (Some(adhoc_profile(a)), a.magi.is_none()),
82        None => (s.tax_profile(year)?, false),
83    };
84
85    let prices = s.prices();
86    let tables = BundledTaxTables::load();
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).
111pub fn parse_harvest_target(s: &str) -> Result<HarvestTarget, CliError> {
112    let lower = s.trim().to_ascii_lowercase();
113    match lower.as_str() {
114        "zero-ltcg" | "zero_ltcg" | "zeroltcg" => return Ok(HarvestTarget::ZeroLtcg),
115        "fifteen-ltcg" | "fifteen_ltcg" | "fifteenltcg" => return Ok(HarvestTarget::FifteenLtcg),
116        _ => {}
117    }
118    if let Some(v) = lower.strip_prefix("gain=") {
119        return Ok(HarvestTarget::Gain(parse_target_amount(v)?));
120    }
121    if let Some(v) = lower.strip_prefix("tax=") {
122        return Ok(HarvestTarget::Tax(parse_target_amount(v)?));
123    }
124    Err(CliError::Usage(format!(
125        "bad --target {s:?}: expected zero-ltcg | fifteen-ltcg | gain=$X | tax=$X"
126    )))
127}
128fn parse_target_amount(v: &str) -> Result<Usd, CliError> {
129    let cleaned = v.trim().replace(['$', ','], "");
130    Usd::from_str(&cleaned).map_err(|e| {
131        CliError::Usage(format!(
132            "bad --target amount {v:?}: expected a USD number: {e}"
133        ))
134    })
135}
136
137/// `what-if harvest` — READ-ONLY harvest optimizer. Resolves the profile (ad-hoc if supplied, else the
138/// stored one for the harvest year), builds the `HarvestRequest`, and calls the core segment-walk
139/// optimizer. No `save()`.
140#[allow(clippy::too_many_arguments)]
141pub fn harvest(
142    vault: &Path,
143    pp: &Passphrase,
144    wallet: WalletId,
145    at: TaxDate,
146    price: Option<Usd>,
147    target: HarvestTarget,
148    adhoc: Option<AdhocProfile>,
149) -> Result<HarvestOutcome, CliError> {
150    let s = Session::open(vault, pp)?;
151    let (events, _state, cfg) = s.load_events_and_project()?;
152    let year = at.year();
153
154    let (profile, magi_caveat) = match &adhoc {
155        Some(a) => (Some(adhoc_profile(a)), a.magi.is_none()),
156        None => (s.tax_profile(year)?, false),
157    };
158
159    let prices = s.prices();
160    let tables = BundledTaxTables::load();
161    let req = HarvestRequest {
162        wallet,
163        at,
164        price,
165        target,
166    };
167    // whatif::harvest is READ-ONLY (clone-fold-discard); no save() is ever called.
168    let report = whatif::harvest(&events, prices, &cfg, profile.as_ref(), &tables, &req)
169        .map_err(map_whatif_err)?;
170    Ok(HarvestOutcome {
171        report,
172        magi_caveat,
173    })
174}
175
176/// Map a `WhatIfError` to a user-facing `CliError` (mirrors `cmd::optimize::map_opt_err`).
177pub fn map_whatif_err(e: WhatIfError) -> CliError {
178    match e {
179        WhatIfError::YearNotComputable(b) => CliError::Usage(format!(
180            "year not computable — resolve the blocker or set a tax profile first: [{:?}] {}",
181            b.kind, b.detail
182        )),
183        WhatIfError::PreTransitionYear(y) => CliError::Usage(format!(
184            "{y} is pre-2025: a pre-2025 sale restates a closed year — not a plan"
185        )),
186        WhatIfError::NoLots => {
187            CliError::Usage("no lots available to sell from that wallet as of that date".into())
188        }
189        WhatIfError::Evaluate(EvaluateError::ProceedsRequired) => CliError::Usage(
190            "--price <usd-per-btc> is required for a future/off-dataset date with no bundled price"
191                .into(),
192        ),
193        WhatIfError::Evaluate(ev) => CliError::Usage(format!("evaluate error: {ev:?}")),
194        WhatIfError::InvalidTarget(msg) => CliError::Usage(msg),
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use rust_decimal_macros::dec;
202
203    /// [R0-M4] `--magi` defaults to `--income` (NEVER $0) when omitted; supplying it wins.
204    #[test]
205    fn adhoc_magi_defaults_to_income() {
206        let defaulted = adhoc_profile(&AdhocProfile {
207            filing_status: FilingStatus::Single,
208            income: dec!(100000),
209            magi: None,
210            cf_long: Usd::ZERO,
211        });
212        assert_eq!(
213            defaulted.magi_excluding_crypto,
214            dec!(100000),
215            "MAGI must FLOOR at income, never $0"
216        );
217        assert_ne!(defaulted.magi_excluding_crypto, Usd::ZERO);
218
219        let explicit = adhoc_profile(&AdhocProfile {
220            filing_status: FilingStatus::Single,
221            income: dec!(100000),
222            magi: Some(dec!(130000)),
223            cf_long: Usd::ZERO,
224        });
225        assert_eq!(explicit.magi_excluding_crypto, dec!(130000));
226    }
227
228    /// `--target` parses the four forms; `$`/commas are optional; a bad form / negative is rejected.
229    #[test]
230    fn parse_harvest_target_forms() {
231        assert_eq!(
232            parse_harvest_target("zero-ltcg").unwrap(),
233            HarvestTarget::ZeroLtcg
234        );
235        assert_eq!(
236            parse_harvest_target("FIFTEEN-LTCG").unwrap(),
237            HarvestTarget::FifteenLtcg
238        );
239        assert_eq!(
240            parse_harvest_target("gain=$25,000").unwrap(),
241            HarvestTarget::Gain(dec!(25000))
242        );
243        assert_eq!(
244            parse_harvest_target("tax=$0").unwrap(),
245            HarvestTarget::Tax(dec!(0))
246        );
247        assert_eq!(
248            parse_harvest_target("tax=1500.50").unwrap(),
249            HarvestTarget::Tax(dec!(1500.50))
250        );
251        assert!(parse_harvest_target("nonsense").is_err());
252        assert!(parse_harvest_target("gain=abc").is_err());
253    }
254
255    /// The ad-hoc long-term carryforward-in flows into the profile (short stays $0).
256    #[test]
257    fn adhoc_carryforward_in_is_long_term() {
258        let p = adhoc_profile(&AdhocProfile {
259            filing_status: FilingStatus::Mfj,
260            income: dec!(80000),
261            magi: Some(dec!(90000)),
262            cf_long: dec!(25000),
263        });
264        assert_eq!(p.capital_loss_carryforward_in.long, dec!(25000));
265        assert_eq!(p.capital_loss_carryforward_in.short, Usd::ZERO);
266    }
267}