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 CliError::Usage("no lots available to sell from that wallet as of that date".into())
172 }
173 WhatIfError::Evaluate(EvaluateError::ProceedsRequired) => CliError::Usage(
174 "--price <usd-per-btc> is required for a future/off-dataset date with no bundled price"
175 .into(),
176 ),
177 WhatIfError::Evaluate(ev) => CliError::Usage(format!("evaluate error: {ev:?}")),
178 WhatIfError::InvalidTarget(msg) => CliError::Usage(msg),
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use rust_decimal_macros::dec;
186
187 #[test]
189 fn adhoc_magi_defaults_to_income() {
190 let defaulted = adhoc_profile(&AdhocProfile {
191 filing_status: FilingStatus::Single,
192 income: dec!(100000),
193 magi: None,
194 cf_long: Usd::ZERO,
195 });
196 assert_eq!(
197 defaulted.magi_excluding_crypto,
198 dec!(100000),
199 "MAGI must FLOOR at income, never $0"
200 );
201 assert_ne!(defaulted.magi_excluding_crypto, Usd::ZERO);
202
203 let explicit = adhoc_profile(&AdhocProfile {
204 filing_status: FilingStatus::Single,
205 income: dec!(100000),
206 magi: Some(dec!(130000)),
207 cf_long: Usd::ZERO,
208 });
209 assert_eq!(explicit.magi_excluding_crypto, dec!(130000));
210 }
211
212 #[test]
216 fn parse_harvest_target_forms() {
217 assert_eq!(
218 parse_harvest_target("zero-ltcg").unwrap(),
219 HarvestTarget::ZeroLtcg
220 );
221 assert_eq!(
222 parse_harvest_target("FIFTEEN-LTCG").unwrap(),
223 HarvestTarget::FifteenLtcg
224 );
225 assert_eq!(
226 parse_harvest_target("gain=$25,000").unwrap(),
227 HarvestTarget::Gain(dec!(25000))
228 );
229 assert_eq!(
230 parse_harvest_target("tax=$0").unwrap(),
231 HarvestTarget::Tax(dec!(0))
232 );
233 assert_eq!(
234 parse_harvest_target("tax=1500.50").unwrap(),
235 HarvestTarget::Tax(dec!(1500.50))
236 );
237 assert!(parse_harvest_target("nonsense").is_err());
238 assert!(parse_harvest_target("gain=abc").is_err());
239 }
240
241 #[test]
250 fn cmd_and_panel_share_fromstr() {
251 for s in [
252 "zero-ltcg",
253 "FIFTEEN-LTCG",
254 "gain=$25,000",
255 "gain=1000",
256 "tax=$0",
257 "tax=1500.50",
258 "gain=-1", "gain=1_000", "nonsense",
261 "gain=abc",
262 ] {
263 let via_cmd = parse_harvest_target(s);
264 let via_core = s.parse::<HarvestTarget>();
265 assert_eq!(
266 via_cmd.as_ref().ok(),
267 via_core.as_ref().ok(),
268 "cmd wrapper must yield FromStr's Ok value: {s:?}"
269 );
270 assert_eq!(
271 via_cmd.is_err(),
272 via_core.is_err(),
273 "cmd wrapper must agree with FromStr on failure: {s:?}"
274 );
275 }
276 }
277
278 #[test]
280 fn adhoc_carryforward_in_is_long_term() {
281 let p = adhoc_profile(&AdhocProfile {
282 filing_status: FilingStatus::Mfj,
283 income: dec!(80000),
284 magi: Some(dec!(90000)),
285 cf_long: dec!(25000),
286 });
287 assert_eq!(p.capital_loss_carryforward_in.long, dec!(25000));
288 assert_eq!(p.capital_loss_carryforward_in.short, Usd::ZERO);
289 }
290}