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}