use crate::cmd::promote::{
render_consent as render_consent_terms, ProvenanceKind, PROMOTE_ACK_PHRASE, PROVENANCE_TEXT,
PROVENANCE_VERSION,
};
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, EventPayload, FloorMethod, PromoteTranche,
};
use btctax_core::persistence::{append_decision, load_all};
use btctax_core::price::PriceProvider;
use btctax_core::project::ProjectionConfig;
use btctax_core::state::BlockerKind;
use btctax_core::{
project, EventId, LedgerEvent, LedgerState, RemovalKind, Sat, TaxDate, Usd, WalletId,
};
use std::collections::BTreeSet;
use time::{OffsetDateTime, UtcOffset};
#[derive(Debug, Clone)]
pub struct PromotePlan {
pub target: EventId,
pub terms: Vec<ConsentTerm>,
pub advisory_lines: Vec<String>,
pub gift_only_years: BTreeSet<i32>,
pub post_consent_note: Option<String>,
pub payload: EventPayload,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
Target(String),
Provenance(String),
Coverage(String),
PartII(String),
}
impl From<Refusal> for CliError {
fn from(r: Refusal) -> CliError {
let msg = match r {
Refusal::Target(m) => m,
Refusal::Provenance(m) => m,
Refusal::Coverage(m) => m,
Refusal::PartII(m) => m,
};
CliError::Usage(msg)
}
}
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, Refusal> {
let not_live = || {
Refusal::Target(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) -> Refusal {
Refusal::Provenance(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; a §1015 donor carryover for a gift; a §1014 date-of-death basis for an \
inheritance) — 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) -> Refusal {
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"
}
};
Refusal::Coverage(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 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 plan_promote(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
cfg: &ProjectionConfig,
target: &EventId,
provenance: ProvenanceKind,
part_ii: &str,
now: OffsetDateTime,
) -> Result<PromotePlan, Refusal> {
let tranche = resolve_live_tranche(events, target)?;
if provenance != ProvenanceKind::Purchase {
return Err(refuse_non_purchase(provenance));
}
if part_ii.trim().is_empty() {
return Err(Refusal::PartII(
"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 floor = conservative_promote::filed_basis_for(
prices,
tranche.sat,
tranche.window_start,
tranche.window_end,
)
.map_err(|e| refuse_no_floor(e, tranche.window_start, tranche.window_end))?;
let mut honest_cfg = *cfg;
honest_cfg.pseudo_reconcile = false;
let tables = btctax_adapters::BundledTaxTables::load();
let terms = conservative_promote::consent_terms(
events,
prices,
&honest_cfg,
target,
floor.filed_basis,
None,
&tables,
);
let with_events = with_synthetic_promote(events, target, 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_lines = conservative::promote_prior_year_advisory(
&with_events,
prices,
&honest_cfg,
&synthetic_id,
Direction::Promote,
None,
&tables,
current,
);
let gift_only_years = gift_only_flagged_years(prices, &honest_cfg, events, &with_events);
let payload = EventPayload::PromoteTranche(PromoteTranche {
target: target.clone(),
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.clone(),
provenance_text: PROVENANCE_TEXT.to_string(),
provenance_version: PROVENANCE_VERSION.to_string(),
},
part_ii_narrative: part_ii.to_string(),
});
Ok(PromotePlan {
target: target.clone(),
terms,
advisory_lines,
gift_only_years,
post_consent_note: wide_window_note(tranche.window_start, tranche.window_end),
payload,
})
}
pub fn render_consent(plan: &PromotePlan) -> String {
let mut out = String::new();
for line in &plan.advisory_lines {
out.push_str(line);
out.push('\n');
}
out.push_str(&render_consent_terms(&plan.terms, &plan.gift_only_years));
if let Some(note) = &plan.post_consent_note {
out.push('\n');
out.push_str(note);
}
out
}
pub fn apply_promote(
session: &mut Session,
plan: PromotePlan,
acknowledge: Option<&str>,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
require_promote_ack(acknowledge)?;
let events = load_all(session.conn())?;
let cfg = session.config()?.to_projection();
if let Some(detail) =
btctax_core::would_conflict(&events, session.prices(), &cfg, &plan.payload, now)
{
return Err(CliError::Usage(format!(
"cannot record this promote — a decision conflict: {detail}"
)));
}
let id = append_decision(session.conn(), plan.payload, now, UtcOffset::UTC, None)?;
session.save()?;
Ok(id)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclarePlan {
pub payload: EventPayload,
}
#[allow(clippy::too_many_arguments)]
pub fn plan_declare(
events: &[LedgerEvent],
prices: &dyn PriceProvider,
cfg: &ProjectionConfig,
sat: Sat,
wallet: WalletId,
window_start: TaxDate,
window_end: TaxDate,
target_shortfall: Option<EventId>,
now: OffsetDateTime,
) -> Result<DeclarePlan, Refusal> {
if sat <= 0 {
return Err(Refusal::Coverage(format!(
"tranche amount must be > 0 sat (got {sat})"
)));
}
if window_start > window_end {
return Err(Refusal::Coverage(format!(
"tranche window_start ({window_start}) must be <= window_end ({window_end})"
)));
}
crate::cmd::tranche::guard_tranche_vs_allocation(events, window_end).map_err(|e| match e {
CliError::Usage(m) => Refusal::Coverage(m),
other => Refusal::Coverage(other.to_string()), })?;
let payload = EventPayload::DeclareTranche(DeclareTranche {
sat,
wallet,
window_start,
window_end,
});
if let Some(id) = target_shortfall {
let mut honest_cfg = *cfg;
honest_cfg.pseudo_reconcile = false;
let next_seq = events
.iter()
.filter_map(|e| match e.id {
EventId::Decision { seq } => Some(seq),
_ => None,
})
.max()
.map_or(1, |m| m + 1);
let candidate = LedgerEvent {
id: EventId::decision(next_seq),
utc_timestamp: now,
original_tz: UtcOffset::UTC,
wallet: None,
payload: payload.clone(),
};
let mut with_candidate = events.to_vec();
with_candidate.push(candidate);
let state = project(&with_candidate, prices, &honest_cfg);
let still_uncovered = state
.blockers
.iter()
.any(|b| b.kind == BlockerKind::UncoveredDisposal && b.event.as_ref() == Some(&id));
if still_uncovered {
return Err(Refusal::Coverage(format!(
"this candidate tranche does not clear the shortfall on {} — after adding it, an \
UncoveredDisposal blocker still remains on that event. A tranche's synthetic acquisition \
lands at window_end and sorts AFTER a same-instant import, so window_end must be \
STRICTLY BEFORE the short event's date (and the wallet/sat must actually cover it) to \
clear it.",
id.canonical()
)));
}
}
Ok(DeclarePlan { payload })
}
pub fn apply_declare(
session: &mut Session,
plan: DeclarePlan,
now: OffsetDateTime,
) -> Result<EventId, CliError> {
let id = append_decision(session.conn(), plan.payload, now, UtcOffset::UTC, None)?;
session.save()?;
Ok(id)
}
pub(crate) fn promoted_filing_years(state: &LedgerState) -> BTreeSet<i32> {
let mut years = BTreeSet::new();
for d in &state.disposals {
if d.legs
.iter()
.any(|l| state.promoted_origins.contains(&l.lot_id.origin_event_id))
{
years.insert(d.disposed_at.year());
}
}
years
}
#[cfg(test)]
mod tests {
use super::*;
use btctax_core::state::{Disposal, DisposalLeg, Term};
use btctax_core::{BasisSource, DisposeKind, LotId};
use rust_decimal_macros::dec;
use time::macros::date;
fn wallet() -> WalletId {
WalletId::SelfCustody {
label: "pfy".into(),
}
}
fn leg(origin: EventId) -> DisposalLeg {
DisposalLeg {
lot_id: LotId {
origin_event_id: origin,
split_sequence: 0,
},
sat: 1_000_000,
proceeds: dec!(100),
basis: dec!(0),
gain: dec!(100),
term: Term::LongTerm,
basis_source: BasisSource::EstimatedConservative,
gift_zone: None,
acquired_at: date!(2016 - 03 - 31),
wallet: wallet(),
pseudo: false,
}
}
fn disposal(year: i32, origin: EventId) -> Disposal {
Disposal {
event: EventId::decision(90 + year as u64 % 10),
kind: DisposeKind::Sell,
disposed_at: time::Date::from_calendar_date(year, time::Month::June, 1).unwrap(),
legs: vec![leg(origin)],
fee_mini_disposition: false,
}
}
#[test]
fn promoted_filing_years_enumerates_promoted_disposal_legs_only() {
let promoted = EventId::decision(1);
let unpromoted = EventId::decision(2);
let state = LedgerState {
disposals: vec![
disposal(2024, unpromoted.clone()),
disposal(2025, promoted.clone()),
],
promoted_origins: [promoted].into_iter().collect(),
..Default::default()
};
let years = promoted_filing_years(&state);
assert_eq!(
years.iter().copied().collect::<Vec<_>>(),
vec![2025],
"only the year holding a PROMOTED disposal leg is enumerated: {years:?}"
);
assert!(
!years.contains(&2024),
"an unpromoted disposal leg's year must NOT enter the 8275-completeness set: {years:?}"
);
}
}