Skip to main content

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}