use crate::{CliError, Session};
use btctax_adapters::{BundledPrices, BundledTaxTables};
use btctax_core::conventions::tax_date;
use btctax_core::persistence::append_decision;
use btctax_core::{
consult_sale, optimize_year, ConsultReport, ConsultRequest, DisposeKind, EvaluateError,
EventId, EventPayload, LotPick, LotSelection, OptimizeError, OptimizeProposal, Persistability,
TaxDate, TaxTables, Usd, WalletId,
};
use btctax_store::Passphrase;
use std::path::Path;
use time::{OffsetDateTime, UtcOffset};
pub fn run(
vault: &Path,
pp: &Passphrase,
year: i32,
now: OffsetDateTime,
) -> Result<OptimizeProposal, CliError> {
let s = Session::open(vault, pp)?;
let (events, _state, cfg) = s.load_events_and_project()?;
let profile = s.tax_profile(year)?;
let prices = BundledPrices::load()?;
let tables = BundledTaxTables::load();
let attested = s.optimize_attested_set()?;
let proposal_made = tax_date(now, UtcOffset::UTC); let p = optimize_year(
&events,
&prices,
&cfg,
year,
profile.as_ref(),
&tables,
&attested,
proposal_made,
)
.map_err(map_opt_err)?;
if p.approximate {
eprintln!(
"warning: optimize result is APPROXIMATE (not a guaranteed global minimum): {:?}",
p.approx_reason
);
}
Ok(p)
}
pub(crate) fn map_opt_err(e: OptimizeError) -> CliError {
match e {
OptimizeError::YearNotComputable(b) => CliError::Usage(format!(
"year not computable — resolve the blocker first: [{:?}] {}",
b.kind, b.detail
)),
OptimizeError::PreTransitionYear(y) => CliError::Usage(format!(
"{y} is pre-2025: a pre-2025 selection restates a closed year — not an optimization (M7)"
)),
OptimizeError::NoDisposals => {
CliError::Usage("no method-honoring disposals in that year".into())
}
OptimizeError::NoLots => CliError::Usage("no lots available to sell".into()),
OptimizeError::Evaluate(EvaluateError::ProceedsRequired) => CliError::Usage(
"--proceeds <usd> is required for a date with no bundled dataset price \
(--fmv alone cannot resolve proceeds for a future or off-dataset date)"
.into(),
),
OptimizeError::Evaluate(ev) => CliError::Usage(format!("evaluate error: {ev:?}")),
}
}
pub fn consult(
vault: &Path,
pp: &Passphrase,
sell_sat: i64,
wallet: WalletId,
at: TaxDate,
proceeds: Option<Usd>,
kind: DisposeKind,
) -> Result<ConsultReport, CliError> {
let s = Session::open(vault, pp)?;
let (events, _state, cfg) = s.load_events_and_project()?;
let profile = s.tax_profile(at.year())?;
let prices = BundledPrices::load()?;
let tables = BundledTaxTables::load();
let req = ConsultRequest {
sell_sat,
wallet,
at,
proceeds,
kind,
};
consult_sale(&events, &prices, &cfg, profile.as_ref(), &tables, &req).map_err(map_opt_err)
}
#[derive(Debug)]
pub struct AcceptOutcome {
pub persisted: Vec<(EventId, EventId, &'static str)>,
pub skipped: Vec<(EventId, String)>,
}
pub fn accept(
vault: &Path,
pp: &Passphrase,
year: i32,
only: Option<&str>,
attestation: Option<&str>,
now: OffsetDateTime,
) -> Result<AcceptOutcome, CliError> {
accept_with_tables(
vault,
pp,
year,
only,
attestation,
now,
&BundledTaxTables::load(),
)
}
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub fn accept_with_tables(
vault: &Path,
pp: &Passphrase,
year: i32,
only: Option<&str>,
attestation: Option<&str>,
now: OffsetDateTime,
tables: &dyn TaxTables,
) -> Result<AcceptOutcome, CliError> {
let mut session = Session::open(vault, pp)?;
let (events, _state, cfg) = session.load_events_and_project()?;
let profile = session.tax_profile(year)?;
let prices = BundledPrices::load()?;
let attested = session.optimize_attested_set()?;
let made = tax_date(now, UtcOffset::UTC); let only_id = only.map(crate::eventref::parse_event_id).transpose()?;
if attestation.is_some() && only_id.is_none() {
return Err(CliError::Usage(
"--attest must be scoped to ONE disposal via --disposal (no blanket attestation)"
.into(),
));
}
let proposal = optimize_year(
&events,
&prices,
&cfg,
year,
profile.as_ref(),
tables,
&attested,
made,
)
.map_err(map_opt_err)?;
let mut out = AcceptOutcome {
persisted: vec![],
skipped: vec![],
};
for d in &proposal.per_disposal {
if let Some(target) = &only_id {
if &d.disposal != target {
continue;
}
}
if d.proposed_selection == d.current_selection {
out.skipped.push((
d.disposal.clone(),
"already optimal under current identification".into(),
));
continue;
}
match d.persistable {
Persistability::ForbiddenBroker2027 => {
out.skipped.push((
d.disposal.clone(),
"2027+ broker-held: own-books is insufficient; cannot persist \
(FIFO is the defensible position)"
.into(),
));
}
Persistability::ContemporaneousNow => {
let id = persist_selection(&mut session, &d.disposal, &d.proposed_selection, now)?;
out.persisted
.push((d.disposal.clone(), id, "Contemporaneous"));
}
Persistability::NeedsAttestation => {
let Some(att) = attestation else {
out.skipped.push((
d.disposal.clone(),
"already executed — re-run \
`optimize accept --disposal <ref> --attest \"<genuine contemporaneous ID>\"`"
.into(),
));
continue;
};
let id = persist_selection(&mut session, &d.disposal, &d.proposed_selection, now)?;
crate::optimize_attest::set(session.conn(), &d.disposal, att, &made.to_string())?;
out.persisted
.push((d.disposal.clone(), id, "AttestedRecording"));
}
}
}
session.save()?;
Ok(out)
}
fn persist_selection(
session: &mut Session,
disposal: &EventId,
picks: &[LotPick],
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let payload = EventPayload::LotSelection(LotSelection {
disposal_event: disposal.clone(),
lots: picks.to_vec(),
});
Ok(append_decision(
session.conn(),
payload,
now,
UtcOffset::UTC,
None,
)?)
}