btctax_core/state.rs
1use crate::conventions::{Sat, TaxDate, Usd};
2use crate::event::{BasisSource, DisposeKind, IncomeKind};
3use crate::identity::{EventId, LotId, WalletId};
4use std::collections::{BTreeMap, BTreeSet};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Term {
8 ShortTerm,
9 LongTerm,
10}
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum GiftZone {
13 Gain,
14 Loss,
15 NoGainNoLoss,
16}
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Severity {
19 Hard,
20 Advisory,
21}
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum BlockerKind {
24 FmvMissing,
25 UncoveredDisposal,
26 ImportConflict,
27 DecisionConflict,
28 UnknownBasisInbound,
29 Unclassified,
30 SafeHarborUnconservable,
31 SafeHarborTimebar,
32 UnmatchedOutflows,
33 Pre2025MethodNote,
34 /// §A.5(a): a `MethodElection` whose `effective_from` precedes its made-date or TRANSITION_DATE —
35 /// a standing order cannot be back-dated (§1.1012-1(j) no post-hoc identification). Hard.
36 MethodElectionBackdated,
37 /// §A.4: a `LotSelection` that fails validation (unknown/cross-wallet/over-drawn lot, or principal
38 /// mismatch). Hard — the named identification is unusable so the disposal's tax is gated.
39 LotSelectionInvalid,
40 /// §A.7 / §7.4: the live `pre2025_method` config differs from the GOVERNING (effective) allocation's
41 /// recorded `pre2025_method`. The allocation conserves under ITS recorded method, so this is a method
42 /// drift — NOT bad data (never `SafeHarborUnconservable`). Hard: a post-attestation method change would
43 /// silently break conservation between the pre-2025 residue and the irrevocable allocation. Clearable by
44 /// reverting the live config to the recorded method; the irrevocable allocation is never rewritten.
45 Pre2025MethodConflictsAllocation,
46 /// §B.4 / B-I1: the projection carries an unresolved Hard blocker (`severity()==Hard`) anywhere, so B
47 /// refuses to present a number for the year (projection-wide gate). Returns a `TaxOutcome::NotComputable`
48 /// with this kind. Clearable by resolving the underlying Hard blocker. Hard.
49 TaxYearNotComputable,
50 /// §B.1: no `tax_profile` is set for the year being computed. B does not guess the surrounding tax
51 /// context — the user must supply it via `tax-profile set`. Hard.
52 TaxProfileMissing,
53 /// §B.2: no bundled tax table is available for the year being computed. Hard.
54 TaxTableMissing,
55 /// §170(f)(11)(C): the term-aware claimed-deduction proxy for a charitable donation exceeds
56 /// $5,000 — a qualified appraisal is likely required (CCA 202302012 confirms the
57 /// exchange-price/readily-valued exception does NOT apply to crypto).
58 /// **Advisory** — never gates `compute_tax_year`; emitted per qualifying Donate event.
59 QualifiedAppraisalNote,
60 /// Cycle A: an inbound self-transfer (`InboundClass::SelfTransferMine`) whose basis was DEFAULTED
61 /// to $0 (`basis == None`). $0 is a *computable*, conservative value (never gates a later disposal —
62 /// contrast the Hard `UnknownBasisInbound`, which creates NO lot); this is a pure honesty prompt to
63 /// supply the real cost. Fires on `None` only, never on an attested `Some(0)`.
64 /// **Advisory** — never gates `compute_tax_year`.
65 SelfTransferInboundZeroBasis,
66 /// [reconcile-defaults] An inbound self-transfer (`InboundClass::SelfTransferMine`) whose acquisition
67 /// date was DEFAULTED (`acquired_at == None`) to 1 year + 1 day before receipt → the holding period is
68 /// assumed LONG-TERM. A pure honesty prompt to supply the real `--acquired` date if the coins were held
69 /// ≤ 1 year. Fires INDEPENDENTLY of the basis default (so a supplied `--basis` with no `--acquired`
70 /// still discloses the backdating). **Advisory** — never gates `compute_tax_year`.
71 SelfTransferInboundDefaultedAcquired,
72 /// Pseudo-reconcile mode (sub-project 2) is ON and at least one synthetic (non-persisted) default
73 /// is CONTRIBUTING to this projection. A loud "this picture is a placeholder — correct it toward
74 /// truth, and do NOT file it" banner (renders automatically via `{:?}` in `verify`). Drives the
75 /// interim export-refusal guard (sub-2) until the sub-3 typed-attest gate ships.
76 /// **Advisory** — never gates `compute_tax_year` (the mode's whole point is to PRESENT a number).
77 PseudoReconcileActive,
78}
79impl BlockerKind {
80 pub fn severity(self) -> Severity {
81 use BlockerKind::*;
82 match self {
83 FmvMissing
84 | UncoveredDisposal
85 | ImportConflict
86 | DecisionConflict
87 | UnknownBasisInbound
88 | Unclassified
89 | SafeHarborUnconservable
90 | MethodElectionBackdated
91 | LotSelectionInvalid
92 | Pre2025MethodConflictsAllocation
93 | TaxYearNotComputable
94 | TaxProfileMissing
95 | TaxTableMissing => Severity::Hard,
96 SafeHarborTimebar
97 | UnmatchedOutflows
98 | Pre2025MethodNote
99 | QualifiedAppraisalNote
100 | SelfTransferInboundZeroBasis
101 | SelfTransferInboundDefaultedAcquired
102 | PseudoReconcileActive => Severity::Advisory,
103 }
104 }
105}
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Blocker {
108 pub kind: BlockerKind,
109 pub event: Option<EventId>,
110 pub detail: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Lot {
115 pub lot_id: LotId,
116 pub wallet: WalletId,
117 pub acquired_at: TaxDate, // gift loss-zone HP start = this (gift date); see donor_acquired_at for tacking
118 pub original_sat: Sat,
119 pub remaining_sat: Sat,
120 pub usd_basis: Usd, // gain basis
121 pub basis_source: BasisSource,
122 pub dual_loss_basis: Option<Usd>, // received gifts (TP11): loss basis when FMV-at-gift < donor basis
123 pub donor_acquired_at: Option<TaxDate>, // tacking (TP11/§1223(2)); gain/no-dual HP start
124 pub basis_pending: bool, // FMV-missing income / unknown-basis gift: gain is gated until resolved
125 /// Pseudo-reconcile taint (sub-project 2, [R0-C1]): `true` when this lot's EXISTENCE or its BASIS
126 /// traces to a synthetic (non-persisted) default decision — e.g. a `SelfTransferMine{$0}` conjured
127 /// for an unknown-basis inbound, or a relocated fragment carrying a pseudo source lot's taint. Rides
128 /// the DATA (`Lot`→`Consumed`→leg) so a REAL Sell consuming a pseudo `$0`-basis lot renders FLAGGED,
129 /// never as a clean `proceeds − 0`. A DEDICATED bool — never a `BasisSource` variant — so the
130 /// CSV/form writers OMIT it (it must NEVER reach any export file). Always `false` outside pseudo mode.
131 pub pseudo: bool,
132}
133impl Lot {
134 /// HP start used on the gain side / no-dual case (tacks donor period when present).
135 pub fn gain_hp_start(&self) -> TaxDate {
136 self.donor_acquired_at.unwrap_or(self.acquired_at)
137 }
138 /// HP start used on the loss side of a dual-basis gift (the gift/received date).
139 pub fn loss_hp_start(&self) -> TaxDate {
140 self.acquired_at
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct DisposalLeg {
146 pub lot_id: LotId,
147 pub sat: Sat,
148 pub proceeds: Usd, // allocated net proceeds (gross − disposition fee, TP2)
149 pub basis: Usd, // tax-reported basis (zone-dependent for dual-basis gifts)
150 pub gain: Usd,
151 pub term: Term,
152 pub basis_source: BasisSource,
153 pub gift_zone: Option<GiftZone>,
154 /// Zone-aware holding-period start: the SAME HP-start passed to `term_for` for this leg.
155 /// In the §1015 dual-basis loss zone this is `loss_hp_start` (the gift date — loss basis
156 /// does NOT tack); in all other zones it is `gain_hp_start` (tacked donor date for gifts,
157 /// acquisition date otherwise). Must never contradict `leg.term`. [R0-C1]
158 pub acquired_at: TaxDate,
159 /// The wallet that held the consumed lot at disposal time — the ONLY sound source (D1 [R0-I1]).
160 pub wallet: WalletId,
161 /// Pseudo-reconcile taint (sub-project 2, [R0-C1]): `true` when this leg's basis/existence traces to
162 /// a synthetic default — set from the consumed lot's `pseudo` bit OR the disposal event itself being
163 /// synthetic. Renders `[PSEUDO]` on screen; OMITTED from every CSV/form writer.
164 pub pseudo: bool,
165}
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct Disposal {
168 pub event: EventId,
169 pub kind: DisposeKind,
170 pub disposed_at: TaxDate,
171 pub legs: Vec<DisposalLeg>,
172 /// TP8 config-(b) fee-sat mini-disposition: a RECOGNITION record only — excluded from FR9 Σdisposed
173 /// (its sats live in Σ on-chain-fee-sats; no second conservation entry).
174 pub fee_mini_disposition: bool,
175}
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum RemovalKind {
178 Gift,
179 Donation,
180}
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct RemovalLeg {
183 pub lot_id: LotId,
184 pub sat: Sat,
185 pub basis: Usd,
186 pub fmv_at_transfer: Usd,
187 pub term: Term,
188 pub basis_source: BasisSource,
189 /// Holding-period start = the SAME HP-start passed to `term_for` for this leg (`gain_hp_start`).
190 /// A removal (gift/donation) recognizes NO gain/loss (TP10), so there is NO §1015 dual-basis
191 /// loss-zone HP-start divergence like a `DisposalLeg` — this is ALWAYS `gain_hp_start` and can
192 /// never contradict `leg.term`. For a gift-received-then-donated lot, `gain_hp_start` is the
193 /// tacked donor acquisition date (§1223), which is the correct Form 8283 "date acquired" because
194 /// it matches the leg's holding-period `term` [R0-M2]. Must never contradict `leg.term`. [D1]
195 pub acquired_at: TaxDate,
196 /// Pseudo-reconcile taint (sub-project 2, [R0-C1]): `true` when this removal leg's basis/existence
197 /// traces to a synthetic default (consumed lot pseudo OR the removal event synthetic). Renders
198 /// `[PSEUDO]` on screen; OMITTED from removals.csv / Form 8283.
199 pub pseudo: bool,
200}
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct Removal {
203 pub event: EventId,
204 pub kind: RemovalKind,
205 pub removed_at: TaxDate,
206 pub legs: Vec<RemovalLeg>,
207 pub appraisal_required: bool, // donation (>$5k FMV over-flag, FOLLOWUPS)
208 pub donor_acquired_at: Option<TaxDate>,
209 /// §170(e)(1)(A) charitable-deduction amount for a Donation: `Some(Σ(LT→fmv; ST→min(fmv,basis)))`.
210 /// `None` for a Gift (not a charitable deduction). Standalone Schedule-A figure — does NOT
211 /// feed engine B / `compute_tax_year`. Pre-§170(b) AGI limits and carryover.
212 pub claimed_deduction: Option<Usd>,
213 /// Free-form donee identifier (Chunk 2). `None` for legacy events without the field.
214 /// Used by removals.csv and (for Donations) Form 8283. Does NOT feed engine B / tax math.
215 pub donee: Option<String>,
216}
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct IncomeRecord {
219 pub event: EventId,
220 pub recognized_at: TaxDate,
221 pub sat: Sat,
222 pub usd_fmv: Usd,
223 pub kind: IncomeKind,
224 pub business: bool,
225 /// Pseudo-reconcile taint (sub-project 2 / #41 Part B, [R0-I2]): `true` when this income's
226 /// recognized `usd_fmv` was SYNTHESIZED from the daily-close default in pseudo mode (a
227 /// `PseudoKind::PseudoFmv` — the import carried no FMV and a local price existed). Set from
228 /// `eff.pseudo` at BOTH fold push sites. The on-screen report flags such a row `[PSEUDO]`; the CSV
229 /// writers OMIT it (never exported — the estimate is export-gated). Always `false` outside pseudo
230 /// mode ⇒ projection byte-identical.
231 pub pseudo: bool,
232}
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct PendingLeg {
235 pub lot_id: LotId,
236 pub sat: Sat,
237 pub usd_basis: Usd,
238 pub acquired_at: TaxDate,
239 /// Pseudo-reconcile taint (sub-project 2, [R0-C1]): `true` when the lot removed into pending traces
240 /// to a synthetic default (e.g. a pseudo self-transfer-in lot later withdrawn). Never exported.
241 pub pseudo: bool,
242}
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct PendingTransfer {
245 pub event: EventId,
246 pub principal_sat: Sat,
247 pub fee_sat: Option<Sat>,
248 pub legs: Vec<PendingLeg>, // lots removed into pending (carry basis + acquired_at)
249}
250
251/// Defensive Filing Wizard Task 5 (arch-m-new-2/arch-I-2): a RAW per-emission shortfall record,
252/// populated in `fold.rs` at every sat-carrying `BlockerKind::UncoveredDisposal` site (the fold already
253/// has the sat amount at hand — this is NEVER derived from `Blocker.detail`). Multiple records can
254/// share the same `event` (e.g. a self-transfer short on BOTH principal and its on-chain fee);
255/// `defensive::discovery::shortfalls` aggregates them per event into a `Shortfall` (Σ principal+fee,
256/// Σ fee separately). Additive/derived — no filed-number change.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct ShortfallRecord {
259 pub event: EventId,
260 pub wallet: Option<WalletId>,
261 pub date: TaxDate,
262 pub principal_sat: Sat,
263 pub fee_sat: Sat,
264}
265
266/// Fold accumulators that are NOT directly reconstructable from the post-fold `LedgerState` vectors
267/// (FR9 `Σ in` / `Σ on-chain-fee-sats` / `Σ pending`). Carried as a FIELD on `LedgerState` (M3) —
268/// `project` always returns `LedgerState` (NO `(LedgerState, FoldStats)` tuple). Populated in `finalize`;
269/// a deterministic function of the events, so it is included in `PartialEq` and the determinism tests hold.
270/// Zero-valued by `Default` (the early tasks leave it zero; Task 11 fills `fee_sats_consumed`, Task 13 the rest).
271#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
272pub struct FoldStats {
273 pub sigma_in: Sat, // externally-sourced acquisitions (Acquire + Income + classified GiftReceived)
274 pub fee_sats_consumed: Sat, // sole FR9 conservation home for network-fee sats
275 pub sigma_pending: Sat, // principal + fee sats sitting in pending_reconciliation
276}
277
278#[derive(Debug, Clone, Default, PartialEq, Eq)]
279pub struct LedgerState {
280 pub lots: Vec<Lot>,
281 pub holdings_by_wallet: BTreeMap<WalletId, Sat>,
282 pub disposals: Vec<Disposal>,
283 pub removals: Vec<Removal>,
284 pub income_recognized: Vec<IncomeRecord>,
285 pub pending_reconciliation: Vec<PendingTransfer>,
286 pub blockers: Vec<Blocker>,
287 pub stats: FoldStats, // M3: fold accumulators (FR9), on-state field — never a tuple return
288 /// Pseudo-reconcile (sub-project 2): count of synthetic (non-persisted) default decisions
289 /// CONTRIBUTING to this projection. `0` outside pseudo mode. Queryable signal for the banner, the
290 /// interim export-refusal guard [R0-I3], and sub-3's typed-attest gate. `> 0` ⇔ a
291 /// `PseudoReconcileActive` advisory blocker is present and every `[PSEUDO]`-flagged row is fictional.
292 pub pseudo_synthetic_count: usize,
293 /// Task 11 (BG-D3 tag-side census): the set of `DeclareTranche` origin event ids whose tranche is
294 /// PROMOTED in this projection (a live `PromoteTranche` rewrote its `$0` basis to a filed `>$0`
295 /// floor). A disposal/removal leg or a lot is promoted iff `lot_id.origin_event_id ∈
296 /// promoted_origins`. Populated by `fold` from `Resolution.promotes`; empty when no promote is live.
297 /// Lets the state-only advisories (`basis_methodology`, the overpayment nudge) distinguish a promoted
298 /// `>$0` estimate floor from a documented on-chain fee carry WITHOUT re-deriving the promote set —
299 /// both are `EstimatedConservative` with `>$0` basis, indistinguishable from the leg alone.
300 pub promoted_origins: BTreeSet<EventId>,
301 /// Defensive Filing Wizard Task 5: raw shortfall emissions (see `ShortfallRecord`), populated in
302 /// `fold.rs` at every sat-carrying `UncoveredDisposal` site. Aggregated by
303 /// `defensive::discovery::shortfalls`/`triage` — additive/derived, never a second source of truth.
304 pub shortfalls: Vec<ShortfallRecord>,
305}
306impl LedgerState {
307 /// Pseudo-reconcile (sub-project 2): `true` when any synthetic default contributes to this
308 /// projection. The single load-bearing signal for the export-refusal guard [R0-I3].
309 pub fn pseudo_active(&self) -> bool {
310 self.pseudo_synthetic_count > 0
311 }
312
313 pub(crate) fn add_blocker(
314 &mut self,
315 kind: BlockerKind,
316 event: Option<EventId>,
317 detail: impl Into<String>,
318 ) {
319 self.blockers.push(Blocker {
320 kind,
321 event,
322 detail: detail.into(),
323 });
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn new_blockers_are_hard() {
333 assert_eq!(
334 BlockerKind::MethodElectionBackdated.severity(),
335 Severity::Hard
336 );
337 assert_eq!(BlockerKind::LotSelectionInvalid.severity(), Severity::Hard);
338 assert_eq!(
339 BlockerKind::Pre2025MethodConflictsAllocation.severity(),
340 Severity::Hard
341 );
342 assert_eq!(BlockerKind::TaxProfileMissing.severity(), Severity::Hard);
343 assert_eq!(BlockerKind::TaxTableMissing.severity(), Severity::Hard);
344 assert_eq!(BlockerKind::TaxYearNotComputable.severity(), Severity::Hard);
345 // Task 1 KAT: QualifiedAppraisalNote MUST be Advisory — never Hard; must never gate compute_tax_year.
346 assert_eq!(
347 BlockerKind::QualifiedAppraisalNote.severity(),
348 Severity::Advisory
349 );
350 }
351}