1use 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#[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
33pub 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), 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#[derive(Debug)]
56pub struct SellOutcome {
57 pub report: SellReport,
58 pub magi_caveat: bool,
59}
60
61#[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 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 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#[derive(Debug)]
105pub struct HarvestOutcome {
106 pub report: HarvestReport,
107 pub magi_caveat: bool,
108}
109
110pub 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#[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 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
160pub 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 #[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 #[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 #[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", "gain=1_000", "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 #[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, requested: 60_000_000, });
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 #[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 #[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}