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;
22use std::str::FromStr;
23
24#[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
34pub 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), 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#[derive(Debug)]
57pub struct SellOutcome {
58 pub report: SellReport,
59 pub magi_caveat: bool,
60}
61
62#[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 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 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> {
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#[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 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
176pub 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 #[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 #[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 #[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}