use crate::bulk_estimated;
use crate::config::{self, CliConfig};
use crate::donation_details;
use crate::optimize_attest;
use crate::tax_profile;
use crate::CliError;
use btctax_adapters::{BundledPrices, BundledTaxTables};
use btctax_core::conventions::{round_cents, tax_date, TRANSITION_DATE};
use btctax_core::persistence::{init_schema, load_all};
use btctax_core::{project, LedgerEvent, LedgerState, ProjectionConfig};
use btctax_core::{
AllocLot, BlockerKind, DonationDetails, EventId, EventPayload, LotMethod, PendingTransfer, Sat,
TaxDate, TaxProfile, Usd, WalletId,
};
use btctax_store::{Passphrase, Vault};
use rusqlite::Connection;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Frame {
All,
Year(i32),
Range { from: TaxDate, to: TaxDate },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkFilter {
pub frame: Frame,
pub from_wallet: Option<WalletId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkLinkRow {
pub out_event: EventId,
pub date: TaxDate,
pub source_wallet: Option<WalletId>,
pub principal_sat: Sat,
pub usd_value: Option<Usd>,
pub basis_usd: Usd,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkLinkPlan {
pub dest: WalletId,
pub included: Vec<BulkLinkRow>,
pub skipped_same_wallet: Vec<BulkLinkRow>,
pub total_sat: Sat,
pub total_usd_value_floor: Usd,
pub missing_price_count: usize,
pub total_basis_usd: Usd,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkStiFilter {
pub frame: Frame,
pub wallet: Option<WalletId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkStiRow {
pub in_event: EventId,
pub date: TaxDate,
pub wallet: Option<WalletId>,
pub sat: Sat,
pub usd_fmv: Option<Usd>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkStiPlan {
pub included: Vec<BulkStiRow>,
pub total_sat: Sat,
pub total_usd_fmv_floor: Usd,
pub missing_price_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkIncomeFilter {
pub frame: Frame,
pub wallet: Option<WalletId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkIncomeRow {
pub in_event: EventId,
pub date: TaxDate,
pub sat: Sat,
pub fmv: Usd,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkIncomePlan {
pub included: Vec<BulkIncomeRow>,
pub excluded_missing_price: usize,
pub total_sat: Sat,
pub total_income_usd: Usd,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkReclassifyOutflowRow {
pub out_event: EventId,
pub date: TaxDate,
pub wallet: Option<WalletId>,
pub principal_sat: Sat,
pub fmv: Usd,
pub basis_usd: Usd,
pub estimated_gain: Usd,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkReclassifyOutflowPlan {
pub included: Vec<BulkReclassifyOutflowRow>,
pub excluded_missing_price: usize,
pub total_sat: Sat,
pub total_proceeds_usd: Usd,
pub total_basis_usd: Usd,
pub total_estimated_gain: Usd,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkResolveRow {
pub conflict_event: EventId,
pub date: TaxDate,
pub target: EventId,
pub current_payload: EventPayload,
pub new_payload: EventPayload,
pub new_fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkResolvePlan {
pub rows: Vec<BulkResolveRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkVoidRow {
pub target_event_id: EventId,
pub seq: u64,
pub date: TaxDate,
pub payload: EventPayload,
pub disposal_to_clear: Option<EventId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BulkVoidPlan {
pub rows: Vec<BulkVoidRow>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchAction {
Drop,
Relocate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatchProposal {
pub in_event: EventId,
pub out_event: EventId,
pub in_date: TaxDate,
pub out_date: TaxDate,
pub in_wallet: Option<WalletId>,
pub out_wallet: Option<WalletId>,
pub in_sat: Sat,
pub out_principal_sat: Sat,
pub usd_value: Option<Usd>,
pub action: MatchAction,
pub ambiguous: bool,
pub txid_match: bool,
}
pub struct Session {
vault: Vault,
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session").finish_non_exhaustive()
}
}
impl Session {
pub fn create(vault_path: &Path, pp: &Passphrase) -> Result<Session, CliError> {
Self::from_fresh_vault(Vault::create(vault_path, pp)?)
}
pub fn repair(vault_path: &Path, pp: &Passphrase) -> Result<Session, CliError> {
Self::from_fresh_vault(Vault::repair(vault_path, pp)?)
}
fn from_fresh_vault(mut vault: Vault) -> Result<Session, CliError> {
init_schema(vault.conn())?;
config::init_config_table(vault.conn())?;
tax_profile::init_table(vault.conn())?;
optimize_attest::init_table(vault.conn())?;
donation_details::init_table(vault.conn())?;
bulk_estimated::init_table(vault.conn())?;
vault.save()?;
Ok(Session { vault })
}
pub fn open(vault_path: &Path, pp: &Passphrase) -> Result<Session, CliError> {
Ok(Session {
vault: Vault::open(vault_path, pp)?,
})
}
pub fn conn(&self) -> &Connection {
self.vault.conn()
}
pub fn save(&mut self) -> Result<(), CliError> {
self.vault.save()?;
Ok(())
}
pub fn snapshot(&self) -> Result<Vec<u8>, CliError> {
Ok(self.vault.snapshot()?)
}
pub fn restore(&mut self, image: &[u8]) -> Result<(), CliError> {
self.vault.restore(image)?;
Ok(())
}
pub fn vault(&self) -> &Vault {
&self.vault
}
pub fn config(&self) -> Result<CliConfig, CliError> {
config::read_config(self.conn())
}
pub fn tax_profile(&self, year: i32) -> Result<Option<TaxProfile>, CliError> {
tax_profile::get(self.conn(), year)
}
pub fn all_tax_profiles(
&self,
) -> Result<std::collections::BTreeMap<i32, TaxProfile>, CliError> {
tax_profile::all(self.conn())
}
pub fn optimize_attested_set(
&self,
) -> Result<std::collections::BTreeSet<btctax_core::EventId>, CliError> {
optimize_attest::attested_set(self.conn())
}
pub fn donation_details(
&self,
) -> Result<std::collections::BTreeMap<EventId, DonationDetails>, CliError> {
donation_details::all(self.conn())
}
pub fn bulk_estimated(&self) -> Result<std::collections::BTreeMap<EventId, String>, CliError> {
bulk_estimated::all(self.conn())
}
pub fn project(&self) -> Result<(LedgerState, ProjectionConfig), CliError> {
let events = load_all(self.conn())?;
let cfg = self.config()?.to_projection();
let prices = BundledPrices::load()?;
let state = project(&events, &prices, &cfg);
Ok((state, cfg))
}
pub fn load_events_and_project(
&self,
) -> Result<(Vec<LedgerEvent>, LedgerState, ProjectionConfig), CliError> {
let events = load_all(self.conn())?;
let cfg = self.config()?.to_projection();
let prices = BundledPrices::load()?;
let state = project(&events, &prices, &cfg);
Ok((events, state, cfg))
}
pub fn exchange_method_election_rows(
&self,
date: TaxDate,
) -> Result<Vec<(WalletId, LotMethod, bool)>, CliError> {
let events = load_all(self.conn())?;
let cfg = self.config()?.to_projection();
let prices = BundledPrices::load()?;
let wallets: Vec<WalletId> = events
.iter()
.filter_map(|e| e.wallet.clone())
.filter(|w| matches!(w, WalletId::Exchange { .. }))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let methods = btctax_core::in_force_methods(&events, &prices, &cfg, date, &wallets);
Ok(wallets
.into_iter()
.zip(methods)
.map(|(w, m)| (w, m.method, m.scoped))
.collect())
}
pub fn optimize_proposal(
&self,
year: i32,
now: time::OffsetDateTime,
) -> Result<btctax_core::OptimizeProposal, CliError> {
let (events, _state, cfg) = self.load_events_and_project()?;
let profile = self.tax_profile(year)?;
let prices = BundledPrices::load()?;
let tables = BundledTaxTables::load();
let attested = self.optimize_attested_set()?;
let proposal_made = tax_date(now, time::UtcOffset::UTC);
btctax_core::optimize_year(
&events,
&prices,
&cfg,
year,
profile.as_ref(),
&tables,
&attested,
proposal_made,
)
.map_err(crate::cmd::optimize::map_opt_err)
}
pub fn safe_harbor_residue(&self) -> Result<(Vec<AllocLot>, LotMethod), CliError> {
let cfg = self.config()?;
let pre2025_method = cfg.pre2025_method; let proj = cfg.to_projection();
let pre2025: Vec<LedgerEvent> = load_all(self.conn())?
.into_iter()
.filter(|e| match &e.id {
EventId::Import { .. } => {
tax_date(e.utc_timestamp, e.original_tz) < TRANSITION_DATE
}
_ => !matches!(e.payload, EventPayload::SafeHarborAllocation(_)),
})
.collect();
let prices = BundledPrices::load()?;
let residue = project(&pre2025, &prices, &proj);
let lots = residue
.lots
.iter()
.filter(|l| l.remaining_sat > 0)
.map(|l| AllocLot {
wallet: l.wallet.clone(),
sat: l.remaining_sat,
usd_basis: l.usd_basis,
acquired_at: l.acquired_at,
dual_loss_basis: l.dual_loss_basis,
donor_acquired_at: l.donor_acquired_at,
})
.collect();
Ok((lots, pre2025_method))
}
pub fn bulk_link_transfer_plan(
&self,
filter: BulkFilter,
dest: WalletId,
) -> Result<BulkLinkPlan, CliError> {
let (events, state, _cfg) = self.load_events_and_project()?;
let prices = BundledPrices::load()?;
let index: std::collections::HashMap<EventId, &LedgerEvent> =
events.iter().map(|e| (e.id.clone(), e)).collect();
let enrich = |pt: &PendingTransfer| -> BulkLinkRow {
let ev = index.get(&pt.event).copied();
let date = ev
.map(|e| tax_date(e.utc_timestamp, e.original_tz))
.unwrap_or_else(|| {
tax_date(
time::OffsetDateTime::from_unix_timestamp(0).unwrap(),
time::UtcOffset::UTC,
)
});
let source_wallet = ev.and_then(|e| e.wallet.clone());
let usd_value = btctax_core::price::fmv_of(&prices, date, pt.principal_sat);
let basis_usd: Usd = pt.legs.iter().map(|l| l.usd_basis).sum();
BulkLinkRow {
out_event: pt.event.clone(),
date,
source_wallet,
principal_sat: pt.principal_sat,
usd_value,
basis_usd,
}
};
let in_frame = |date: TaxDate| match &filter.frame {
Frame::All => true,
Frame::Year(y) => date.year() == *y,
Frame::Range { from, to } => *from <= date && date <= *to,
};
let mut included: Vec<BulkLinkRow> = Vec::new();
let mut skipped_same_wallet: Vec<BulkLinkRow> = Vec::new();
for pt in &state.pending_reconciliation {
let row = enrich(pt);
if !in_frame(row.date) {
continue;
}
if let Some(w) = &filter.from_wallet {
if row.source_wallet.as_ref() != Some(w) {
continue;
}
}
if row.source_wallet.as_ref() == Some(&dest) {
skipped_same_wallet.push(row);
} else {
included.push(row);
}
}
included.sort_by_key(|r| r.date);
let total_sat: Sat = included.iter().map(|r| r.principal_sat).sum();
let total_usd_value_floor: Usd = included.iter().filter_map(|r| r.usd_value).sum();
let missing_price_count = included.iter().filter(|r| r.usd_value.is_none()).count();
let total_basis_usd: Usd = included.iter().map(|r| r.basis_usd).sum();
Ok(BulkLinkPlan {
dest,
included,
skipped_same_wallet,
total_sat,
total_usd_value_floor,
missing_price_count,
total_basis_usd,
})
}
pub fn bulk_self_transfer_in_plan(
&self,
filter: BulkStiFilter,
) -> Result<BulkStiPlan, CliError> {
let (events, state, _cfg) = self.load_events_and_project()?;
let prices = BundledPrices::load()?;
let index: std::collections::HashMap<EventId, &LedgerEvent> =
events.iter().map(|e| (e.id.clone(), e)).collect();
let voided: std::collections::BTreeSet<EventId> = events
.iter()
.filter_map(|e| match &e.payload {
EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
_ => None,
})
.collect();
let already_classified: std::collections::BTreeSet<EventId> = events
.iter()
.filter(|e| !voided.contains(&e.id))
.filter_map(|e| match &e.payload {
EventPayload::ClassifyInbound(ci) => Some(ci.transfer_in_event.clone()),
_ => None,
})
.collect();
let in_frame = |date: TaxDate| match &filter.frame {
Frame::All => true,
Frame::Year(y) => date.year() == *y,
Frame::Range { from, to } => *from <= date && date <= *to,
};
let mut included: Vec<BulkStiRow> = Vec::new();
for b in &state.blockers {
if b.kind != BlockerKind::UnknownBasisInbound {
continue;
}
let Some(id) = &b.event else { continue };
if already_classified.contains(id) {
continue;
}
let Some(ev) = index.get(id) else { continue };
let EventPayload::TransferIn(ti) = &ev.payload else {
continue;
};
let Some(wallet) = ev.wallet.clone() else {
continue;
};
let date = tax_date(ev.utc_timestamp, ev.original_tz);
if !in_frame(date) {
continue;
}
if let Some(w) = &filter.wallet {
if &wallet != w {
continue;
}
}
let sat = ti.sat;
let usd_fmv = btctax_core::price::fmv_of(&prices, date, sat);
included.push(BulkStiRow {
in_event: id.clone(),
date,
wallet: Some(wallet),
sat,
usd_fmv,
});
}
included.sort_by_key(|r| r.date);
let total_sat: Sat = included.iter().map(|r| r.sat).sum();
let total_usd_fmv_floor: Usd = included.iter().filter_map(|r| r.usd_fmv).sum();
let missing_price_count = included.iter().filter(|r| r.usd_fmv.is_none()).count();
Ok(BulkStiPlan {
included,
total_sat,
total_usd_fmv_floor,
missing_price_count,
})
}
pub fn bulk_classify_income_plan(
&self,
filter: BulkIncomeFilter,
) -> Result<BulkIncomePlan, CliError> {
let (events, state, _cfg) = self.load_events_and_project()?;
let prices = BundledPrices::load()?;
let index: std::collections::HashMap<EventId, &LedgerEvent> =
events.iter().map(|e| (e.id.clone(), e)).collect();
let voided: std::collections::BTreeSet<EventId> = events
.iter()
.filter_map(|e| match &e.payload {
EventPayload::VoidDecisionEvent(v) => Some(v.target_event_id.clone()),
_ => None,
})
.collect();
let already_classified: std::collections::BTreeSet<EventId> = events
.iter()
.filter(|e| !voided.contains(&e.id))
.filter_map(|e| match &e.payload {
EventPayload::ClassifyInbound(ci) => Some(ci.transfer_in_event.clone()),
_ => None,
})
.collect();
let in_frame = |date: TaxDate| match &filter.frame {
Frame::All => true,
Frame::Year(y) => date.year() == *y,
Frame::Range { from, to } => *from <= date && date <= *to,
};
let mut included: Vec<BulkIncomeRow> = Vec::new();
let mut excluded_missing_price = 0usize;
for b in &state.blockers {
if b.kind != BlockerKind::UnknownBasisInbound {
continue;
}
let Some(id) = &b.event else { continue };
if already_classified.contains(id) {
continue;
}
let Some(ev) = index.get(id) else { continue };
let EventPayload::TransferIn(ti) = &ev.payload else {
continue;
};
let Some(wallet) = ev.wallet.clone() else {
continue;
};
let date = tax_date(ev.utc_timestamp, ev.original_tz);
if !in_frame(date) {
continue;
}
if let Some(w) = &filter.wallet {
if &wallet != w {
continue;
}
}
let sat = ti.sat;
match btctax_core::price::fmv_of(&prices, date, sat) {
Some(fmv) => included.push(BulkIncomeRow {
in_event: id.clone(),
date,
sat,
fmv,
}),
None => excluded_missing_price += 1,
}
}
included.sort_by_key(|r| r.date);
let total_sat: Sat = included.iter().map(|r| r.sat).sum();
let total_income_usd: Usd = included.iter().map(|r| r.fmv).sum();
Ok(BulkIncomePlan {
included,
excluded_missing_price,
total_sat,
total_income_usd,
})
}
pub fn bulk_reclassify_outflow_plan(
&self,
filter: BulkFilter,
) -> Result<BulkReclassifyOutflowPlan, CliError> {
let (events, state, _cfg) = self.load_events_and_project()?;
let prices = BundledPrices::load()?;
let index: std::collections::HashMap<EventId, &LedgerEvent> =
events.iter().map(|e| (e.id.clone(), e)).collect();
let in_frame = |date: TaxDate| match &filter.frame {
Frame::All => true,
Frame::Year(y) => date.year() == *y,
Frame::Range { from, to } => *from <= date && date <= *to,
};
let mut included: Vec<BulkReclassifyOutflowRow> = Vec::new();
let mut excluded_missing_price = 0usize;
for pt in &state.pending_reconciliation {
let ev = index.get(&pt.event).copied();
let date = ev
.map(|e| tax_date(e.utc_timestamp, e.original_tz))
.unwrap_or_else(|| {
tax_date(
time::OffsetDateTime::from_unix_timestamp(0).unwrap(),
time::UtcOffset::UTC,
)
});
if !in_frame(date) {
continue;
}
let wallet = ev.and_then(|e| e.wallet.clone());
if let Some(w) = &filter.from_wallet {
if wallet.as_ref() != Some(w) {
continue;
}
}
let fmv = match btctax_core::price::fmv_of(&prices, date, pt.principal_sat) {
Some(v) => v,
None => {
excluded_missing_price += 1;
continue;
}
};
let basis_usd: Usd = pt.legs.iter().map(|l| l.usd_basis).sum();
let estimated_gain = round_cents(fmv - basis_usd);
included.push(BulkReclassifyOutflowRow {
out_event: pt.event.clone(),
date,
wallet,
principal_sat: pt.principal_sat,
fmv,
basis_usd,
estimated_gain,
});
}
included.sort_by_key(|r| r.date);
let total_sat: Sat = included.iter().map(|r| r.principal_sat).sum();
let total_proceeds_usd: Usd = included.iter().map(|r| r.fmv).sum();
let total_basis_usd: Usd = included.iter().map(|r| r.basis_usd).sum();
let total_estimated_gain: Usd = included.iter().map(|r| r.estimated_gain).sum();
Ok(BulkReclassifyOutflowPlan {
included,
excluded_missing_price,
total_sat,
total_proceeds_usd,
total_basis_usd,
total_estimated_gain,
})
}
pub fn bulk_resolve_conflict_plan(&self) -> Result<BulkResolvePlan, CliError> {
let (events, state, _cfg) = self.load_events_and_project()?;
let index: std::collections::HashMap<EventId, &LedgerEvent> =
events.iter().map(|e| (e.id.clone(), e)).collect();
let mut rows: Vec<BulkResolveRow> = Vec::new();
for b in &state.blockers {
if b.kind != BlockerKind::ImportConflict {
continue;
}
let Some(conflict_id) = &b.event else {
continue;
};
let Some(conflict_ev) = index.get(conflict_id) else {
continue;
};
let EventPayload::ImportConflict(c) = &conflict_ev.payload else {
continue;
};
let Some(target_ev) = index.get(&c.target) else {
continue;
};
let date = tax_date(conflict_ev.utc_timestamp, conflict_ev.original_tz);
rows.push(BulkResolveRow {
conflict_event: conflict_id.clone(),
date,
target: c.target.clone(),
current_payload: target_ev.payload.clone(),
new_payload: (*c.new_payload).clone(),
new_fingerprint: c.new_fingerprint.0.chars().take(8).collect::<String>(),
});
}
rows.sort_by_key(|r| r.date);
Ok(BulkResolvePlan { rows })
}
pub fn bulk_void_plan(&self) -> Result<BulkVoidPlan, CliError> {
let (events, state, _cfg) = self.load_events_and_project()?;
let mut rows: Vec<BulkVoidRow> = btctax_core::voidable_decisions(&events, &state.blockers)
.into_iter()
.map(|e| {
let seq = match &e.id {
EventId::Decision { seq } => *seq,
_ => 0,
};
let disposal_to_clear = match &e.payload {
EventPayload::LotSelection(ls) => Some(ls.disposal_event.clone()),
_ => None,
};
BulkVoidRow {
target_event_id: e.id.clone(),
seq,
date: tax_date(e.utc_timestamp, e.original_tz),
payload: e.payload.clone(),
disposal_to_clear,
}
})
.collect();
rows.sort_by_key(|r| r.seq);
Ok(BulkVoidPlan { rows })
}
pub fn self_transfer_match_plan(&self) -> Result<Vec<MatchProposal>, CliError> {
let (events, state, _cfg) = self.load_events_and_project()?;
let prices = BundledPrices::load()?;
let index: std::collections::HashMap<EventId, &LedgerEvent> =
events.iter().map(|e| (e.id.clone(), e)).collect();
struct CandIn {
id: EventId,
sat: Sat,
txid: Option<String>,
date: TaxDate,
wallet: Option<WalletId>,
}
let mut ins: Vec<CandIn> = Vec::new();
for b in &state.blockers {
if b.kind != BlockerKind::UnknownBasisInbound {
continue;
}
let Some(id) = &b.event else { continue };
let Some(ev) = index.get(id) else { continue };
let EventPayload::TransferIn(ti) = &ev.payload else {
continue;
};
ins.push(CandIn {
id: id.clone(),
sat: ti.sat,
txid: ti.txid.clone(),
date: tax_date(ev.utc_timestamp, ev.original_tz),
wallet: ev.wallet.clone(),
});
}
struct CandOut {
id: EventId,
principal: Sat,
fee: Option<Sat>,
txid: Option<String>,
date: TaxDate,
wallet: Option<WalletId>,
}
let mut outs: Vec<CandOut> = Vec::new();
for pt in &state.pending_reconciliation {
let ev = index.get(&pt.event).copied();
let date = ev
.map(|e| tax_date(e.utc_timestamp, e.original_tz))
.unwrap_or_else(|| {
tax_date(
time::OffsetDateTime::from_unix_timestamp(0).unwrap(),
time::UtcOffset::UTC,
)
});
let txid = ev.and_then(|e| match &e.payload {
EventPayload::TransferOut(t) => t.txid.clone(),
_ => None,
});
outs.push(CandOut {
id: pt.event.clone(),
principal: pt.principal_sat,
fee: pt.fee_sat,
txid,
date,
wallet: ev.and_then(|e| e.wallet.clone()),
});
}
let mut passing: Vec<(usize, usize, MatchAction, bool)> = Vec::new();
for (i, ci) in ins.iter().enumerate() {
let Some(in_wallet) = ci.wallet.as_ref() else {
continue;
};
for (j, co) in outs.iter().enumerate() {
let Some(out_wallet) = co.wallet.as_ref() else {
continue;
};
let action = if in_wallet == out_wallet {
MatchAction::Drop
} else {
MatchAction::Relocate
};
let slack = if co.principal > 0 {
(co.principal + 199) / 200
} else {
0
};
let tol = co.fee.unwrap_or(0).max(slack);
let txid_match = ci.txid.is_some() && ci.txid == co.txid;
let amount_ok = txid_match || (ci.sat - co.principal).abs() <= tol;
if !amount_ok {
continue;
}
let window_ok = match action {
MatchAction::Drop => {
let d = (co.date - ci.date).whole_days();
(0..=2).contains(&d)
}
MatchAction::Relocate => {
let d = (ci.date - co.date).whole_days();
(0..=2).contains(&d)
}
};
if !window_ok {
continue;
}
passing.push((i, j, action, txid_match));
}
}
let mut in_count: std::collections::HashMap<usize, usize> =
std::collections::HashMap::new();
let mut out_count: std::collections::HashMap<usize, usize> =
std::collections::HashMap::new();
for (i, j, _, _) in &passing {
*in_count.entry(*i).or_insert(0) += 1;
*out_count.entry(*j).or_insert(0) += 1;
}
let mut proposals: Vec<MatchProposal> = passing
.iter()
.map(|(i, j, action, txid_match)| {
let ci = &ins[*i];
let co = &outs[*j];
let ambiguous = in_count[i] > 1 || out_count[j] > 1;
MatchProposal {
in_event: ci.id.clone(),
out_event: co.id.clone(),
in_date: ci.date,
out_date: co.date,
in_wallet: ci.wallet.clone(),
out_wallet: co.wallet.clone(),
in_sat: ci.sat,
out_principal_sat: co.principal,
usd_value: btctax_core::price::fmv_of(&prices, co.date, co.principal),
action: *action,
ambiguous,
txid_match: *txid_match,
}
})
.collect();
proposals.sort_by(|a, b| {
a.out_date
.cmp(&b.out_date)
.then(a.out_event.cmp(&b.out_event))
.then(a.in_event.cmp(&b.in_event))
});
Ok(proposals)
}
}
#[cfg(test)]
mod tests {
use super::*;
use btctax_store::Passphrase;
fn pp() -> Passphrase {
Passphrase::new("test-pass".into())
}
#[test]
fn create_then_open_round_trips_over_a_temp_vault() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
{
let _s = Session::create(&vault, &pp()).unwrap(); }
let s = Session::open(&vault, &pp()).unwrap();
let (state, _cfg) = s.project().unwrap();
assert!(state.lots.is_empty());
assert!(state.blockers.is_empty());
}
#[test]
fn session_snapshot_restore_reverts_in_memory_mutation() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
let mut s = Session::create(&vault, &pp()).unwrap();
s.conn().execute("CREATE TABLE t (x INTEGER)", []).unwrap();
s.save().unwrap();
let snap = s.snapshot().unwrap();
s.conn().execute("INSERT INTO t VALUES (7)", []).unwrap();
let n: i64 = s
.conn()
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1, "pre-restore: the inserted row is present in memory");
let before = std::fs::read(&vault).unwrap();
s.restore(&snap).unwrap();
let after = std::fs::read(&vault).unwrap();
assert_eq!(before, after, "restore must not write the vault file");
let n: i64 = s
.conn()
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0, "restore must revert the in-memory insert");
}
#[test]
fn wrong_passphrase_is_surfaced_not_a_panic() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
Session::create(&vault, &pp()).unwrap();
let err = Session::open(&vault, &Passphrase::new("nope".into())).unwrap_err();
assert!(matches!(
err,
CliError::Store(btctax_store::StoreError::WrongPassphrase)
));
}
#[test]
fn load_events_and_project_matches_separate_calls() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
Session::create(&vault, &pp()).unwrap();
let s = Session::open(&vault, &pp()).unwrap();
let (events, state, cfg) = s.load_events_and_project().unwrap();
let events2 = btctax_core::persistence::load_all(s.conn()).unwrap();
let (state2, cfg2) = s.project().unwrap();
assert_eq!(events.len(), events2.len(), "event count must match");
assert_eq!(state.lots.len(), state2.lots.len(), "lots count must match");
assert_eq!(
state.blockers.len(),
state2.blockers.len(),
"blocker count must match"
);
assert_eq!(cfg, cfg2, "ProjectionConfig must match");
}
#[test]
fn optimize_proposal_recomputes_a_persistable_proposal_on_held_session() {
use btctax_core::event::{
Acquire, BasisSource, DisposeKind, EventPayload, LedgerEvent, OutflowClass,
ReclassifyOutflow, TransferOut,
};
use btctax_core::identity::{Source, SourceRef};
use btctax_core::persistence::{append_decision, append_import_batch};
use btctax_core::{Carryforward, EventId, FilingStatus, TaxProfile, WalletId};
use rust_decimal_macros::dec;
use time::{OffsetDateTime, UtcOffset};
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("vault.pgp");
Session::create(&vault, &pp()).unwrap();
let mut s = Session::open(&vault, &pp()).unwrap();
let wallet = Some(WalletId::Exchange {
provider: "River".to_string(),
account: "main".to_string(),
});
let lot_a = EventId::import(Source::River, SourceRef::new("op-lot-a"));
let lot_b = EventId::import(Source::River, SourceRef::new("op-lot-b"));
let to_id = EventId::import(Source::River, SourceRef::new("op-sell"));
let ta = OffsetDateTime::from_unix_timestamp(1_739_000_000).unwrap();
let tb = OffsetDateTime::from_unix_timestamp(1_741_000_000).unwrap();
let tc = OffsetDateTime::from_unix_timestamp(1_748_000_000).unwrap();
let td = OffsetDateTime::from_unix_timestamp(1_748_100_000).unwrap();
let batch = vec![
LedgerEvent {
id: lot_a.clone(),
utc_timestamp: ta,
original_tz: UtcOffset::UTC,
wallet: wallet.clone(),
payload: EventPayload::Acquire(Acquire {
sat: 1_000_000,
usd_cost: dec!(30000),
fee_usd: dec!(0),
basis_source: BasisSource::ExchangeProvided,
}),
},
LedgerEvent {
id: lot_b.clone(),
utc_timestamp: tb,
original_tz: UtcOffset::UTC,
wallet: wallet.clone(),
payload: EventPayload::Acquire(Acquire {
sat: 1_000_000,
usd_cost: dec!(50000),
fee_usd: dec!(0),
basis_source: BasisSource::ExchangeProvided,
}),
},
LedgerEvent {
id: to_id.clone(),
utc_timestamp: tc,
original_tz: UtcOffset::UTC,
wallet: wallet.clone(),
payload: EventPayload::TransferOut(TransferOut {
sat: 500_000,
fee_sat: None,
dest_addr: None,
txid: None,
}),
},
];
append_import_batch(s.conn(), &batch).unwrap();
let ro = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
transfer_out_event: to_id.clone(),
as_: OutflowClass::Dispose {
kind: DisposeKind::Sell,
},
principal_proceeds_or_fmv: dec!(30000),
fee_usd: None,
donee: None,
});
append_decision(s.conn(), ro, td, UtcOffset::UTC, None).unwrap();
let profile = TaxProfile {
filing_status: FilingStatus::Single,
ordinary_taxable_income: dec!(100000),
magi_excluding_crypto: dec!(100000),
qualified_dividends_and_other_pref_income: dec!(0),
other_net_capital_gain: dec!(0),
capital_loss_carryforward_in: Carryforward::default(),
w2_ss_wages: dec!(0),
w2_medicare_wages: dec!(0),
schedule_c_expenses: dec!(0),
};
crate::tax_profile::set(s.conn(), 2025, &profile).unwrap();
s.save().unwrap();
let now = OffsetDateTime::from_unix_timestamp(1_752_000_000).unwrap();
let proposal = s.optimize_proposal(2025, now).unwrap();
assert!(
proposal.delta <= dec!(0),
"delta must be ≤ 0 (baseline-seeded)"
);
let row = proposal
.per_disposal
.iter()
.find(|d| d.disposal == to_id)
.expect("the 2025 sale must be in the proposal");
assert_ne!(
row.proposed_selection, row.current_selection,
"the optimizer must propose the dearer lot (a change from FIFO)"
);
}
}