Skip to main content

btctax_core/
forms.rs

1//! IRS **Form 8949** (per-disposition rows) + **Schedule D** (aggregated part totals) generation
2//! (Phase-2 sub-project 2). Both are **pure, year-scoped projections over `state.disposals`** — no
3//! tax math, no rounding, no float (NFR5: the BTC-amount description is EXACT `Decimal`). Federal-only.
4//!
5//! **Scope boundary (D3):** these are the RAW, pre-netting Form 8949 rows / Schedule D part totals.
6//! The §1222 ST/LT netting + §1211/§1212 loss limit + carryforward live in engine B
7//! (`compute_tax_year` / `report --tax-year`), NOT here. The reconciliation KAT ties the raw part
8//! gains to B's within-character `st_net`/`lt_net` (before B's carryforward/other-LT netting) so the
9//! forms and the tax engine can never silently diverge.
10use crate::conventions::{Sat, TaxDate, Usd, SATS_PER_BTC};
11use crate::donation::DonationDetails;
12use crate::event::{BasisSource, DisposeKind};
13use crate::identity::{EventId, LotId, WalletId};
14use crate::state::{LedgerState, RemovalKind, RemovalLeg, Term};
15use crate::tax::tables::QUALIFIED_APPRAISAL_THRESHOLD;
16use rust_decimal::Decimal;
17use std::collections::BTreeMap;
18
19/// Which Form 8949 part / holding-period a row belongs to. **Part I = short-term** (held ≤ 1 yr);
20/// **Part II = long-term** (held > 1 yr). Derived 1:1 from the leg's `Term`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Form8949Part {
23    /// Part I — short-term.
24    ShortTerm,
25    /// Part II — long-term.
26    LongTerm,
27}
28
29/// The Form 8949 "box" (the reporting category). We **only ever** emit the conservative
30/// "**not reported to the IRS**" default, YEAR-AWARE:
31/// - **pre-TY2025** digital-asset sales used the securities boxes → **C** (ST) / **F** (LT), "not reported
32///   on a 1099-B".
33/// - **TY2025+** the 2025 Form 8949 added digital-asset-specific boxes and the i8949 states *"Do not use
34///   box C to report digital asset transactions. Use box I"* / *"Do not use box F… Use box L"* → **I** (ST)
35///   / **L** (LT), "not reported on a 1099-DA".
36///
37/// We NEVER auto-assign the 1099-reported boxes (A/B/D/E pre-2025; G/H/J/K from 2025): the model carries no
38/// 1099-B / 1099-DA signal (D4), and asserting a broker form was issued / basis reported would fabricate an
39/// unsubstantiated box. The `box_needs_review` flag surfaces exchange dispositions that MAY carry a broker
40/// form, to be reclassified on the actual return (G/H or J/K from 2025).
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Form8949Box {
43    /// Box **C** — short-term, not reported on a 1099-B (pre-TY2025 digital-asset default).
44    C,
45    /// Box **F** — long-term, not reported on a 1099-B (pre-TY2025 digital-asset default).
46    F,
47    /// Box **I** — short-term digital-asset sale NOT reported on a 1099-DA (TY2025+ default).
48    I,
49    /// Box **L** — long-term digital-asset sale NOT reported on a 1099-DA (TY2025+ default).
50    L,
51}
52
53/// The first tax year the 2025 Form 8949 digital-asset boxes (G–L) apply; before this, the securities
54/// boxes (A–F) are used. Transactions in TY2025 are filed on the 2025 form.
55pub const DIGITAL_ASSET_8949_FIRST_YEAR: i32 = 2025;
56
57/// One Form 8949 row = one `DisposalLeg` disposed in the tax year. A pure projection of the leg;
58/// no gain/basis/term math is performed here (all of it is already on the leg from the fold).
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Form8949Row {
61    /// Part I (ST) or Part II (LT), from `leg.term`.
62    pub part: Form8949Part,
63    /// The conservative "not reported to the IRS" box, chosen year-aware by [`form_8949`]:
64    /// pre-TY2025 the securities boxes C (ST) / F (LT); from TY2025 the digital-asset boxes I (ST) /
65    /// L (LT) — the securities boxes are forbidden for digital assets on the 2025 revision (D4).
66    pub box_: Form8949Box,
67    /// `true` iff the disposing wallet is an **Exchange** (`matches!(leg.wallet, Exchange { .. })`)
68    /// — such a disposition MAY have been reported to the IRS by the broker (1099-DA from TY2025;
69    /// 1099-B before), so the conservative default should be reviewed and reclassified: pre-TY2025
70    /// to A/B (ST) or D/E (LT) if a 1099-B was issued; from TY2025 to G/H (ST) or J/K (LT) if a
71    /// 1099-DA was issued. Direct match on `leg.wallet` (D4/[R0-M2]) — never the private
72    /// `optimize.rs::is_broker`.
73    pub box_needs_review: bool,
74    /// Column (a): the BTC amount, 8dp + `" BTC"` (e.g. `"0.53000000 BTC"`). Computed as EXACT
75    /// `Decimal` (`Decimal::from(sat) / SATS_PER_BTC`) — NEVER `sat as f64 / 1e8` [R0-M5].
76    pub description: String,
77    /// Column (b): date acquired = the leg's zone-aware holding-period start (`leg.acquired_at`).
78    pub date_acquired: TaxDate,
79    /// Column (c): date sold = the disposal's `disposed_at`.
80    pub date_sold: TaxDate,
81    /// Column (d): proceeds (allocated net proceeds, from the leg).
82    pub proceeds: Usd,
83    /// Column (e): cost basis (tax-reported basis, from the leg).
84    pub cost_basis: Usd,
85    /// Column (f): adjustment code — always empty. No §1091 (wash sale is N/A to crypto) and no
86    /// other adjustments are modelled.
87    pub adjustment_code: String,
88    /// Column (g): adjustment amount — always `0`. See `adjustment_code`.
89    pub adjustment_amount: Usd,
90    /// Column (h): gain/loss (from the leg; for a NoGainNoLoss dual-basis gift leg this is `0`).
91    pub gain: Usd,
92    /// The disposing wallet (CSV `wallet` column + the `box_needs_review` source).
93    pub wallet: WalletId,
94    /// The disposition kind (Sell/Spend), for the CSV `disposition_kind` column.
95    pub disposition_kind: DisposeKind,
96}
97
98/// Format a satoshi quantity as its exact BTC amount, 8dp + `" BTC"` (e.g. `"0.53000000 BTC"`).
99///
100/// **Exact `Decimal` (NFR5 / [R0-M5]):** `Decimal::from(sat) / SATS_PER_BTC`. `sat` is an integer
101/// count and `SATS_PER_BTC` is `100_000_000`, so the quotient has at most 8 decimal places — the 8dp
102/// format is lossless (no rounding, ever). A float (`sat as f64 / 1e8`) would be non-exact and is
103/// forbidden.
104fn btc_amount_description(sat: Sat) -> String {
105    let btc = Decimal::from(sat) / Decimal::from(SATS_PER_BTC);
106    format!("{btc:.8} BTC")
107}
108
109/// Build the Form 8949 rows for tax year `year`: **one row per `DisposalLeg`** whose
110/// `Disposal.disposed_at.year() == year`. Pure over `state.disposals`.
111///
112/// Rows for ALL legs in the year are emitted, INCLUDING NoGainNoLoss dual-basis gift-zone legs — the
113/// fold already set `basis == proceeds` for that zone, so the row is internally consistent
114/// (proceeds, basis, gain = 0) with no special 8949 adjustment code needed [R0-M1].
115///
116/// **Deterministic ordering (NFR4):** rows are sorted by `disposed_at`, then the disposal's `event`
117/// id, then the leg's `lot_id` — a total order over the (event, lot) space.
118pub fn form_8949(state: &LedgerState, year: i32) -> Vec<Form8949Row> {
119    // Key each row by (disposed_at, event, lot_id) so ordering is a deterministic total order
120    // independent of the projection's disposal/leg iteration order.
121    let mut keyed: Vec<(
122        TaxDate,
123        &crate::identity::EventId,
124        &crate::identity::LotId,
125        Form8949Row,
126    )> = Vec::new();
127    for d in state
128        .disposals
129        .iter()
130        .filter(|d| d.disposed_at.year() == year)
131    {
132        for leg in &d.legs {
133            // Year-aware conservative "not reported to the IRS" box: pre-TY2025 uses the securities boxes
134            // C/F; TY2025+ MUST use the digital-asset boxes I/L (the i8949 forbids C/F for digital assets).
135            let da = year >= DIGITAL_ASSET_8949_FIRST_YEAR;
136            let (part, box_) = match leg.term {
137                Term::ShortTerm => (
138                    Form8949Part::ShortTerm,
139                    if da { Form8949Box::I } else { Form8949Box::C },
140                ),
141                Term::LongTerm => (
142                    Form8949Part::LongTerm,
143                    if da { Form8949Box::L } else { Form8949Box::F },
144                ),
145            };
146            let row = Form8949Row {
147                part,
148                box_,
149                box_needs_review: matches!(leg.wallet, WalletId::Exchange { .. }),
150                description: btc_amount_description(leg.sat),
151                date_acquired: leg.acquired_at,
152                date_sold: d.disposed_at,
153                proceeds: leg.proceeds,
154                cost_basis: leg.basis,
155                adjustment_code: String::new(),
156                adjustment_amount: Usd::ZERO,
157                gain: leg.gain,
158                wallet: leg.wallet.clone(),
159                disposition_kind: d.kind,
160            };
161            keyed.push((d.disposed_at, &d.event, &leg.lot_id, row));
162        }
163    }
164    keyed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(b.1)).then(a.2.cmp(b.2)));
165    keyed.into_iter().map(|(_, _, _, r)| r).collect()
166}
167
168/// One Schedule D part total (Part I / ST or Part II / LT): the RAW, pre-netting sums over the
169/// year's Form 8949 rows (equivalently, the year's disposal legs of that term).
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
171pub struct ScheduleDPart {
172    /// Σ proceeds over the part's legs.
173    pub proceeds: Usd,
174    /// Σ cost basis over the part's legs.
175    pub cost_basis: Usd,
176    /// Σ gain/loss over the part's legs (signed).
177    pub gain: Usd,
178}
179
180/// Schedule D part totals for a tax year: **Part I (ST)** + **Part II (LT)**.
181///
182/// These are the RAW pre-netting totals. §1222/§1211/§1212 netting + carryforward is applied by
183/// engine B (`compute_tax_year` / `report --tax-year`), NOT here.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
185pub struct ScheduleDTotals {
186    /// Part I — short-term.
187    pub st: ScheduleDPart,
188    /// Part II — long-term.
189    pub lt: ScheduleDPart,
190}
191
192/// Aggregate the year's disposal legs into Schedule D part totals (Part I ST + Part II LT), summing
193/// proceeds/basis/gain within each character. Pure over `state.disposals`; year-scoped by
194/// `Disposal.disposed_at.year() == year`. An empty year yields all-zero totals.
195///
196/// The ST/LT `gain` totals reconcile with engine B's within-character `st_net`/`lt_net` on an
197/// all-gains fixture with zero carryforward-in + zero other-net-capital-gain (the R0-M3
198/// reconciliation KAT) — `schedule_d` and `compute_tax_year` are separate functions reading the same
199/// `state.disposals`, so the equality is a genuine cross-check, not a tautology.
200pub fn schedule_d(state: &LedgerState, year: i32) -> ScheduleDTotals {
201    let mut totals = ScheduleDTotals::default();
202    for d in state
203        .disposals
204        .iter()
205        .filter(|d| d.disposed_at.year() == year)
206    {
207        for leg in &d.legs {
208            let part = match leg.term {
209                Term::ShortTerm => &mut totals.st,
210                Term::LongTerm => &mut totals.lt,
211            };
212            part.proceeds += leg.proceeds;
213            part.cost_basis += leg.basis;
214            part.gain += leg.gain;
215        }
216    }
217    totals
218}
219
220// ── Sub-project C (P2-C): IRS Form 8283 (Noncash Charitable Contributions) ───────────────────────
221//
222// Pure, year-scoped projection over `state.removals` where `kind == Donation`. No tax math is done
223// here (the §170(e) `claimed_deduction` was computed by the fold and lives on the `Removal`). Like
224// Form 8949 this is STANDALONE — it does NOT feed `compute_tax_year` / engine B (Schedule-A-adjacent,
225// §170). Federal-only. No float (NFR5: the BTC-amount description is EXACT `Decimal`).
226
227/// The Form 8283 part a donation is reported in, driven by the **§170(f)(11)(F) year-aggregate**
228/// claimed deduction over ALL BTC donations in the tax year (all BTC is "similar property"):
229/// - **Section A** — year-aggregate ≤ $5,000 (§170(f)(11)(C): "more than $5,000" is the threshold;
230///   exactly $5,000 → Section A).
231/// - **Section B** — year-aggregate > $5,000: a **qualified appraisal + appraiser signature** is
232///   required (CCA 202302012 confirms the readily-valued exception does NOT apply to crypto).
233///
234/// The section is **UNIFORM** across all donations in the year (all BTC is one similar-property
235/// class). This replaces the prior per-donation threshold test (§170(f)(11)(F) aggregates similar
236/// items across the year; a per-donation test under-triggers Section B).
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum Form8283Section {
239    /// Section A — year-aggregate ≤ $5,000.
240    A,
241    /// Section B — year-aggregate > $5,000 (qualified appraisal required).
242    B,
243}
244
245/// The Form 8283 "How acquired by donor" category, derived from the leg's `BasisSource` [R0-N2]:
246/// - `ExchangeProvided` / `ComputedFromCost` → **Purchased**
247/// - `GiftCarryover` / `GiftFmvFallback` → **Gift**
248/// - `FmvAtIncome` → **Other** ("income" is NOT a literal Form 8283 how-acquired category)
249/// - `CarriedFromTransfer` / `SafeHarborAllocated` / `ReconstructedPerWallet` /
250///   `SelfTransferInbound` → **Review** (origin lost — the acquisition provenance cannot be soundly
251///   asserted; a self-transfer-in's coins came from an un-imported wallet with attested/defaulted basis).
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum Form8283HowAcquired {
254    /// Purchased (basis from an exchange record or a computed cost).
255    Purchased,
256    /// Gift (a received-gift lot with carryover / FMV-fallback basis).
257    Gift,
258    /// Other (income-recognized basis — mining/staking/airdrops/rewards).
259    Other,
260    /// Review — the acquisition origin was lost (transferred/safe-harbor/reconstructed basis).
261    Review,
262}
263
264/// Map a leg's `BasisSource` to its Form 8283 "how acquired" category [R0-N2].
265///
266/// `EstimatedConservative` (a conservative-filing tranche) → `Review`: the origin is unprovable, so a
267/// donation of it needs manual handling — an LT-held tranche donation deducts FMV, an ST-held one is
268/// limited to its DOCUMENTED basis per §170(e)(1)(A) (`$0` for an unpromoted tranche; the estimate/floor
269/// never funds a deduction — BG-D11). (This is the Form 8283 donor field, NOT an 8949 column.)
270pub fn how_acquired_from(bs: BasisSource) -> Form8283HowAcquired {
271    use Form8283HowAcquired as H;
272    match bs {
273        BasisSource::ExchangeProvided | BasisSource::ComputedFromCost => H::Purchased,
274        BasisSource::GiftCarryover | BasisSource::GiftFmvFallback => H::Gift,
275        BasisSource::FmvAtIncome => H::Other,
276        BasisSource::CarriedFromTransfer
277        | BasisSource::SafeHarborAllocated
278        | BasisSource::ReconstructedPerWallet
279        | BasisSource::SelfTransferInbound
280        | BasisSource::EstimatedConservative => H::Review,
281    }
282}
283
284/// One Form 8283 row = one `RemovalLeg` of a `Donation` contributed in the tax year. A pure
285/// projection of the leg; no gain/basis/deduction math is done here.
286///
287/// **First-leg convention (no CSV SUM double-count):** the per-DONATION `section`,
288/// `claimed_deduction`, `fmv_method`, and `donee` appear on the FIRST leg row only; subsequent leg
289/// rows carry `None`/`""` — so a naive SUM over the deduction column equals the correct per-donation
290/// total (mirrors P2-A's removals.csv).
291///
292/// **Partially unmodeled user-input (honest gaps, never fabricated):** `appraiser` is populated from
293/// `DonationDetails` when present, otherwise empty. `needs_review` is section-aware: for Section B,
294/// `false` only when `DonationDetails` is present and `is_review_complete(Section::B)` returns `true`.
295/// For Section A, `false` when `DonationDetails` is present. `fmv_method` is derived from the
296/// section, overridden by `DonationDetails.fmv_method_override` when present. `donee` is populated
297/// from `DonationDetails.donee_name` when present, falling back to `Removal.donee`.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct Form8283Row {
300    /// Section A/B — on the FIRST leg row only (`None` on subsequent legs). Driven by the
301    /// **§170(f)(11)(F) year-aggregate** claimed deduction over ALL BTC donations in the tax year
302    /// (Section B when the year-aggregate > $5,000; Section A otherwise). The section is UNIFORM
303    /// across all donations in the year — all BTC is one "similar property" class.
304    pub section: Option<Form8283Section>,
305    /// Column: description — the EXACT BTC amount, 8dp + `" BTC"` (`btc_amount_description`; NFR5).
306    pub description: String,
307    /// Column: how the donor acquired the property, from the leg's `BasisSource` [R0-N2].
308    pub how_acquired: Form8283HowAcquired,
309    /// Column: date acquired = the leg's holding-period start (`leg.acquired_at`; §1223 tacked donor
310    /// date for received gifts). Consistent with the leg's `term` by construction (Task 1).
311    pub date_acquired: TaxDate,
312    /// Column: date contributed = the donation's `removed_at`.
313    pub date_contributed: TaxDate,
314    /// Column: donor's cost basis (from the leg).
315    pub cost_basis: Usd,
316    /// Column: fair market value at the contribution (from the leg).
317    pub fmv: Usd,
318    /// Column: the per-DONATION §170(e) claimed deduction — on the FIRST leg row only (`None` on
319    /// subsequent legs, so a CSV SUM does not double-count).
320    pub claimed_deduction: Option<Usd>,
321    /// Column: FMV determination method — on the carrier (first-leg) row: populated from
322    /// `DonationDetails.fmv_method_override` when present, otherwise `"qualified appraisal"`
323    /// for Section B or `""` for Section A (honest gap, never fabricated). Empty on subsequent
324    /// (non-carrier) leg rows. No `FmvStatus` dependency — derived from the section only.
325    pub fmv_method: String,
326    /// Column: donee organization — from `DonationDetails.donee_name` when present on the carrier
327    /// row, otherwise from `Removal.donee` (free-form label; `""` when `None`). Empty on subsequent
328    /// (non-carrier) legs (first-leg convention).
329    pub donee: String,
330    /// Column: appraiser — from `DonationDetails.appraiser_name` when present on the carrier row;
331    /// empty on non-carrier legs and when no details are stored.
332    pub appraiser: String,
333    /// `true` when the row needs manual review: section-aware. For carrier rows with no details,
334    /// always `true`. For carrier rows with details: `false` when `is_review_complete(section)`
335    /// returns `true` (Section B requires full appraiser declaration; Section A: complete on presence).
336    /// Non-carrier rows: always `true`.
337    pub needs_review: bool,
338    /// Full donation details for the carrier (first-leg) row — `None` on non-carrier legs.
339    /// Used by the CSV writer to flatten the Part III/IV extra columns without bloating the
340    /// common row fields. `None` when no details are stored for this donation.
341    pub details: Option<DonationDetails>,
342}
343
344/// Compute the §170(f)(11)(F) **year-aggregate** claimed deduction over all `Donation` removals in
345/// `year`. Returns `Usd::ZERO` when there are no donations in the year.
346///
347/// Gifts (`kind == Gift`) have `claimed_deduction == None` and are NOT §170; they do not appear
348/// in the filter and cannot enter this aggregate.
349///
350/// This is the **single source of truth** for the year-aggregate sum used by:
351/// - `form_8283` — to determine the uniform Section A/B for all rows in the year.
352/// - The CLI render layer's donation-appraisal advisory — for the D2 year-aggregate advisory.
353///
354/// Centralised here so the two consumers cannot silently diverge (structural guarantee, not a
355/// runtime check): if the Section B decision and the advisory ever disagreed, `form8283.csv` could
356/// show Section A while the advisory shows a Section B warning — extracting the helper into `core`
357/// makes this structurally impossible.
358pub fn year_donation_deduction(state: &LedgerState, year: i32) -> Usd {
359    state
360        .removals
361        .iter()
362        .filter(|r| r.kind == RemovalKind::Donation && r.removed_at.year() == year)
363        .filter_map(|r| r.claimed_deduction)
364        .sum()
365}
366
367/// Build the Form 8283 rows for tax year `year`: **one row per `RemovalLeg`** of a `Donation` whose
368/// `Removal.removed_at.year() == year`. Pure over `state.removals`. Gifts (`kind == Gift`) produce
369/// NO rows (a gift is not a charitable contribution; `claimed_deduction` is `None` and they are NOT
370/// §170 — Gifts must NOT enter the donation aggregate). An empty year yields an empty vec.
371///
372/// **Section A/B (D1 — §170(f)(11)(F) year-aggregate):** the year's total `claimed_deduction` over
373/// ALL Donation removals determines the section UNIFORMLY (all BTC is "similar property"). The per-
374/// donation threshold test is replaced by this year-aggregate test: if `Σ claimed_deduction > $5,000`
375/// → every carrier row is Section B; otherwise Section A. The `$5,000` threshold is strict `>`
376/// (§170(f)(11)(C): "more than $5,000"; exactly $5,000 → Section A).
377///
378/// **`fmv_method` (D3 — honest, section-derived):** Section B → `"qualified appraisal"` (a qualified
379/// appraisal IS required and IS the FMV-determination method for Section B); Section A → `""` (FMV
380/// method is not modeled for Section A; honest gap, never fabricated). `RemovalLeg` carries no FMV
381/// provenance, so `fmv_method` cannot be sourced from price status without an event-schema change
382/// (out of scope for Chunk 1).
383///
384/// **First-leg convention:** `section`, `claimed_deduction`, and `fmv_method` are emitted on the
385/// FIRST leg row only (carrier = smallest `lot_id`), so a naive CSV SUM of the deduction column does
386/// not double-count. Subsequent legs carry `None`/`""` for these fields.
387///
388/// **Deterministic ordering (NFR4):** rows are sorted by `removed_at`, then the donation's `event`
389/// id, then the leg's `lot_id` — a total order over the (event, lot) space (mirrors `form_8949`).
390pub fn form_8283(
391    state: &LedgerState,
392    year: i32,
393    details: &BTreeMap<EventId, DonationDetails>,
394) -> Vec<Form8283Row> {
395    // D1: §170(f)(11)(F) year-aggregate — use the shared helper (single source of truth).
396    // Gifts have claimed_deduction == None and are NOT §170 — they must NOT enter this aggregate.
397    let year_agg_deduction: Usd = year_donation_deduction(state, year);
398    // §170(f)(11)(C): "more than $5,000" — strict `>` (exactly $5,000 → Section A).
399    // The section is UNIFORM across the year: all BTC is "similar property", one aggregate class.
400    let section = if year_agg_deduction > QUALIFIED_APPRAISAL_THRESHOLD {
401        Form8283Section::B
402    } else {
403        Form8283Section::A
404    };
405    // D3: honest fmv_method — derived from the section only (no FMV provenance on RemovalLeg;
406    // no FmvStatus dependency; no fabrication). Carrier row only; empty on subsequent legs.
407    let carrier_fmv_method = match section {
408        Form8283Section::B => "qualified appraisal".to_string(),
409        Form8283Section::A => String::new(),
410    };
411
412    let mut keyed: Vec<(TaxDate, &EventId, &LotId, Form8283Row)> = Vec::new();
413    for r in state
414        .removals
415        .iter()
416        .filter(|r| r.kind == RemovalKind::Donation && r.removed_at.year() == year)
417    {
418        // The FIRST leg (smallest lot_id; `min_by` returns the first minimum, so it is unique even
419        // if two legs shared a lot_id) alone carries the per-donation section + claimed_deduction
420        // + fmv_method (the first-leg / carrier-row convention).
421        let carrier_idx: Option<usize> = r
422            .legs
423            .iter()
424            .enumerate()
425            .min_by(|(_, a): &(usize, &RemovalLeg), (_, b)| a.lot_id.cmp(&b.lot_id))
426            .map(|(i, _)| i);
427        for (i, leg) in r.legs.iter().enumerate() {
428            let is_first = Some(i) == carrier_idx;
429            let d = if is_first {
430                details.get(&r.event)
431            } else {
432                None
433            };
434            let row = Form8283Row {
435                section: is_first.then_some(section),
436                description: btc_amount_description(leg.sat),
437                how_acquired: how_acquired_from(leg.basis_source),
438                date_acquired: leg.acquired_at,
439                date_contributed: r.removed_at,
440                cost_basis: leg.basis,
441                fmv: leg.fmv_at_transfer,
442                claimed_deduction: if is_first { r.claimed_deduction } else { None },
443                fmv_method: if is_first {
444                    d.and_then(|d| d.fmv_method_override.clone())
445                        .unwrap_or_else(|| carrier_fmv_method.clone())
446                } else {
447                    String::new()
448                },
449                donee: if is_first {
450                    d.map(|d| d.donee_name.clone())
451                        .unwrap_or_else(|| r.donee.clone().unwrap_or_default())
452                } else {
453                    String::new()
454                },
455                appraiser: if is_first {
456                    d.map(|d| d.appraiser_name.clone()).unwrap_or_default()
457                } else {
458                    String::new()
459                },
460                needs_review: if is_first {
461                    d.is_none_or(|d| !d.is_review_complete(section))
462                } else {
463                    true
464                },
465                details: if is_first { d.cloned() } else { None },
466            };
467            keyed.push((r.removed_at, &r.event, &leg.lot_id, row));
468        }
469    }
470    keyed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(b.1)).then(a.2.cmp(b.2)));
471    keyed.into_iter().map(|(_, _, _, r)| r).collect()
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn description_is_exact_decimal_8dp() {
480        // 0.53 BTC, exact — must be "0.53000000 BTC" (NOT a float artifact).
481        assert_eq!(btc_amount_description(53_000_000), "0.53000000 BTC");
482        // one satoshi → smallest representable unit at 8dp.
483        assert_eq!(btc_amount_description(1), "0.00000001 BTC");
484        // whole BTC.
485        assert_eq!(btc_amount_description(SATS_PER_BTC), "1.00000000 BTC");
486        // an amount a binary float cannot represent exactly is still exact here.
487        assert_eq!(btc_amount_description(12_345_678), "0.12345678 BTC");
488    }
489}