use crate::{CliError, Session};
use btctax_adapters::BundledTaxTables;
use btctax_core::whatif::{
self, HarvestReport, HarvestRequest, HarvestTarget, SellMethod, SellReport, SellRequest,
};
use btctax_core::{
Carryforward, EvaluateError, FilingStatus, LotMethod, TaxDate, TaxProfile, Usd, WalletId,
WhatIfError,
};
use btctax_store::Passphrase;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct AdhocProfile {
pub filing_status: FilingStatus,
pub income: Usd,
pub magi: Option<Usd>,
pub cf_long: Usd,
}
pub fn adhoc_profile(a: &AdhocProfile) -> TaxProfile {
TaxProfile {
filing_status: a.filing_status,
ordinary_taxable_income: a.income,
magi_excluding_crypto: a.magi.unwrap_or(a.income), qualified_dividends_and_other_pref_income: Usd::ZERO,
other_net_capital_gain: Usd::ZERO,
capital_loss_carryforward_in: Carryforward {
short: Usd::ZERO,
long: a.cf_long,
},
w2_ss_wages: Usd::ZERO,
w2_medicare_wages: Usd::ZERO,
schedule_c_expenses: Usd::ZERO,
}
}
#[derive(Debug)]
pub struct SellOutcome {
pub report: SellReport,
pub magi_caveat: bool,
}
#[allow(clippy::too_many_arguments)]
pub fn sell(
vault: &Path,
pp: &Passphrase,
sell_sat: i64,
wallet: WalletId,
at: TaxDate,
price: Option<Usd>,
method: Option<LotMethod>,
adhoc: Option<AdhocProfile>,
) -> Result<SellOutcome, CliError> {
let s = Session::open(vault, pp)?;
let (events, state, cfg) = s.load_events_and_project()?;
let year = at.year();
let prices = s.prices();
let tables = BundledTaxTables::load();
let (profile, magi_caveat) = match &adhoc {
Some(a) => (Some(adhoc_profile(a)), a.magi.is_none()),
None => (s.resolve_screened_profile(&state, year, &tables)?, false),
};
let req = SellRequest {
sell_sat,
wallet,
at,
price,
method: method.map(SellMethod::Method),
};
let report = whatif::sell(&events, prices, &cfg, profile.as_ref(), &tables, &req)
.map_err(map_whatif_err)?;
Ok(SellOutcome {
report,
magi_caveat,
})
}
#[derive(Debug)]
pub struct HarvestOutcome {
pub report: HarvestReport,
pub magi_caveat: bool,
}
pub fn parse_harvest_target(s: &str) -> Result<HarvestTarget, CliError> {
s.parse::<HarvestTarget>()
.map_err(|e| CliError::Usage(e.to_string()))
}
#[allow(clippy::too_many_arguments)]
pub fn harvest(
vault: &Path,
pp: &Passphrase,
wallet: WalletId,
at: TaxDate,
price: Option<Usd>,
target: HarvestTarget,
adhoc: Option<AdhocProfile>,
) -> Result<HarvestOutcome, CliError> {
let s = Session::open(vault, pp)?;
let (events, state, cfg) = s.load_events_and_project()?;
let year = at.year();
let prices = s.prices();
let tables = BundledTaxTables::load();
let (profile, magi_caveat) = match &adhoc {
Some(a) => (Some(adhoc_profile(a)), a.magi.is_none()),
None => (s.resolve_screened_profile(&state, year, &tables)?, false),
};
let req = HarvestRequest {
wallet,
at,
price,
target,
};
let report = whatif::harvest(&events, prices, &cfg, profile.as_ref(), &tables, &req)
.map_err(map_whatif_err)?;
Ok(HarvestOutcome {
report,
magi_caveat,
})
}
pub fn map_whatif_err(e: WhatIfError) -> CliError {
match e {
WhatIfError::YearNotComputable(b) => CliError::Usage(format!(
"year not computable — resolve the blocker or set a tax profile first: [{:?}] {}",
b.kind, b.detail
)),
WhatIfError::PreTransitionYear(y) => CliError::Usage(format!(
"{y} is pre-2025: a pre-2025 sale restates a closed year — not a plan"
)),
WhatIfError::NoLots {
wallet,
at,
available,
requested,
} => CliError::Usage(crate::render::no_lots_message(
&wallet, at, available, requested,
)),
WhatIfError::Evaluate(EvaluateError::ProceedsRequired) => CliError::Usage(
"--price <usd-per-btc> is required for a future/off-dataset date with no bundled price"
.into(),
),
WhatIfError::Evaluate(ev) => CliError::Usage(format!("evaluate error: {ev:?}")),
WhatIfError::InvalidTarget(msg) => CliError::Usage(msg),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn adhoc_magi_defaults_to_income() {
let defaulted = adhoc_profile(&AdhocProfile {
filing_status: FilingStatus::Single,
income: dec!(100000),
magi: None,
cf_long: Usd::ZERO,
});
assert_eq!(
defaulted.magi_excluding_crypto,
dec!(100000),
"MAGI must FLOOR at income, never $0"
);
assert_ne!(defaulted.magi_excluding_crypto, Usd::ZERO);
let explicit = adhoc_profile(&AdhocProfile {
filing_status: FilingStatus::Single,
income: dec!(100000),
magi: Some(dec!(130000)),
cf_long: Usd::ZERO,
});
assert_eq!(explicit.magi_excluding_crypto, dec!(130000));
}
#[test]
fn parse_harvest_target_forms() {
assert_eq!(
parse_harvest_target("zero-ltcg").unwrap(),
HarvestTarget::ZeroLtcg
);
assert_eq!(
parse_harvest_target("FIFTEEN-LTCG").unwrap(),
HarvestTarget::FifteenLtcg
);
assert_eq!(
parse_harvest_target("gain=$25,000").unwrap(),
HarvestTarget::Gain(dec!(25000))
);
assert_eq!(
parse_harvest_target("tax=$0").unwrap(),
HarvestTarget::Tax(dec!(0))
);
assert_eq!(
parse_harvest_target("tax=1500.50").unwrap(),
HarvestTarget::Tax(dec!(1500.50))
);
assert!(parse_harvest_target("nonsense").is_err());
assert!(parse_harvest_target("gain=abc").is_err());
}
#[test]
fn cmd_and_panel_share_fromstr() {
for s in [
"zero-ltcg",
"FIFTEEN-LTCG",
"gain=$25,000",
"gain=1000",
"tax=$0",
"tax=1500.50",
"gain=-1", "gain=1_000", "nonsense",
"gain=abc",
] {
let via_cmd = parse_harvest_target(s);
let via_core = s.parse::<HarvestTarget>();
assert_eq!(
via_cmd.as_ref().ok(),
via_core.as_ref().ok(),
"cmd wrapper must yield FromStr's Ok value: {s:?}"
);
assert_eq!(
via_cmd.is_err(),
via_core.is_err(),
"cmd wrapper must agree with FromStr on failure: {s:?}"
);
}
}
#[test]
fn no_lots_message_insufficient_names_available_wallet_date_requested() {
use time::macros::date;
let err = map_whatif_err(WhatIfError::NoLots {
wallet: WalletId::SelfCustody {
label: "cold".into(),
},
at: date!(2025 - 08 - 01),
available: 50_000_000, requested: 60_000_000, });
let msg = err.to_string();
assert!(msg.contains("only 0.50000000 BTC available"), "{msg}");
assert!(msg.contains("self:cold"), "names the wallet: {msg}");
assert!(msg.contains("2025-08-01"), "names the as-of date: {msg}");
assert!(
msg.contains("(requested 0.60000000 BTC)"),
"names the request: {msg}"
);
assert!(
!msg.contains("no lots available"),
"the false 'no lots available' wording is gone when lots DO exist: {msg}"
);
}
#[test]
fn no_lots_message_empty_pool_says_no_btc() {
use time::macros::date;
let err = map_whatif_err(WhatIfError::NoLots {
wallet: WalletId::SelfCustody {
label: "hot".into(),
},
at: date!(2025 - 08 - 01),
available: 0,
requested: 60_000_000,
});
let msg = err.to_string();
assert!(
msg.contains("no BTC available in self:hot as of 2025-08-01"),
"empty pool names the wallet + date: {msg}"
);
assert!(
!msg.contains("only"),
"an empty pool must not say 'only <X>': {msg}"
);
}
#[test]
fn adhoc_carryforward_in_is_long_term() {
let p = adhoc_profile(&AdhocProfile {
filing_status: FilingStatus::Mfj,
income: dec!(80000),
magi: Some(dec!(90000)),
cf_long: dec!(25000),
});
assert_eq!(p.capital_loss_carryforward_in.long, dec!(25000));
assert_eq!(p.capital_loss_carryforward_in.short, Usd::ZERO);
}
}