use crate::{
BulkFilter, BulkLinkPlan, BulkReclassifyOutflowPlan, BulkResolvePlan, BulkStiFilter,
BulkStiPlan, BulkVoidPlan, CliError, MatchProposal, Session,
};
use btctax_core::conventions::{tax_date, TRANSITION_DATE};
use btctax_core::persistence::{append_decision, load_all};
use btctax_core::{
AllocMethod, BlockerKind, ClassifyInbound, ClassifyRaw, DisposeKind, DonationDetails, EventId,
EventPayload, InboundClass, IncomeKind, LedgerEvent, LotId, LotMethod, LotPick, LotSelection,
ManualFmv, MethodElection, OutflowClass, ReclassifyIncome, ReclassifyOutflow, RejectImport,
RemovalKind, SafeHarborAllocation, SelfTransferPassthrough, SupersedeImport, TaxDate,
TransferLink, TransferTarget, Usd, VoidDecisionEvent, WalletId,
};
use btctax_store::Passphrase;
use std::path::Path;
use time::{OffsetDateTime, UtcOffset};
use crate::eventref::parse_event_id;
fn append_and_save(
session: &mut Session,
payload: EventPayload,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let id = append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
session.save()?;
Ok(id)
}
fn guard_decision_conflict(
session: &Session,
payload: &EventPayload,
now: OffsetDateTime,
) -> Result<(), CliError> {
let events = load_all(session.conn())?;
let cfg = session.config()?.to_projection();
if let Some(detail) = btctax_core::would_conflict(&events, session.prices(), &cfg, payload, now)
{
return Err(CliError::Usage(format!(
"cannot record this decision — {detail}"
)));
}
Ok(())
}
pub fn classify_inbound(
vault_path: &Path,
pp: &Passphrase,
in_ref: &str,
class: InboundClass,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let transfer_in_event = parse_event_id(in_ref)?;
let mut session = Session::open(vault_path, pp)?;
let acquired_override: Option<(&str, TaxDate)> = match &class {
InboundClass::SelfTransferMine { acquired_at, .. } => {
acquired_at.map(|d| ("--acquired", d))
}
InboundClass::GiftReceived {
donor_acquired_at, ..
} => donor_acquired_at.map(|d| ("--donor-acquired", d)),
InboundClass::Income { .. } => None,
};
if let Some((flag, acquired)) = acquired_override {
let events = load_all(session.conn())?;
if let Some(ev) = events.iter().find(|e| e.id == transfer_in_event) {
if let EventPayload::TransferIn(_) = &ev.payload {
let receipt = tax_date(ev.utc_timestamp, ev.original_tz);
if acquired > receipt {
return Err(CliError::Usage(format!(
"{flag} {acquired} is after the receipt date {receipt} (recorded in {tz}); \
coins cannot be acquired after they are received. Same-day is allowed.",
tz = tz_label(ev.original_tz),
)));
}
}
}
}
let payload = EventPayload::ClassifyInbound(ClassifyInbound {
transfer_in_event,
as_: class,
});
guard_decision_conflict(&session, &payload, now)?;
append_and_save(&mut session, payload, now)
}
fn tz_label(tz: UtcOffset) -> String {
let (h, m, _s) = tz.as_hms();
if h == 0 && m == 0 {
"UTC".to_string()
} else {
let sign = if h < 0 || m < 0 { '-' } else { '+' };
format!("UTC{sign}{:02}:{:02}", h.unsigned_abs(), m.unsigned_abs())
}
}
#[allow(clippy::too_many_arguments)]
pub fn reclassify_outflow(
vault_path: &Path,
pp: &Passphrase,
out_ref: &str,
class: OutflowClass,
principal: Usd,
fee_usd: Option<Usd>,
donee: Option<String>,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let transfer_out_event = parse_event_id(out_ref)?;
let mut session = Session::open(vault_path, pp)?;
let payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
transfer_out_event: transfer_out_event.clone(),
as_: class,
principal_proceeds_or_fmv: principal,
fee_usd,
donee,
});
guard_decision_conflict(&session, &payload, now)?;
{
let events = load_all(session.conn())?;
if let Some(ev) = events.iter().find(|e| e.id == transfer_out_event) {
if let EventPayload::TransferOut(out) = &ev.payload {
let date = tax_date(ev.utc_timestamp, ev.original_tz);
let market_value = btctax_core::price::fmv_of(session.prices(), date, out.sat);
let btc = Usd::from(out.sat) / Usd::from(100_000_000);
if let Some(line) = amount_fmv_advisory(principal, market_value, btc, date) {
eprintln!("{line}");
}
}
}
}
append_and_save(&mut session, payload, now)
}
fn amount_fmv_advisory(
amount: Usd,
market_value: Option<Usd>,
btc: Usd,
date: TaxDate,
) -> Option<String> {
match market_value {
Some(mv) if mv > Usd::ZERO && amount > mv * Usd::from(100) => Some(format!(
"warning: --amount ${amount} is more than 100x the ${mv} market value of {btc} BTC at \
the {date} close — did you enter the sats amount as dollars? --amount is the USD \
proceeds/FMV; recording it as entered (not fatal)."
)),
Some(_) => None, None => Some(format!(
"note: no BTC price for {date}; skipping the --amount FMV sanity check."
)),
}
}
pub fn set_fmv(
vault_path: &Path,
pp: &Passphrase,
event_ref: &str,
usd_fmv: Usd,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let event = parse_event_id(event_ref)?;
let mut session = Session::open(vault_path, pp)?;
let payload = EventPayload::ManualFmv(ManualFmv { event, usd_fmv });
guard_decision_conflict(&session, &payload, now)?;
append_and_save(&mut session, payload, now)
}
fn promote_void_advisory_lines(
session: &Session,
events: &[LedgerEvent],
target_event_id: &EventId,
now: OffsetDateTime,
) -> Vec<String> {
let promote_id = events
.iter()
.find(|e| e.id == *target_event_id && matches!(e.payload, EventPayload::PromoteTranche(_)))
.map(|e| e.id.clone());
let Some(pid) = promote_id else {
return Vec::new();
};
let Ok(cfg) = session.config().map(|c| c.to_projection()) else {
return Vec::new();
};
let tables = btctax_adapters::BundledTaxTables::load();
let current = tax_date(now, UtcOffset::UTC).year();
btctax_core::conservative::promote_prior_year_advisory(
events,
session.prices(),
&cfg,
&pid,
btctax_core::conservative::Direction::Void,
None,
&tables,
current,
)
}
pub fn void(
vault_path: &Path,
pp: &Passphrase,
target_ref: &str,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let target_event_id = parse_event_id(target_ref)?;
let mut session = Session::open(vault_path, pp)?;
let events = load_all(session.conn())?;
let disposal_to_clear: Option<EventId> = events
.iter()
.find(|e| e.id == target_event_id)
.and_then(|e| match &e.payload {
EventPayload::LotSelection(ls) => Some(ls.disposal_event.clone()),
_ => None,
});
let reclass_out_to_clear: Option<EventId> = events
.iter()
.find(|e| e.id == target_event_id)
.and_then(|e| match &e.payload {
EventPayload::ReclassifyOutflow(ro) => Some(ro.transfer_out_event.clone()),
_ => None,
});
guard_decision_conflict(
&session,
&EventPayload::VoidDecisionEvent(VoidDecisionEvent {
target_event_id: target_event_id.clone(),
}),
now,
)?;
if events.iter().any(|e| {
matches!(&e.payload,
EventPayload::VoidDecisionEvent(v) if v.target_event_id == target_event_id)
}) {
return Err(CliError::Usage(format!(
"cannot record this decision — {} is already voided — see `btctax events list` for event \
refs + decision status",
target_event_id.canonical()
)));
}
for line in promote_void_advisory_lines(&session, &events, &target_event_id, now) {
println!("{line}");
}
let id = append_decision(
session.conn(),
EventPayload::VoidDecisionEvent(VoidDecisionEvent { target_event_id }),
now,
UtcOffset::UTC,
None,
)?;
if let Some(disposal) = disposal_to_clear {
crate::optimize_attest::clear(session.conn(), &disposal)?;
}
if let Some(out_event) = reclass_out_to_clear {
crate::bulk_estimated::clear(session.conn(), &out_event)?;
}
session.save()?;
Ok(id)
}
pub fn pseudo_set_mode(vault_path: &Path, pp: &Passphrase, on: bool) -> Result<bool, CliError> {
let mut session = Session::open(vault_path, pp)?;
crate::config::set_pseudo_reconcile(session.conn(), on)?;
session.save()?;
Ok(on)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PseudoApproveRow {
pub target: EventId,
pub kind: btctax_core::PseudoKind,
pub wallet: Option<WalletId>,
pub year: Option<i32>,
}
#[derive(Debug, Clone, Default)]
pub struct PseudoApproveFilter {
pub kind: Option<btctax_core::PseudoKind>,
pub wallet: Option<WalletId>,
pub year: Option<i32>,
}
fn filtered_pseudo_plan<'a>(
plan: &'a [btctax_core::PseudoDefault],
index: &std::collections::HashMap<&EventId, &btctax_core::LedgerEvent>,
filter: &PseudoApproveFilter,
) -> Vec<(
&'a btctax_core::PseudoDefault,
Option<WalletId>,
Option<i32>,
)> {
plan.iter()
.filter_map(|pd| {
let ev = index.get(&pd.target);
let wallet = ev.and_then(|e| e.wallet.clone());
let year = ev.map(|e| tax_date(e.utc_timestamp, e.original_tz).year());
if let Some(k) = filter.kind {
if pd.kind != k {
return None;
}
}
if let Some(want) = &filter.wallet {
if wallet.as_ref() != Some(want) {
return None;
}
}
if let Some(y) = filter.year {
if year != Some(y) {
return None;
}
}
Some((pd, wallet, year))
})
.collect()
}
pub fn pseudo_approve_plan(
vault_path: &Path,
pp: &Passphrase,
filter: PseudoApproveFilter,
) -> Result<Vec<PseudoApproveRow>, CliError> {
let session = Session::open(vault_path, pp)?;
let prices = session.prices();
let events = load_all(session.conn())?;
let cfg = session.config()?.to_projection();
let plan = btctax_core::pseudo_plan(&events, prices, &cfg);
let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
events.iter().map(|e| (&e.id, e)).collect();
Ok(filtered_pseudo_plan(&plan, &index, &filter)
.into_iter()
.map(|(pd, wallet, year)| PseudoApproveRow {
target: pd.target.clone(),
kind: pd.kind,
wallet,
year,
})
.collect())
}
pub fn apply_bulk_pseudo_approve(
vault_path: &Path,
pp: &Passphrase,
filter: PseudoApproveFilter,
now: OffsetDateTime,
) -> Result<usize, CliError> {
let mut session = Session::open(vault_path, pp)?;
let prices = session.prices();
let events = load_all(session.conn())?;
let cfg = session.config()?.to_projection();
let plan = btctax_core::pseudo_plan(&events, prices, &cfg);
let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
events.iter().map(|e| (&e.id, e)).collect();
let selected = filtered_pseudo_plan(&plan, &index, &filter);
if selected.is_empty() {
return Ok(0);
}
let mut n = 0usize;
for (pd, _wallet, _year) in &selected {
append_decision(
session.conn(),
pd.decision.clone(),
now,
UtcOffset::UTC,
None,
)?;
n += 1;
}
session.save()?;
Ok(n)
}
pub fn classify_raw(
vault_path: &Path,
pp: &Passphrase,
target_ref: &str,
payload_json: &str,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let target = parse_event_id(target_ref)?;
let as_: EventPayload = serde_json::from_str(payload_json)
.map_err(|e| CliError::Usage(format!("bad --payload-json: {e}")))?;
if !as_.is_imported() {
return Err(CliError::Usage(
"classify-raw payload must be an imported variant (Acquire/Income/Dispose/TransferOut/TransferIn/Unclassified)".into(),
));
}
let mut session = Session::open(vault_path, pp)?;
let payload = EventPayload::ClassifyRaw(ClassifyRaw {
target,
as_: Box::new(as_),
});
guard_decision_conflict(&session, &payload, now)?;
append_and_save(&mut session, payload, now)
}
pub fn accept_conflict(
vault_path: &Path,
pp: &Passphrase,
conflict_ref: &str,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let conflict_event = parse_event_id(conflict_ref)?;
let mut session = Session::open(vault_path, pp)?;
append_and_save(
&mut session,
EventPayload::SupersedeImport(SupersedeImport { conflict_event }),
now,
)
}
pub fn reject_conflict(
vault_path: &Path,
pp: &Passphrase,
conflict_ref: &str,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let conflict_event = parse_event_id(conflict_ref)?;
let mut session = Session::open(vault_path, pp)?;
append_and_save(
&mut session,
EventPayload::RejectImport(RejectImport { conflict_event }),
now,
)
}
pub fn bulk_link_plan(
vault_path: &Path,
pp: &Passphrase,
filter: BulkFilter,
dest: WalletId,
) -> Result<BulkLinkPlan, CliError> {
let session = Session::open(vault_path, pp)?;
session.bulk_link_transfer_plan(filter, dest)
}
pub fn apply_bulk_link_transfer(
vault_path: &Path,
pp: &Passphrase,
out_events: Vec<EventId>,
dest: WalletId,
now: OffsetDateTime,
) -> Result<usize, CliError> {
let mut session = Session::open(vault_path, pp)?;
for out_event in &out_events {
let payload = EventPayload::TransferLink(TransferLink {
out_event: out_event.clone(),
in_event_or_wallet: TransferTarget::Wallet(dest.clone()),
});
append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
}
session.save()?;
Ok(out_events.len())
}
pub fn bulk_self_transfer_in_plan(
vault_path: &Path,
pp: &Passphrase,
filter: BulkStiFilter,
) -> Result<BulkStiPlan, CliError> {
let session = Session::open(vault_path, pp)?;
session.bulk_self_transfer_in_plan(filter)
}
pub fn apply_bulk_self_transfer_in(
vault_path: &Path,
pp: &Passphrase,
in_events: Vec<EventId>,
now: OffsetDateTime,
) -> Result<usize, CliError> {
let mut session = Session::open(vault_path, pp)?;
for in_event in &in_events {
let payload = EventPayload::ClassifyInbound(ClassifyInbound {
transfer_in_event: in_event.clone(),
as_: InboundClass::SelfTransferMine {
basis: None,
acquired_at: None,
},
});
append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
}
session.save()?;
Ok(in_events.len())
}
pub fn bulk_classify_income_plan(
vault_path: &Path,
pp: &Passphrase,
filter: crate::BulkIncomeFilter,
) -> Result<crate::BulkIncomePlan, CliError> {
let session = Session::open(vault_path, pp)?;
session.bulk_classify_income_plan(filter)
}
pub fn apply_bulk_classify_inbound_income(
vault_path: &Path,
pp: &Passphrase,
in_events: Vec<EventId>,
kind: IncomeKind,
business: bool,
now: OffsetDateTime,
) -> Result<usize, CliError> {
let mut session = Session::open(vault_path, pp)?;
let prices = session.prices();
let events = load_all(session.conn())?;
let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
events.iter().map(|e| (&e.id, e)).collect();
let mut n = 0usize;
for in_event in &in_events {
let Some(ev) = index.get(in_event) else {
continue;
};
let EventPayload::TransferIn(ti) = &ev.payload else {
continue;
};
let date = btctax_core::conventions::tax_date(ev.utc_timestamp, ev.original_tz);
let Some(fmv) = btctax_core::price::fmv_of(prices, date, ti.sat) else {
continue;
};
let payload = EventPayload::ClassifyInbound(ClassifyInbound {
transfer_in_event: in_event.clone(),
as_: InboundClass::Income {
kind,
fmv: Some(fmv),
business,
},
});
append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
n += 1;
}
session.save()?;
Ok(n)
}
pub fn bulk_reclassify_outflow_plan(
vault_path: &Path,
pp: &Passphrase,
filter: BulkFilter,
) -> Result<BulkReclassifyOutflowPlan, CliError> {
let session = Session::open(vault_path, pp)?;
session.bulk_reclassify_outflow_plan(filter)
}
pub fn apply_bulk_reclassify_outflow(
vault_path: &Path,
pp: &Passphrase,
out_events: Vec<EventId>,
kind: DisposeKind,
now: OffsetDateTime,
) -> Result<usize, CliError> {
let mut session = Session::open(vault_path, pp)?;
let prices = session.prices();
let events = load_all(session.conn())?;
let index: std::collections::HashMap<&EventId, &btctax_core::LedgerEvent> =
events.iter().map(|e| (&e.id, e)).collect();
let marked_at = tax_date(now, UtcOffset::UTC).to_string();
let mut n = 0usize;
for out_event in &out_events {
let Some(ev) = index.get(out_event) else {
continue;
};
let EventPayload::TransferOut(t) = &ev.payload else {
continue;
};
let date = tax_date(ev.utc_timestamp, ev.original_tz);
let Some(fmv) = btctax_core::price::fmv_of(prices, date, t.sat) else {
continue;
};
let payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
transfer_out_event: out_event.clone(),
as_: OutflowClass::Dispose { kind },
principal_proceeds_or_fmv: fmv,
fee_usd: None,
donee: None,
});
append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
crate::bulk_estimated::mark(session.conn(), out_event, &marked_at)?;
n += 1;
}
session.save()?;
Ok(n)
}
pub fn bulk_resolve_conflict_plan(
vault_path: &Path,
pp: &Passphrase,
) -> Result<BulkResolvePlan, CliError> {
let session = Session::open(vault_path, pp)?;
session.bulk_resolve_conflict_plan()
}
pub fn apply_bulk_accept_conflicts(
vault_path: &Path,
pp: &Passphrase,
conflict_events: Vec<EventId>,
now: OffsetDateTime,
) -> Result<usize, CliError> {
let mut session = Session::open(vault_path, pp)?;
for conflict_event in &conflict_events {
let payload = EventPayload::SupersedeImport(SupersedeImport {
conflict_event: conflict_event.clone(),
});
append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
}
session.save()?;
Ok(conflict_events.len())
}
pub fn apply_bulk_reject_conflicts(
vault_path: &Path,
pp: &Passphrase,
conflict_events: Vec<EventId>,
now: OffsetDateTime,
) -> Result<usize, CliError> {
let mut session = Session::open(vault_path, pp)?;
for conflict_event in &conflict_events {
let payload = EventPayload::RejectImport(RejectImport {
conflict_event: conflict_event.clone(),
});
append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
}
session.save()?;
Ok(conflict_events.len())
}
pub fn bulk_void_plan(vault_path: &Path, pp: &Passphrase) -> Result<BulkVoidPlan, CliError> {
let session = Session::open(vault_path, pp)?;
session.bulk_void_plan()
}
pub fn apply_bulk_void(
vault_path: &Path,
pp: &Passphrase,
targets: Vec<(EventId, Option<EventId>)>,
now: OffsetDateTime,
) -> Result<usize, CliError> {
let mut session = Session::open(vault_path, pp)?;
let events = load_all(session.conn())?;
let reclass_map: std::collections::HashMap<EventId, EventId> = events
.iter()
.filter_map(|e| match &e.payload {
EventPayload::ReclassifyOutflow(ro) => {
Some((e.id.clone(), ro.transfer_out_event.clone()))
}
_ => None,
})
.collect();
for (target_event_id, _) in &targets {
for line in promote_void_advisory_lines(&session, &events, target_event_id, now) {
println!("{line}");
}
}
for (target_event_id, disposal_to_clear) in &targets {
append_decision(
session.conn(),
EventPayload::VoidDecisionEvent(VoidDecisionEvent {
target_event_id: target_event_id.clone(),
}),
now,
UtcOffset::UTC,
None,
)?;
if let Some(disposal) = disposal_to_clear {
crate::optimize_attest::clear(session.conn(), disposal)?;
}
if let Some(out_event) = reclass_map.get(target_event_id) {
crate::bulk_estimated::clear(session.conn(), out_event)?;
}
}
session.save()?;
Ok(targets.len())
}
pub fn link_transfer(
vault_path: &Path,
pp: &Passphrase,
out_ref: &str,
target: TransferTarget,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let out_event = parse_event_id(out_ref)?;
let mut session = Session::open(vault_path, pp)?;
let payload = EventPayload::TransferLink(TransferLink {
out_event,
in_event_or_wallet: target,
});
append_and_save(&mut session, payload, now)
}
pub fn self_transfer_match_plan(
vault_path: &Path,
pp: &Passphrase,
) -> Result<Vec<MatchProposal>, CliError> {
let session = Session::open(vault_path, pp)?;
session.self_transfer_match_plan()
}
pub fn apply_self_transfer_passthrough(
vault_path: &Path,
pp: &Passphrase,
in_ref: &str,
out_ref: &str,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let in_event = parse_event_id(in_ref)?;
let out_event = parse_event_id(out_ref)?;
let mut session = Session::open(vault_path, pp)?;
let payload = EventPayload::SelfTransferPassthrough(SelfTransferPassthrough {
in_event,
out_event,
});
append_and_save(&mut session, payload, now)
}
pub fn safe_harbor_allocate(
vault_path: &Path,
pp: &Passphrase,
method: AllocMethod,
attested: bool,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let mut session = Session::open(vault_path, pp)?;
crate::cmd::tranche::guard_allocation_vs_tranche(&load_all(session.conn())?)?;
{
let cli_cfg = session.config()?;
if !cli_cfg.pre2025_method_attested {
let m = match cli_cfg.pre2025_method {
LotMethod::Fifo => "fifo",
LotMethod::Lifo => "lifo",
LotMethod::Hifo => "hifo",
};
return Err(CliError::Usage(format!(
"refusing to record a safe-harbor allocation under an UNDECLARED pre-2025 method \
({m}); a safe-harbor allocation permanently records the method used to reconstruct \
your pre-2025 basis. Declare your filed method first: \
config --set-pre2025-method {m} --attest-pre2025-method"
)));
}
}
let (lots, pre2025_method) = session.safe_harbor_residue()?;
if lots.is_empty() {
return Err(CliError::Usage(
"no pre-2025 lots to allocate (Path A applies; safe harbor unnecessary)".into(),
));
}
let payload = EventPayload::SafeHarborAllocation(SafeHarborAllocation {
lots,
as_of_date: TRANSITION_DATE,
method,
timely_allocation_attested: attested,
pre2025_method,
});
append_and_save(&mut session, payload, now)
}
pub fn select_lots(
vault_path: &Path,
pp: &Passphrase,
disposal_ref: &str,
picks: Vec<LotPick>,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let disposal_event = parse_event_id(disposal_ref)?;
if picks.is_empty() {
return Err(CliError::Usage(
"select-lots needs at least one --from <lot-id>:<sat>".into(),
));
}
let mut session = Session::open(vault_path, pp)?;
append_and_save(
&mut session,
EventPayload::LotSelection(LotSelection {
disposal_event,
lots: picks,
}),
now,
)
}
pub fn import_selections(
vault_path: &Path,
pp: &Passphrase,
csv_path: &Path,
now: OffsetDateTime,
) -> Result<Vec<EventId>, CliError> {
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_path(csv_path)?;
{
let hdr = rdr.headers()?;
let cols: Vec<&str> = hdr.iter().collect();
if cols != ["disposal_ref", "origin_event_id", "split_sequence", "sat"] {
return Err(CliError::Usage(format!(
"import-selections CSV header must be \
disposal_ref,origin_event_id,split_sequence,sat; got {cols:?}"
)));
}
}
let mut by_disposal: std::collections::BTreeMap<String, Vec<LotPick>> =
std::collections::BTreeMap::new();
for rec in rdr.records() {
let rec = rec?;
let disposal_ref = rec
.get(0)
.ok_or_else(|| CliError::Usage("missing disposal_ref".into()))?
.to_string();
let origin_str = rec
.get(1)
.ok_or_else(|| CliError::Usage("missing origin_event_id".into()))?;
let split_str = rec
.get(2)
.ok_or_else(|| CliError::Usage("missing split_sequence".into()))?;
let sat_str = rec
.get(3)
.ok_or_else(|| CliError::Usage("missing sat".into()))?;
let origin_event_id = parse_event_id(origin_str)?;
let split_sequence = split_str
.trim()
.parse::<u32>()
.map_err(|e| CliError::Usage(format!("bad split_sequence {split_str:?}: {e}")))?;
let sat = sat_str
.trim()
.parse::<i64>()
.map_err(|e| CliError::Usage(format!("bad sat {sat_str:?}: {e}")))?;
by_disposal.entry(disposal_ref).or_default().push(LotPick {
lot: LotId {
origin_event_id,
split_sequence,
},
sat,
});
}
let mut session = Session::open(vault_path, pp)?;
let mut ids = Vec::new();
for (disposal_ref, lots) in by_disposal {
let disposal_event = parse_event_id(&disposal_ref)?;
let id = append_decision(
session.conn(),
EventPayload::LotSelection(LotSelection {
disposal_event,
lots,
}),
now,
UtcOffset::UTC,
None,
)?;
ids.push(id);
}
session.save()?;
Ok(ids)
}
pub fn set_forward_method(
vault_path: &Path,
pp: &Passphrase,
m: LotMethod,
wallet: Option<WalletId>,
effective_from: Option<TaxDate>,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let effective_from = effective_from.unwrap_or_else(|| now.to_offset(UtcOffset::UTC).date());
let mut session = Session::open(vault_path, pp)?;
if let Some(w) = &wallet {
let WalletId::Exchange { .. } = w else {
return Err(CliError::Usage(format!(
"--exchange must name an exchange account (exchange:PROVIDER:ACCOUNT); \
{w:?} is not electable — a method election is a brokerage-account concept"
)));
};
let events = load_all(session.conn())?;
let known: std::collections::BTreeSet<WalletId> = events
.iter()
.filter_map(|e| e.wallet.clone())
.filter(|w| matches!(w, WalletId::Exchange { .. }))
.collect();
if !known.contains(w) {
let names: Vec<String> = known
.iter()
.map(|k| match k {
WalletId::Exchange { provider, account } => {
format!("exchange:{provider}:{account}")
}
other => format!("{other:?}"),
})
.collect();
return Err(CliError::Usage(format!(
"--exchange {w:?} is not a known exchange account in this vault \
(accounts are created by importing events). Known exchange accounts: [{}]",
names.join(", ")
)));
}
}
append_and_save(
&mut session,
EventPayload::MethodElection(MethodElection {
effective_from,
method: m,
wallet,
}),
now,
)
}
pub fn safe_harbor_attest(
vault_path: &Path,
pp: &Passphrase,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let mut session = Session::open(vault_path, pp)?;
let (events, state, _cfg) = session.load_events_and_project()?;
crate::cmd::tranche::guard_allocation_vs_tranche(&events)?;
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 allocs: Vec<(&EventId, &SafeHarborAllocation)> = events
.iter()
.filter(|e| !voided.contains(&e.id))
.filter_map(|e| match &e.payload {
EventPayload::SafeHarborAllocation(a) => Some((&e.id, a)),
_ => None,
})
.collect();
let (prior_id, prior) = match allocs.as_slice() {
[one] => (one.0.clone(), one.1.clone()),
[] => {
return Err(CliError::Usage(
"no allocation to attest; run `safe-harbor allocate` first".into(),
))
}
_ => {
return Err(CliError::Usage(
"multiple live allocations present; void the stale one before attesting".into(),
))
}
};
if prior.timely_allocation_attested {
return Err(CliError::Usage("allocation is already attested".into()));
}
let blocked_with = |k: BlockerKind| {
state
.blockers
.iter()
.any(|b| b.event.as_ref() == Some(&prior_id) && b.kind == k)
};
let unconservable = blocked_with(BlockerKind::SafeHarborUnconservable);
let timebarred = blocked_with(BlockerKind::SafeHarborTimebar);
if unconservable {
return Err(CliError::Usage(
"allocation fails conservation (not a time-bar); re-run `safe-harbor allocate` to rebuild it — attestation cannot cure conservation".into(),
));
}
if !timebarred {
return Err(CliError::Usage(
"allocation already effective; no attestation needed — run `verify`".into(),
));
}
append_decision(
session.conn(),
EventPayload::VoidDecisionEvent(VoidDecisionEvent {
target_event_id: prior_id,
}),
now,
UtcOffset::UTC,
None,
)?;
let attested = SafeHarborAllocation {
timely_allocation_attested: true,
..prior
};
let id = append_decision(
session.conn(),
EventPayload::SafeHarborAllocation(attested),
now,
UtcOffset::UTC,
None,
)?;
session.save()?;
Ok(id)
}
pub fn reclassify_income(
vault_path: &Path,
pp: &Passphrase,
income_ref: &str,
business: bool,
kind: Option<IncomeKind>,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let income_event = parse_event_id(income_ref)?;
let mut session = Session::open(vault_path, pp)?;
let payload = EventPayload::ReclassifyIncome(ReclassifyIncome {
income_event,
business,
kind,
});
guard_decision_conflict(&session, &payload, now)?;
append_and_save(&mut session, payload, now)
}
pub fn set_donation_details(
vault_path: &Path,
pp: &Passphrase,
event_ref: &str,
details: DonationDetails,
) -> Result<(), CliError> {
let event_id = parse_event_id(event_ref)?;
let mut session = Session::open(vault_path, pp)?;
let (state, _cfg) = session.project()?;
let matched_removal = state.removals.iter().find(|r| r.event == event_id);
match matched_removal {
None => {
return Err(CliError::Usage(format!(
"not a donation / not found: {event_ref:?} does not match any removal in the \
projected ledger. If this is an outflow you intend to donate, first classify it: \
`reconcile reclassify-outflow <out-ref> --as-kind donate --amount <usd> …` — once \
classified, its ref appears in removals.csv (the 'event' column)."
)));
}
Some(r) if r.kind != RemovalKind::Donation => {
return Err(CliError::Usage(format!(
"not a donation: {event_ref:?} is a {:?} removal, not a Donation",
r.kind
)));
}
Some(_) => {} }
crate::donation_details::set(session.conn(), &event_id, &details)?;
session.save()?;
Ok(())
}
pub fn show_donation_details(
vault_path: &Path,
pp: &Passphrase,
event_ref: &str,
) -> Result<Option<DonationDetails>, CliError> {
let event_id = parse_event_id(event_ref)?;
let session = Session::open(vault_path, pp)?;
crate::donation_details::get(session.conn(), &event_id)
}
#[cfg(test)]
mod tests {
use super::*;
use btctax_core::event::{Acquire, BasisSource, TransferOut};
use btctax_core::persistence::append_import_batch;
use btctax_core::{
EventId, LedgerEvent, OutflowClass, ReclassifyOutflow, Source, SourceRef, WalletId,
};
use btctax_store::Passphrase;
use rust_decimal_macros::dec;
use time::macros::date;
fn pp() -> Passphrase {
Passphrase::new("test-pass".into())
}
#[test]
fn amount_fmv_advisory_warns_on_sats_as_dollars() {
let line = amount_fmv_advisory(
dec!(5000000),
Some(dec!(3044.48)),
dec!(0.05),
date!(2024 - 07 - 03),
)
.expect("a sats-as-dollars amount must warn");
assert!(
line.contains("warning")
&& line.contains("--amount")
&& line.to_lowercase().contains("sats"),
"warning must name --amount + the sats mistake: {line}"
);
}
#[test]
fn amount_fmv_advisory_silent_on_plausible_amount() {
assert!(amount_fmv_advisory(
dec!(3044.48),
Some(dec!(3044.48)),
dec!(0.05),
date!(2024 - 07 - 03)
)
.is_none());
assert!(amount_fmv_advisory(
dec!(9000),
Some(dec!(3044.48)),
dec!(0.05),
date!(2024 - 07 - 03)
)
.is_none());
}
#[test]
fn amount_fmv_advisory_boundary_is_strict_100x() {
let mv = dec!(3000);
assert!(
amount_fmv_advisory(dec!(300000), Some(mv), dec!(0.05), date!(2024 - 07 - 03))
.is_none(),
"exactly 100x must not warn"
);
assert!(
amount_fmv_advisory(dec!(300000.01), Some(mv), dec!(0.05), date!(2024 - 07 - 03))
.is_some(),
"just over 100x must warn"
);
}
#[test]
fn amount_fmv_advisory_skips_dust_that_rounds_to_zero() {
assert!(amount_fmv_advisory(
dec!(100),
Some(dec!(0)),
dec!(0.00000001),
date!(2024 - 07 - 03)
)
.is_none());
}
#[test]
fn amount_fmv_advisory_notes_missing_price() {
let line = amount_fmv_advisory(dec!(3000), None, dec!(0.05), date!(2024 - 07 - 03))
.expect("no-price must produce a note");
assert!(
line.contains("no BTC price") && !line.contains("warning"),
"no-price must be a note, not a warning: {line}"
);
}
#[test]
fn tz_label_renders_utc_and_signed_offsets() {
use time::macros::offset;
assert_eq!(tz_label(UtcOffset::UTC), "UTC");
assert_eq!(tz_label(offset!(-05:00)), "UTC-05:00");
assert_eq!(tz_label(offset!(+05:45)), "UTC+05:45");
assert_eq!(tz_label(offset!(-00:30)), "UTC-00:30");
}
fn test_details() -> DonationDetails {
DonationDetails {
donee_name: "Test Charity".into(),
donee_address: None,
donee_ein: Some("12-3456789".into()),
appraiser_name: "Test Appraiser".into(),
appraiser_address: None,
appraiser_tin: Some("987654321".into()),
appraiser_ptin: None,
appraiser_qualifications: Some("Certified bitcoin appraiser".into()),
appraisal_date: Some(date!(2025 - 06 - 01)),
fmv_method_override: None,
}
}
fn setup_donation_vault(dir: &tempfile::TempDir) -> (std::path::PathBuf, EventId, EventId) {
use crate::Session;
let vault_path = dir.path().join("vault.pgp");
let mut session = Session::create(&vault_path, &pp()).unwrap();
let ts_acq = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
let ts_out = time::OffsetDateTime::from_unix_timestamp(1_720_000_000).unwrap();
let wallet = WalletId::Exchange {
provider: "coinbase".into(),
account: "default".into(),
};
let acq_id = EventId::import(Source::Coinbase, SourceRef::new("in|test-acq-001"));
let acq_ev = LedgerEvent {
id: acq_id.clone(),
utc_timestamp: ts_acq,
original_tz: UtcOffset::UTC,
wallet: Some(wallet.clone()),
payload: EventPayload::Acquire(Acquire {
sat: 2_000_000,
usd_cost: dec!(60000),
fee_usd: dec!(0),
basis_source: BasisSource::ComputedFromCost,
}),
};
append_import_batch(session.conn(), &[acq_ev]).unwrap();
let out_id = EventId::import(Source::Coinbase, SourceRef::new("out|test-donation-001"));
let out_ev = LedgerEvent {
id: out_id.clone(),
utc_timestamp: ts_out,
original_tz: UtcOffset::UTC,
wallet: Some(wallet.clone()),
payload: EventPayload::TransferOut(TransferOut {
sat: 500_000,
fee_sat: None,
dest_addr: None,
txid: None,
}),
};
append_import_batch(session.conn(), &[out_ev]).unwrap();
let classify_payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
transfer_out_event: out_id.clone(),
as_: OutflowClass::Donate {
appraisal_required: false,
},
principal_proceeds_or_fmv: dec!(15000),
fee_usd: None,
donee: Some("Test Charity".into()),
});
append_decision(
session.conn(),
classify_payload,
ts_out,
UtcOffset::UTC,
None,
)
.unwrap();
session.save().unwrap();
(vault_path, out_id, acq_id)
}
#[test]
fn set_then_show_round_trips_on_real_donation() {
let dir = tempfile::tempdir().unwrap();
let (vault_path, out_id, _acq_id) = setup_donation_vault(&dir);
set_donation_details(&vault_path, &pp(), &out_id.canonical(), test_details()).unwrap();
let stored = show_donation_details(&vault_path, &pp(), &out_id.canonical())
.unwrap()
.expect("details must be present");
assert_eq!(stored, test_details());
}
#[test]
fn set_donation_details_refuses_malformed_donee_ein() {
let dir = tempfile::tempdir().unwrap();
let (vault_path, out_id, _acq_id) = setup_donation_vault(&dir);
let mut bad = test_details();
bad.donee_ein = Some("banana".into());
let err = set_donation_details(&vault_path, &pp(), &out_id.canonical(), bad).unwrap_err();
assert!(
matches!(err, CliError::Usage(_)),
"expected Usage, got: {err}"
);
assert!(
err.to_string().contains("--donee-ein"),
"the refusal must name --donee-ein: {err}"
);
assert!(
show_donation_details(&vault_path, &pp(), &out_id.canonical())
.unwrap()
.is_none(),
"a refused set must store no details"
);
}
#[test]
fn set_donation_details_missing_ref_is_usage_error() {
let dir = tempfile::tempdir().unwrap();
let (vault_path, _out_id, _acq_id) = setup_donation_vault(&dir);
let bogus = EventId::import(Source::Coinbase, SourceRef::new("out|no-such-event"));
let err = set_donation_details(&vault_path, &pp(), &bogus.canonical(), test_details())
.unwrap_err();
assert!(
matches!(err, CliError::Usage(_)),
"expected Usage error, got: {err}"
);
let msg = err.to_string();
assert!(
msg.contains("not a donation") || msg.contains("not found"),
"error must mention 'not a donation' or 'not found': {msg}"
);
assert!(
msg.contains("reclassify-outflow") && msg.contains("--as-kind donate"),
"error must point at the missing prior step with valid syntax: {msg}"
);
}
#[test]
fn set_donation_details_non_donation_event_is_usage_error() {
let dir = tempfile::tempdir().unwrap();
let (vault_path, _out_id, acq_id) = setup_donation_vault(&dir);
let err = set_donation_details(&vault_path, &pp(), &acq_id.canonical(), test_details())
.unwrap_err();
assert!(
matches!(err, CliError::Usage(_)),
"expected Usage error, got: {err}"
);
}
#[test]
fn show_donation_details_returns_none_before_set() {
let dir = tempfile::tempdir().unwrap();
let (vault_path, out_id, _acq_id) = setup_donation_vault(&dir);
let stored = show_donation_details(&vault_path, &pp(), &out_id.canonical()).unwrap();
assert_eq!(stored, None);
}
fn setup_gift_vault(dir: &tempfile::TempDir) -> (std::path::PathBuf, EventId) {
use crate::Session;
let vault_path = dir.path().join("gift-vault.pgp");
let mut session = Session::create(&vault_path, &pp()).unwrap();
let ts_acq = time::OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap();
let ts_out = time::OffsetDateTime::from_unix_timestamp(1_720_000_000).unwrap();
let wallet = WalletId::Exchange {
provider: "coinbase".into(),
account: "default".into(),
};
let acq_id = EventId::import(Source::Coinbase, SourceRef::new("in|gift-acq-001"));
let acq_ev = LedgerEvent {
id: acq_id.clone(),
utc_timestamp: ts_acq,
original_tz: UtcOffset::UTC,
wallet: Some(wallet.clone()),
payload: EventPayload::Acquire(Acquire {
sat: 2_000_000,
usd_cost: dec!(60000),
fee_usd: dec!(0),
basis_source: BasisSource::ComputedFromCost,
}),
};
append_import_batch(session.conn(), &[acq_ev]).unwrap();
let out_id = EventId::import(Source::Coinbase, SourceRef::new("out|gift-001"));
let out_ev = LedgerEvent {
id: out_id.clone(),
utc_timestamp: ts_out,
original_tz: UtcOffset::UTC,
wallet: Some(wallet.clone()),
payload: EventPayload::TransferOut(TransferOut {
sat: 500_000,
fee_sat: None,
dest_addr: None,
txid: None,
}),
};
append_import_batch(session.conn(), &[out_ev]).unwrap();
let classify_payload = EventPayload::ReclassifyOutflow(ReclassifyOutflow {
transfer_out_event: out_id.clone(),
as_: OutflowClass::GiftOut,
principal_proceeds_or_fmv: dec!(15000),
fee_usd: None,
donee: None,
});
append_decision(
session.conn(),
classify_payload,
ts_out,
UtcOffset::UTC,
None,
)
.unwrap();
session.save().unwrap();
(vault_path, out_id)
}
#[test]
fn set_donation_details_gift_removal_is_usage_error() {
let dir = tempfile::tempdir().unwrap();
let (vault_path, gift_out_id) = setup_gift_vault(&dir);
let err =
set_donation_details(&vault_path, &pp(), &gift_out_id.canonical(), test_details())
.unwrap_err();
assert!(
matches!(err, CliError::Usage(_)),
"expected Usage error, got: {err}"
);
let msg = err.to_string();
assert!(
msg.contains("not a donation") || msg.contains("Gift"),
"error must mention 'not a donation' or 'Gift': {msg}"
);
}
}