use crate::{CliError, Session};
use btctax_core::conservative::{self, Direction};
use btctax_core::conservative_promote::{self, PromoteRefusal};
use btctax_core::conventions::tax_date;
use btctax_core::event::{
Acknowledgment, ConsentTerm, DeclareTranche, FloorMethod, PromoteTranche,
};
use btctax_core::persistence::{append_decision, load_all};
use btctax_core::price::PriceProvider;
use btctax_core::project::{project, ProjectionConfig};
use btctax_core::{EventId, EventPayload, LedgerEvent, RemovalKind, TaxDate, Usd};
use btctax_store::Passphrase;
use std::collections::BTreeSet;
use std::path::Path;
use time::{OffsetDateTime, UtcOffset};
use crate::eventref::parse_event_id;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ProvenanceKind {
Purchase,
Gift,
Inheritance,
Mining,
Earned,
Airdrop,
Fork,
}
impl ProvenanceKind {
fn label(self) -> &'static str {
match self {
ProvenanceKind::Purchase => "purchase",
ProvenanceKind::Gift => "gift",
ProvenanceKind::Inheritance => "inheritance",
ProvenanceKind::Mining => "mining",
ProvenanceKind::Earned => "staking/earning",
ProvenanceKind::Airdrop => "airdrop",
ProvenanceKind::Fork => "fork",
}
}
}
pub const PROMOTE_ACK_PHRASE: &str = "I understand and accept this estimated-basis risk";
pub const PROVENANCE_TEXT: &str = "these units were acquired by purchase within the declared window — \
not by gift, inheritance, mining, staking/earning, airdrop, fork, or any acquisition other than \
purchase";
pub const PROVENANCE_VERSION: &str = "v1";
const CONSENT_INTRO: &str = "Promoting this tranche is a KNOWING choice to file a >$0 basis floor \
(the minimum daily closing price over the attested acquisition window) instead of the IRS-fallback \
$0. If an exam determines the correct basis is $0, the penalty is 20% ordinary / 40% worst-case of \
the resulting additional tax (the underpayment attributable to the misstatement), plus interest; \
the Form 8275 disclosure and the good-faith window-low-close methodology mitigate this exposure, \
but do not eliminate it and do not guarantee immunity from penalty.";
fn is_voided(events: &[LedgerEvent], id: &EventId) -> bool {
events.iter().any(
|e| matches!(&e.payload, EventPayload::VoidDecisionEvent(v) if v.target_event_id == *id),
)
}
fn resolve_live_tranche(
events: &[LedgerEvent],
target_event_id: &EventId,
) -> Result<DeclareTranche, CliError> {
let not_live = || {
CliError::Usage(format!(
"{} is not a live DeclareTranche (absent, wrong type, or voided) — see `btctax events list` \
for event refs + decision status",
target_event_id.canonical()
))
};
if is_voided(events, target_event_id) {
return Err(not_live());
}
events
.iter()
.find(|e| e.id == *target_event_id)
.and_then(|e| match &e.payload {
EventPayload::DeclareTranche(t) => Some(t.clone()),
_ => None,
})
.ok_or_else(not_live)
}
fn refuse_non_purchase(provenance: ProvenanceKind) -> CliError {
CliError::Usage(format!(
"promote-tranche requires purchase provenance: {PROVENANCE_TEXT}. This tranche was declared as \
acquired by {label} — a {label} recipient already has a documented, real basis (income \
FMV-at-receipt, or donor/decedent carryover) — model the real acquisition instead (a documented \
Acquire/Income/gift-received event), not a conservative-filing tranche promote.",
label = provenance.label(),
))
}
fn refuse_no_floor(e: PromoteRefusal, window_start: TaxDate, window_end: TaxDate) -> CliError {
let detail = match e {
PromoteRefusal::NoCoverage => {
"no bundled daily-close price exists anywhere in the window — never fabricate a floor over a \
total data gap"
}
PromoteRefusal::PartialCoverage => {
"the window has a gap in bundled daily-close data — the covered-part minimum is not provably \
the window's true minimum, so it cannot be filed as a trustworthy floor"
}
};
CliError::Usage(format!(
"cannot compute a promotion floor for the window [{window_start}, {window_end}]: {detail}. \
Narrow the window to a fully-covered range, or leave this tranche at its filed $0 basis."
))
}
fn wide_window_note(window_start: TaxDate, window_end: TaxDate) -> Option<String> {
let days = (window_end - window_start).whole_days();
if days > 365 {
Some(format!(
"note: this tranche's declared window spans {days} days (over a year). A WIDE window tends \
to produce a LOW (\"trivial\") floor relative to a tight one — for some filers it may be \
simpler, and just as conservative, to leave this tranche at its filed $0 basis and skip the \
Form 8275 disclosure surface entirely."
))
} else {
None
}
}
fn with_synthetic_promote(
events: &[LedgerEvent],
tranche_id: &EventId,
filed_basis: Usd,
now: OffsetDateTime,
) -> Vec<LedgerEvent> {
let seq = events
.iter()
.filter_map(|e| match e.id {
EventId::Decision { seq } => Some(seq),
_ => None,
})
.max()
.map_or(1, |m| m + 1);
let mut out = events.to_vec();
out.push(LedgerEvent {
id: EventId::decision(seq),
utc_timestamp: now,
original_tz: UtcOffset::UTC,
wallet: None,
payload: EventPayload::PromoteTranche(PromoteTranche {
target: tranche_id.clone(),
method: FloorMethod::WindowLowClose,
filed_basis,
coverage: conservative::Coverage::Full,
provenance_attested: true,
acknowledgment: Acknowledgment {
phrase: String::new(),
shown_terms: Vec::new(),
provenance_text: String::new(),
provenance_version: String::new(),
},
part_ii_narrative: String::new(),
}),
});
out
}
fn gift_only_flagged_years(
prices: &dyn PriceProvider,
config: &ProjectionConfig,
events: &[LedgerEvent],
with_events: &[LedgerEvent],
) -> BTreeSet<i32> {
let without_state = project(events, prices, config);
let with_state = project(with_events, prices, config);
let mut years: BTreeSet<i32> = BTreeSet::new();
for st in [&with_state, &without_state] {
for r in &st.removals {
years.insert(r.removed_at.year());
}
}
let rem =
|st: &btctax_core::LedgerState, y: i32, k: RemovalKind| -> Vec<btctax_core::Removal> {
st.removals
.iter()
.filter(|r| r.removed_at.year() == y && r.kind == k)
.cloned()
.collect()
};
years
.into_iter()
.filter(|&y| {
let gift_changed =
rem(&with_state, y, RemovalKind::Gift) != rem(&without_state, y, RemovalKind::Gift);
let don_changed = rem(&with_state, y, RemovalKind::Donation)
!= rem(&without_state, y, RemovalKind::Donation);
gift_changed && !don_changed
})
.collect()
}
fn render_term(term: &ConsentTerm, gift_only_years: &BTreeSet<i32>) -> String {
match term {
ConsentTerm::ComputedTax {
year,
delta_usd,
deduction_delta_usd,
} => {
let mut line = if *delta_usd >= Usd::ZERO {
format!(
"Year {year}: promoting this tranche SAVES ~${} in computed federal tax.",
delta_usd.round_dp(2)
)
} else {
format!(
"Year {year}: promoting this tranche INCREASES computed federal tax by ~${}.",
(-*delta_usd).round_dp(2)
)
};
if let Some(d) = deduction_delta_usd {
line.push_str(&format!(
" The computed tax figure does NOT capture this charitable-deduction change (priced \
only on the full return); its own Δ is ~${}.",
d.round_dp(2)
));
if gift_only_years.contains(year) {
line.push_str(
" That Δ is a donee-basis (§1015) documentation change; the donor's 1040 is \
unaffected — NOT a Schedule-A deduction.",
);
}
}
line
}
ConsentTerm::Uncomputable {
year,
gain_delta_usd,
deduction_delta_usd,
} => {
let mut line = format!(
"Year {year}: tax not computable here (no table/profile/blocked) — promoting changes the \
reported gain by ~${} and the deduction/basis by ~${}.",
gain_delta_usd.round_dp(2),
deduction_delta_usd.round_dp(2)
);
if *deduction_delta_usd != Usd::ZERO && gift_only_years.contains(year) {
line.push_str(
" The deduction/basis figure is a donee-basis (§1015) documentation change; the \
donor's 1040 is unaffected — NOT a Schedule-A deduction.",
);
}
line
}
ConsentTerm::CascadeNamed { year } => format!(
"Year {year}: this promote's cross-year effects may also shift that year's §1212(b)/§170(d) \
carryover-in (named here, not separately quantified)."
),
ConsentTerm::Unrealized {
sat,
hypothetical_reduction,
as_of,
} => match (hypothetical_reduction, as_of) {
(Some(r), Some(d)) => format!(
"{sat} sat remain undisposed: at the {d} close, promoting would reduce a future sale's \
reported gain by up to ~${} (hypothetical, not a filed figure) — saving and exposure \
accrue only at disposal.",
r.round_dp(2)
),
_ => format!(
"{sat} sat remain undisposed: no current bundled price is available — the filed floor \
itself is the maximum possible gain reduction on a future sale (hypothetical, not a \
filed figure) — saving and exposure accrue only at disposal."
),
},
}
}
pub fn render_consent(terms: &[ConsentTerm], gift_only_years: &BTreeSet<i32>) -> String {
let mut out = String::new();
out.push_str(CONSENT_INTRO);
for term in terms {
out.push('\n');
out.push_str(&render_term(term, gift_only_years));
}
out
}
fn require_promote_ack(acknowledge: Option<&str>) -> Result<(), CliError> {
match acknowledge.map(str::trim) {
Some(p) if p == PROMOTE_ACK_PHRASE => Ok(()),
Some(_) => Err(CliError::Usage(format!(
"the acknowledgment phrase did not match. Type it EXACTLY (trimmed, case-sensitive): {PROMOTE_ACK_PHRASE:?}."
))),
None => Err(CliError::Usage(format!(
"promote-tranche requires acknowledging the estimated-basis risk shown above — pass \
--i-acknowledge {PROMOTE_ACK_PHRASE:?} (or type it at the interactive prompt)."
))),
}
}
pub fn promote_tranche(
vault_path: &Path,
pp: &Passphrase,
target_ref: &str,
provenance: ProvenanceKind,
part_ii: String,
acknowledge: Option<&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 tranche = resolve_live_tranche(&events, &target_event_id)?;
if provenance != ProvenanceKind::Purchase {
return Err(refuse_non_purchase(provenance));
}
if part_ii.trim().is_empty() {
return Err(CliError::Usage(
"promote-tranche requires a non-empty Form 8275 Part II narrative (filer facts, Reg. \
§1.6662-4(f) — 'in sufficient detail') — pass --part-ii-file pointing at a file with real \
acquisition/window facts, not an empty or blank file"
.into(),
));
}
let cfg = session.config()?.to_projection();
let floor = conservative_promote::filed_basis_for(
session.prices(),
tranche.sat,
tranche.window_start,
tranche.window_end,
)
.map_err(|e| refuse_no_floor(e, tranche.window_start, tranche.window_end))?;
let tables = btctax_adapters::BundledTaxTables::load();
let terms = conservative_promote::consent_terms(
&events,
session.prices(),
&cfg,
&target_event_id,
floor.filed_basis,
None,
&tables,
);
let with_events = with_synthetic_promote(&events, &target_event_id, floor.filed_basis, now);
let synthetic_id = with_events
.last()
.expect("with_synthetic_promote always pushes exactly one event")
.id
.clone();
let current = tax_date(now, UtcOffset::UTC).year();
let advisory = conservative::promote_prior_year_advisory(
&with_events,
session.prices(),
&cfg,
&synthetic_id,
Direction::Promote,
None,
&tables,
current,
);
for line in &advisory {
println!("{line}");
}
let gift_only_years = gift_only_flagged_years(session.prices(), &cfg, &events, &with_events);
println!("{}", render_consent(&terms, &gift_only_years));
if let Some(note) = wide_window_note(tranche.window_start, tranche.window_end) {
println!("{note}");
}
require_promote_ack(acknowledge)?;
let payload = EventPayload::PromoteTranche(PromoteTranche {
target: target_event_id,
method: FloorMethod::WindowLowClose,
filed_basis: floor.filed_basis,
coverage: floor.coverage,
provenance_attested: true,
acknowledgment: Acknowledgment {
phrase: PROMOTE_ACK_PHRASE.to_string(),
shown_terms: terms,
provenance_text: PROVENANCE_TEXT.to_string(),
provenance_version: PROVENANCE_VERSION.to_string(),
},
part_ii_narrative: part_ii,
});
if let Some(detail) =
btctax_core::would_conflict(&events, session.prices(), &cfg, &payload, now)
{
return Err(CliError::Usage(format!(
"cannot record this promote — a decision conflict: {detail}"
)));
}
let id = append_decision(session.conn(), payload, now, UtcOffset::UTC, None)?;
session.save()?;
Ok(id)
}