use crate::conventions::{round_cents, TaxDate, SATS_PER_BTC};
use crate::event::{EventPayload, LedgerEvent};
use crate::identity::EventId;
use crate::optimize::{persistability, Persistability};
use crate::price::PriceProvider;
use crate::project::fold::fold;
use crate::project::resolve::{resolve, Op};
use crate::project::{in_force_methods, project, ProjectionConfig};
use crate::state::{Disposal, LedgerState, Term};
use crate::tax::{compute_tax_year, TaxOutcome, TaxProfile, TaxTables};
use crate::{BasisSource, LotMethod, Usd, WalletId};
use std::collections::BTreeSet;
pub fn tranche_dip_advisory(disposal: &Disposal) -> Option<String> {
let lines: Vec<String> = disposal
.legs
.iter()
.filter(|l| l.basis_source == BasisSource::EstimatedConservative)
.map(|l| {
format!(
"Conservative-filing dip — {sat} sat of undocumented BTC (estimated acquired by {acq}) \
disposed on {date} at ${basis:.2} basis as filed, reporting ${gain:.2} gain. If you can \
substantiate a higher basis for these units, recording it lowers the reported gain \
(never below the amount you can document).",
sat = l.sat,
acq = l.acquired_at,
date = disposal.disposed_at,
basis = l.basis,
gain = l.gain,
)
})
.collect();
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
pub fn method_inversion_advisory(
state: &LedgerState,
wallet: &WalletId,
method: LotMethod,
) -> Option<String> {
if method == LotMethod::Hifo {
return None;
}
let has_tranche = state.lots.iter().any(|l| {
l.wallet == *wallet
&& l.remaining_sat > 0
&& l.basis_source == BasisSource::EstimatedConservative
});
let has_documented = state.lots.iter().any(|l| {
l.wallet == *wallet
&& l.remaining_sat > 0
&& l.basis_source != BasisSource::EstimatedConservative
});
if has_tranche && has_documented {
Some(format!(
"Method-inversion warning — the in-force lot method for this wallet is {method:?} (not HIFO). \
Under it a disposal can draw a $0-basis conservative-filing unit before your documented \
higher-basis units, maximizing the reported gain. Electing HIFO would draw the documented \
units first — set it forward with `btctax config --set-forward-method hifo` (which binds \
2025+ disposals); a forward election cannot change a PRE-2025-dated disposal, so for those \
elect HIFO as the pre-2025 method instead."
))
} else {
None
}
}
pub fn self_custody_nudge(state: &LedgerState) -> Option<String> {
let has_exchange_tranche = state.lots.iter().any(|l| {
l.remaining_sat > 0
&& l.basis_source == BasisSource::EstimatedConservative
&& matches!(l.wallet, WalletId::Exchange { .. })
});
if has_exchange_tranche {
Some(
"Self-custody nudge — undocumented (conservative-filing) units are held at an exchange. \
Holding your oldest / no-records units in self-custody keeps own-books specific \
identification available indefinitely (a broker's own-books identification is insufficient \
from 2027). Also consider electing HIFO so a disposal draws documented units before the \
$0-basis units: `btctax config --set-forward-method hifo`."
.to_string(),
)
} else {
None
}
}
pub fn basis_methodology(state: &LedgerState, year: i32) -> Option<String> {
let mut items: Vec<String> = Vec::new();
for d in state
.disposals
.iter()
.filter(|d| d.disposed_at.year() == year)
{
for l in d
.legs
.iter()
.filter(|l| l.basis_source == BasisSource::EstimatedConservative)
{
let term = match l.term {
Term::LongTerm => "long-term",
Term::ShortTerm => "short-term",
};
items.push(format!(
" \u{2022} {sat} sat of undocumented BTC, estimated acquired by {acq} (the conservative \
window-end date), disposed on {date}, filed at ${basis:.2} basis ({term} holding \
period).",
sat = l.sat,
acq = l.acquired_at,
date = d.disposed_at,
basis = l.basis,
));
}
}
if items.is_empty() {
return None;
}
let mut out = format!(
"Basis methodology disclosure (conservative filing) \u{2014} tax year {year}\n\n\
For the units below, the actual cost basis could not be substantiated from available records, \
so a conservative estimate was filed \u{2014} the basis filed for each unit is shown on its \
line (the IRS `$0` fallback for unprovable basis, which cannot understate gain; a `>$0` amount \
reflects documented on-chain fee basis re-homed onto that unit under \u{00a7}1011, never the \
estimate). Each unit's holding period is derived from its estimated acquisition date and \
reported as computed, never assumed. If records are later reconstructed, a higher documented \
basis may be substantiated \u{2014} lowering the reported gain, never below the amount that can \
be documented.\n\n"
);
out.push_str(&items.join("\n"));
Some(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Coverage {
Full,
Partial,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowRef {
pub min: Usd,
pub coverage: Coverage,
}
pub fn window_reference(
prices: &dyn PriceProvider,
start: TaxDate,
end: TaxDate,
) -> Option<WindowRef> {
let mut min: Option<Usd> = None;
let mut covered: u64 = 0;
let mut total: u64 = 0;
let mut day = start;
while day <= end {
total += 1;
if let Some(px) = prices.usd_per_btc(day) {
covered += 1;
min = Some(min.map_or(px, |m| if px < m { px } else { m }));
}
match day.next_day() {
Some(d) => day = d,
None => break, }
}
min.map(|m| WindowRef {
min: m,
coverage: if covered == total {
Coverage::Full
} else {
Coverage::Partial
},
})
}
pub fn tranche_broker_specific_id_advisory(
wallet: &WalletId,
sale_date: TaxDate,
selection_made: TaxDate,
) -> Option<String> {
match persistability(wallet, sale_date, selection_made) {
Persistability::ForbiddenBroker2027 => Some(format!(
"Broker specific-ID warning — this {year} disposal draws undocumented BTC held at an \
exchange (broker). From 2027, own-books specific identification is INSUFFICIENT at a \
broker (the Notices 2025-7/2026-20 own-books transitional relief runs only through \
2026-12-31): to specifically identify units the broker must be given the identification by \
the time of sale, otherwise the sale falls back to FIFO. To keep own-books specific-ID for \
no-records units, hold them in self-custody.",
year = sale_date.year(),
)),
_ => None,
}
}
fn tax_total(
events: &[LedgerEvent],
state: &LedgerState,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Option<Usd> {
match compute_tax_year(events, state, year, profile, tables) {
TaxOutcome::Computed(r) => Some(r.total_federal_tax_attributable),
TaxOutcome::NotComputable(_) => None,
}
}
#[allow(clippy::too_many_arguments)]
fn overpayment_delta_one(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
tranche_id: &EventId,
reference: Usd,
baseline: Usd,
) -> Usd {
if reference <= Usd::ZERO {
return Usd::ZERO; }
let mut res = resolve(events, prices, config);
let mut swapped = false;
for eff in res.timeline.iter_mut() {
if eff.id != *tranche_id || !matches!(eff.id, EventId::Decision { .. }) {
continue;
}
if let Op::Acquire(a) = &mut eff.op {
if a.basis_source == BasisSource::EstimatedConservative {
a.usd_cost = round_cents(reference * Usd::from(a.sat) / Usd::from(SATS_PER_BTC));
swapped = true;
}
}
}
if !swapped {
return Usd::ZERO; }
let with_state = fold(res, prices, config);
match tax_total(events, &with_state, year, profile, tables) {
Some(with_tax) => (baseline - with_tax).max(Usd::ZERO), None => Usd::ZERO,
}
}
#[allow(clippy::too_many_arguments)]
pub fn overpayment_delta(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
refs: &[(EventId, Usd)],
) -> Usd {
let baseline = match tax_total(
events,
&project(events, prices, config),
year,
profile,
tables,
) {
Some(t) => t,
None => return Usd::ZERO,
};
refs.iter()
.map(|(id, reference)| {
overpayment_delta_one(
events, prices, config, year, profile, tables, id, *reference, baseline,
)
})
.sum()
}
fn overpayment_nudge_lines(
events: &[LedgerEvent],
state: &LedgerState,
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
if profile.is_none()
|| !events
.iter()
.any(|e| matches!(e.payload, EventPayload::DeclareTranche(_)))
{
return lines;
}
let baseline = match tax_total(
events,
&project(events, prices, config),
year,
profile,
tables,
) {
Some(t) => t,
None => return lines,
};
let mut any = false;
for e in events {
let EventPayload::DeclareTranche(t) = &e.payload else {
continue;
};
let Some(wr) = window_reference(prices, t.window_start, t.window_end) else {
continue; };
let delta = overpayment_delta_one(
events, prices, config, year, profile, tables, &e.id, wr.min, baseline,
);
if delta <= Usd::ZERO {
continue; }
any = true;
let mut line = format!(
"Overpayment nudge — reconstructing this {ws}\u{2013}{we} tranche and importing the records \
could save ~${saving} of federal tax this year, at the cost of a documented basis an \
examiner can question.",
ws = t.window_start,
we = t.window_end,
saving = delta.round_dp(0),
);
if wr.coverage == Coverage::Partial {
line.push_str(
" (Partial-window estimate: some days in the window had no price data, so the true \
saving may differ.)",
);
}
lines.push(line);
}
if any {
lines.push(
"If any of these coins were inherited, their basis is reconstructable by law from the \
date-of-death fair market value \u{2014} no cost records needed (\u{00a7}1014(a); the \
holding period is automatically long-term, \u{00a7}1223(9))."
.to_string(),
);
if state
.lots
.iter()
.any(|l| l.basis_source == BasisSource::EstimatedConservative && l.remaining_sat > 0)
{
lines.push(format!(
"This figure covers only the conservative-filing units disposed in {year}; undisposed \
tranche units remain."
));
}
}
lines
}
#[allow(clippy::too_many_arguments)]
pub fn tranche_report_advisory(
state: &LedgerState,
events: &[LedgerEvent],
prices: &dyn PriceProvider,
config: &ProjectionConfig,
year: i32,
profile: Option<&TaxProfile>,
tables: &dyn TaxTables,
) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
for d in state
.disposals
.iter()
.filter(|d| d.disposed_at.year() == year)
{
if let Some(a) = tranche_dip_advisory(d) {
lines.push(a);
}
if let Some(w) = d
.legs
.iter()
.find(|l| l.basis_source == BasisSource::EstimatedConservative)
.map(|l| &l.wallet)
{
if let Some(a) = tranche_broker_specific_id_advisory(w, d.disposed_at, d.disposed_at) {
lines.push(a);
}
}
}
let tranche_wallets: Vec<WalletId> = state
.lots
.iter()
.filter(|l| l.remaining_sat > 0 && l.basis_source == BasisSource::EstimatedConservative)
.map(|l| l.wallet.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if !tranche_wallets.is_empty() {
if let Ok(as_of) = time::Date::from_calendar_date(year, time::Month::December, 31) {
let methods = in_force_methods(events, prices, config, as_of, &tranche_wallets);
for (w, m) in tranche_wallets.iter().zip(methods) {
if let Some(a) = method_inversion_advisory(state, w, m.method) {
lines.push(a);
}
}
}
}
if let Some(a) = self_custody_nudge(state) {
lines.push(a);
}
lines.extend(overpayment_nudge_lines(
events, state, prices, config, year, profile, tables,
));
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}