btctax-core 0.10.0

Offline US Bitcoin tax engine — per-lot cost basis, realized gains, and IRS-form projection (part of btctax).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
//! Rate-application primitives (Sub-project B, Task 3): the exact-`Decimal` arithmetic core.
//!
//! Two pure functions:
//! - [`ordinary_tax_on`] — progressive marginal-bracket tax on ordinary taxable income.
//! - [`preferential_tax`] — §1(h) 0/15/20% preferential stacking (long-term gain + qualified
//!   dividends sit ON TOP of ordinary income for breakpoint placement), returning a [`PrefSplit`].
//!
//! **Exactness/determinism (NFR4/NFR5):** all math is `Decimal`; there is **no float** anywhere
//! (every rate is a `Decimal` literal). This is the **exact marginal-bracket formula method at cent
//! precision** — NOT the IRS binned Tax Tables and NOT whole-dollar rounding — with `ROUND_HALF_EVEN`
//! to cents applied at the END only (the project's canonical `round_cents`).
use crate::conventions::{round_cents, Usd};
use crate::event::{IncomeKind, LedgerEvent};
use crate::state::{Blocker, BlockerKind, LedgerState, Severity, Term};
use crate::tax::tables::{
    loss_limit, niit_threshold, LtcgBreakpoints, OrdinarySchedule, TaxTables, NIIT_RATE,
};
use crate::tax::types::{Carryforward, MarginalRates, TaxOutcome, TaxProfile, TaxResult};

/// Exact marginal-bracket tax on `taxable` (≥ 0). Sums (min(taxable, next_lower) − lower) × rate over each
/// bracket the income reaches; the open-ended top bracket has no upper bound. ROUND_HALF_EVEN to cents at
/// the END only (NFR5). NOT the IRS binned Tax Tables and NOT whole-dollar rounding — the exact formula
/// method at cent precision (deliberate determinism/exactness choice).
pub fn ordinary_tax_on(schedule: &OrdinarySchedule, taxable: Usd) -> Usd {
    if taxable <= Usd::ZERO {
        return Usd::ZERO;
    }
    let b = &schedule.brackets;
    let mut tax = Usd::ZERO;
    for (i, br) in b.iter().enumerate() {
        if taxable <= br.lower {
            break;
        }
        let upper = b.get(i + 1).map(|n| n.lower).unwrap_or(taxable); // open-ended top
        let span_top = if taxable < upper { taxable } else { upper };
        tax += (span_top - br.lower) * br.rate;
    }
    round_cents(tax)
}

/// The §1(h) preferential-rate split: how many preferential dollars land in each rate zone, plus the tax.
///
/// `#[non_exhaustive]`: surfaced on `TaxResult` (task #43); external crates read its fields but must
/// never construct or exhaustively match it (future-proofs later additions; a 0.4.0 breaking cycle).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrefSplit {
    pub at_0: Usd,
    pub at_15: Usd,
    pub at_20: Usd,
    pub tax: Usd,
}

/// §1(h) stacking: preferential income `pref` (= QD + net LT gain) sits ON TOP of `bottom` (ordinary
/// taxable income incl. net ST gain). Breakpoints are compared against TOTAL taxable income (bottom+pref);
/// ordinary income fills the bottom of the stack first. Exact Decimal; ROUND_HALF_EVEN at the end.
pub fn preferential_tax(bp: &LtcgBreakpoints, bottom: Usd, pref: Usd) -> PrefSplit {
    let z = Usd::ZERO;
    if pref <= z {
        return PrefSplit {
            at_0: z,
            at_15: z,
            at_20: z,
            tax: z,
        };
    }
    let bottom = if bottom < z { z } else { bottom };
    let top = bottom + pref;
    let clamp = |v: Usd| if v < z { z } else { v };
    // 0% zone: pref dollars below max_zero
    let at_0 = {
        let room = clamp(bp.max_zero - bottom);
        if room < pref {
            room
        } else {
            pref
        }
    };
    // 15% zone: (max_zero, max_fifteen]
    let lower15 = if bottom > bp.max_zero {
        bottom
    } else {
        bp.max_zero
    };
    let upper15 = if top < bp.max_fifteen {
        top
    } else {
        bp.max_fifteen
    };
    let at_15 = clamp(upper15 - lower15);
    let at_20 = pref - at_0 - at_15; // remainder above max_fifteen
    let tax = round_cents(at_15 * dec_15() + at_20 * dec_20());
    PrefSplit {
        at_0,
        at_15,
        at_20,
        tax,
    }
}
fn dec_15() -> Usd {
    rust_decimal_macros::dec!(0.15)
}
fn dec_20() -> Usd {
    rust_decimal_macros::dec!(0.20)
}

/// The result of §1222 ST/LT netting + the §1211/§1212(b) loss limit, by character.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapNet {
    /// §1222(5)/(6): within-character net short-term (signed; after `cf_short`).
    pub st_net: Usd,
    /// §1222(7)/(8): within-character net long-term (signed; after `other_lt` & `cf_long`).
    pub lt_net: Usd,
    /// Net short-term gain surviving cross-net (≥0) → ordinary rates.
    pub ordinary_gain: Usd,
    /// §1222(11) net capital gain surviving cross-net (≥0) → §1(h) preferential rates.
    pub preferential_gain: Usd,
    /// §1211(b) ordinary offset used this year (≥0).
    pub loss_deduction: Usd,
    /// §1212(b) short-term carryforward out (≥0).
    pub st_carry: Usd,
    /// §1212(b) long-term carryforward out (≥0).
    pub lt_carry: Usd,
}

/// §1222 ST/LT netting + the §1211/§1212(b) capital-loss limit.
///
/// Inputs are signed: gains positive, losses negative for `crypto_st`/`crypto_lt`/`other_lt`.
/// `cf_short`/`cf_long` are prior-year carryforward LOSS magnitudes (≥0) — they REDUCE the matching
/// character. `loss_limit` is the statutory §1211(b) cap ($3,000 / $1,500 MFS).
///
/// Steps: (1) §1222(5)–(8) within-character netting, treating each prior-year carryforward as a loss of
/// its own character; (2) cross-net a gain in one character against a loss in the other, the residual
/// loss retaining the character it survived in; (3) §1211(b) deduct up to `loss_limit` against ordinary
/// income in a net-loss year; (4) §1212(b) carry the remainder forward by character, the deduction
/// absorbed **short-term-first** (the §1212(b)(2) deemed-short-term-gain ordering).
pub fn net_1222(
    crypto_st: Usd,
    crypto_lt: Usd,
    other_lt: Usd,
    cf_short: Usd,
    cf_long: Usd,
    loss_limit: Usd,
) -> CapNet {
    let z = Usd::ZERO;
    // §1222(5)/(6): within-character net short-term (carryforward-in is a short-term loss → subtract).
    let st_net = crypto_st - cf_short;
    // §1222(7)/(8): within-character net long-term (other_net_capital_gain is LT-character; cf_long subtracts).
    let lt_net = crypto_lt + other_lt - cf_long;

    // Cross-net a gain in one character against a loss in the other (§1222 / Schedule D line 16).
    let (st2, lt2) = match (st_net >= z, lt_net >= z) {
        (true, true) | (false, false) => (st_net, lt_net), // both gains, or both losses: no cross-net
        (true, false) => {
            // ST gain, LT loss
            if -lt_net <= st_net {
                (st_net + lt_net, z)
            } else {
                (z, st_net + lt_net)
            }
        }
        (false, true) => {
            // ST loss, LT gain
            if -st_net <= lt_net {
                (z, lt_net + st_net)
            } else {
                (st_net + lt_net, z)
            }
        }
    };
    let ordinary_gain = if st2 > z { st2 } else { z };
    let preferential_gain = if lt2 > z { lt2 } else { z };
    let net_st_loss = if st2 < z { -st2 } else { z };
    let net_lt_loss = if lt2 < z { -lt2 } else { z };
    let net_loss = net_st_loss + net_lt_loss;

    // §1211(b) limit + §1212(b) ST-first absorption, character-preserving carryforward (M3).
    let loss_deduction = if net_loss < loss_limit {
        net_loss
    } else {
        loss_limit
    };
    let absorbed_st = if net_st_loss < loss_deduction {
        net_st_loss
    } else {
        loss_deduction
    };
    let absorbed_lt = loss_deduction - absorbed_st;
    CapNet {
        st_net,
        lt_net,
        ordinary_gain,
        preferential_gain,
        loss_deduction,
        st_carry: net_st_loss - absorbed_st,
        lt_carry: net_lt_loss - absorbed_lt,
    }
}

/// §B.3/§B.4 ASSEMBLY: compute the crypto-attributable federal tax for `year`.
///
/// Returns `Computed(TaxResult)` or `NotComputable(Blocker)`. Refusal precedence is deterministic:
/// (1) ANY unresolved `severity()==Hard` blocker anywhere in the projection → `TaxYearNotComputable`;
/// (2) no bundled table for `year` → `TaxTableMissing`; (3) no `TaxProfile` for `year` → `TaxProfileMissing`.
///
/// **Incremental delta (I5).** The objective `total_federal_tax_attributable` is the federal tax the crypto
/// items *cause*: `tax(profile WITH app-computed crypto) − tax(profile WITHOUT)`, ceteris paribus on the
/// minimal profile. `net_1222` is run twice (with/without crypto), each scenario priced through the §1
/// ordinary stack + §1(h) preferential stack + §1411 NIIT, and the scenarios are subtracted. By field:
/// `ltcg_tax`, `niit`, and `total_federal_tax_attributable` are crypto-attributable **deltas**; `st_net`,
/// `lt_net`, `ordinary_from_crypto`, `loss_deduction`, `carryforward_out`, and `marginal_rates` describe the
/// **WITH-crypto** filing position (`carryforward_out` MUST be a level — it feeds next year's `carryforward_in`).
///
/// **Double-count guard (I5).** `profile.ordinary_taxable_income` EXCLUDES all app-computed crypto items;
/// crypto ordinary income (mining/staking/etc.) is added onto the ordinary stack exactly **once** (in the WITH
/// scenario's `bottom`) and is reported back as `ordinary_from_crypto`.
///
/// **§1411 (B-M1).** NII is `QD + surviving net capital gains (ST+LT) − the §1211(b)-allowed net capital
/// loss` (≤ $3,000 / $1,500 MFS — matching Form 8960 line 5a / §1.1411-4(d)) **plus** crypto-lending
/// **interest** income (§1411(c)(1)(A)(i), NII). Crypto **ordinary** income (mining/staking/airdrops/rewards)
/// is in MAGI but correctly EXCLUDED from NII (SE income per §1411(c)(6), or non-NII "other income"). NIIT =
/// 3.8% × max(0, min(NII, MAGI − threshold)) (floored at $0; never negative). MAGI gets only the crypto
/// **delta** added (so the non-crypto QD/cap-gain already in `magi_excluding_crypto` is never double-counted).
///
/// The pinned identity `total == (ord_with − ord_without) + ltcg_tax + niit` holds exactly (all cent-rounded
/// Decimal sums; no float).
///
/// **`events` parameter:** accepted for projection-source symmetry with `score_assignment` (the
/// optimizer's clone-fold-score path), which passes the original event slice through to every
/// `compute_tax_year` call so callers share a single signature. The refusal gate reads
/// `state.blockers` (not `events`) — `events` is not consulted in this function.
pub fn compute_tax_year(
    events: &[LedgerEvent],
    state: &LedgerState,
    year: i32,
    profile: Option<&TaxProfile>,
    tables: &dyn TaxTables,
) -> TaxOutcome {
    let _ = events;

    // (1) §B.4 refusal (B-I1): ANY unresolved severity()==Hard blocker ANYWHERE in the projection gates
    // EVERY year — deliberately conservative. An out-of-year unresolved `ImportConflict`/`DecisionConflict`
    // can leave a disputed-basis lot that a later disposal consumes (the lot is not `basis_pending`, so it
    // never re-triggers an in-year `FmvMissing`); any open Hard blocker means the basis foundation is unsound,
    // so B refuses rather than present an authoritative-but-wrong number.
    if let Some(b) = first_hard_blocker(state) {
        let evt = b
            .event
            .as_ref()
            .map(|e| e.canonical())
            .unwrap_or_else(|| "-".into());
        return TaxOutcome::NotComputable(Blocker {
            kind: BlockerKind::TaxYearNotComputable,
            event: b.event.clone(), // B-N1: carry the structured offending EventId for downstream C
            detail: format!(
                "year {year} not computable: unresolved Hard blocker [{:?}] {} :: {}",
                b.kind, evt, b.detail
            ),
        });
    }
    // (2) §B.2 missing table.
    let Some(table) = tables.table_for(year) else {
        return TaxOutcome::NotComputable(Blocker {
            kind: BlockerKind::TaxTableMissing,
            event: None,
            detail: format!("no bundled tax table for {year}"),
        });
    };
    // (3) §B.1 missing profile.
    let Some(profile) = profile else {
        return TaxOutcome::NotComputable(Blocker {
            kind: BlockerKind::TaxProfileMissing,
            event: None,
            detail: format!("no tax_profile set for {year}"),
        });
    };

    let status = profile.filing_status;
    let limit = loss_limit(status);
    let sched = table.ordinary_for(status);
    let bp = *table.ltcg_for(status);
    let thr = niit_threshold(status);

    // ── crypto inputs for the year (this year's disposals, filtered by tax-year) ────────────────────
    let mut crypto_st = Usd::ZERO;
    let mut crypto_lt = Usd::ZERO;
    for d in state
        .disposals
        .iter()
        .filter(|d| d.disposed_at.year() == year)
    {
        for leg in &d.legs {
            match leg.term {
                Term::ShortTerm => crypto_st += leg.gain,
                Term::LongTerm => crypto_lt += leg.gain,
            }
        }
    }
    // Crypto ordinary income (mining/staking/interest/airdrop/reward): every IncomeKind is ordinary at FMV.
    let crypto_ord: Usd = state
        .income_recognized
        .iter()
        .filter(|i| i.recognized_at.year() == year)
        .map(|i| i.usd_fmv)
        .sum();
    // §1411(c)(1)(A)(i): crypto-lending interest IS NII. Isolated from the rest of crypto_ord
    // (mining/staking/airdrops/rewards stay excluded — SE income per §1411(c)(6) or non-NII other income).
    // WITH-scenario only, per the crypto_ord attribution convention (the WITHOUT scenario = no crypto):
    // adding it to both scenarios would cancel out of the r.niit delta and hide the liability.
    let interest_nii: Usd = state
        .income_recognized
        .iter()
        .filter(|i| i.recognized_at.year() == year && i.kind == IncomeKind::Interest)
        .map(|i| i.usd_fmv)
        .sum();

    let cf = profile.capital_loss_carryforward_in;
    // ── two scenarios: §1222 netting WITH and WITHOUT crypto ───────────────────────────────────────
    let with = net_1222(
        crypto_st,
        crypto_lt,
        profile.other_net_capital_gain,
        cf.short,
        cf.long,
        limit,
    );
    let without = net_1222(
        Usd::ZERO,
        Usd::ZERO,
        profile.other_net_capital_gain,
        cf.short,
        cf.long,
        limit,
    );

    let qd = profile.qualified_dividends_and_other_pref_income;
    // §1 ordinary stack: crypto ordinary income + surviving net ST gain on top of profile ordinary income,
    // less the §1211 loss deduction. Crypto ordinary income is added EXACTLY ONCE (WITH scenario only).
    let bottom_with =
        profile.ordinary_taxable_income + crypto_ord + with.ordinary_gain - with.loss_deduction;
    let bottom_without =
        profile.ordinary_taxable_income + without.ordinary_gain - without.loss_deduction;
    let ord_with = ordinary_tax_on(sched, bottom_with);
    let ord_without = ordinary_tax_on(sched, bottom_without);
    // §1(h): QD shares the 0/15/20 preferential stack with surviving net capital gain. Keep the FULL
    // WITH-scenario split (not just `.tax`) so it can be surfaced on `TaxResult.pref_split` — the values
    // used here are the ones a planner reads; nothing about the tax math changes.
    let pref_with_split = preferential_tax(&bp, bottom_with, qd + with.preferential_gain);
    let pref_with = pref_with_split.tax;
    let pref_without = preferential_tax(&bp, bottom_without, qd + without.preferential_gain).tax;

    // §1411 NIIT. NII = QD + surviving net capital gains (ST+LT) − the §1211(b)-allowed net capital loss
    // (B-M1). Per Form 8960 line 5a / §1.1411-4(d) (Example 1), a net capital loss reduces NII by ONLY the
    // §1211-limited amount (≤ $3,000 / $1,500 MFS) — not by wiping out other-category gains. In a gain year
    // `loss_deduction == 0`, so this is a no-op; in a net-loss year the surviving gains are 0, so NII becomes
    // `qd − loss_deduction`. Crypto-lending INTEREST (§1411(c)(1)(A)(i)) is INCLUDED in NII (`interest_nii`,
    // WITH-scenario only). Mining/staking/airdrops/rewards remain EXCLUDED from NII — SE income per
    // §1411(c)(6), or non-NII "other income". NIIT = 3.8% × max(0, min(NII, MAGI − threshold)).
    let nii_with =
        qd + with.ordinary_gain + with.preferential_gain - with.loss_deduction + interest_nii;
    let nii_without =
        qd + without.ordinary_gain + without.preferential_gain - without.loss_deduction;
    // `magi_excluding_crypto` already includes QD + non-crypto cap gain; add ONLY the crypto AGI delta.
    let crypto_agi = (with.ordinary_gain + with.preferential_gain - with.loss_deduction)
        - (without.ordinary_gain + without.preferential_gain - without.loss_deduction)
        + crypto_ord;
    let magi_without = profile.magi_excluding_crypto;
    let magi_with = magi_without + crypto_agi;
    let niit = |nii: Usd, magi: Usd| -> Usd {
        let over = if magi > thr { magi - thr } else { Usd::ZERO };
        let capped = if nii < over { nii } else { over };
        // D2: floor the base at $0 — a negative NII (e.g. `qd < loss_deduction`) must NEVER produce a
        // negative/refundable NIIT. NIIT = 3.8% × max(0, min(NII, MAGI − threshold)).
        let base = if capped > Usd::ZERO {
            capped
        } else {
            Usd::ZERO
        };
        round_cents(base * NIIT_RATE)
    };
    let niit_with = niit(nii_with, magi_with);
    let niit_without = niit(nii_without, magi_without);

    // Incremental delta (I5): tax WITH crypto − tax WITHOUT. Exact (cent-rounded Decimal arithmetic).
    let total = (ord_with + pref_with + niit_with) - (ord_without + pref_without + niit_without);

    let top = bottom_with + qd + with.preferential_gain;
    let marginal_rates = MarginalRates {
        ordinary: marginal_ordinary_rate(sched, bottom_with),
        ltcg: if top <= bp.max_zero {
            Usd::ZERO
        } else if top <= bp.max_fifteen {
            dec_15()
        } else {
            dec_20()
        },
        niit_applies: niit_with > niit_without,
    };

    TaxOutcome::Computed(TaxResult {
        st_net: with.st_net,
        lt_net: with.lt_net,
        ordinary_from_crypto: crypto_ord,
        ltcg_tax: pref_with - pref_without, // crypto-attributable preferential tax (DELTA)
        niit: niit_with - niit_without,     // crypto-attributable §1411 NIIT (DELTA)
        loss_deduction: with.loss_deduction, // WITH-scenario level (drives carryforward_out)
        carryforward_out: Carryforward {
            short: with.st_carry,
            long: with.lt_carry,
        },
        total_federal_tax_attributable: total, // = (ord_with−ord_without) + ltcg_tax + niit
        marginal_rates,
        pref_split: pref_with_split, // the WITH-scenario §1(h) split actually used above (surfaced)
        bottom_with, // the WITH-scenario ordinary-stack bottom the pref stack sits on
    })
}

/// Highest ordinary bracket rate the income reaches (the rate on its last dollar). Display-only (B-M4):
/// uses `taxable > br.lower`, so exactly at a bracket boundary it reports the LOWER bracket's rate.
fn marginal_ordinary_rate(sched: &OrdinarySchedule, taxable: Usd) -> Usd {
    let mut r = Usd::ZERO;
    for br in &sched.brackets {
        if taxable > br.lower {
            r = br.rate;
        } else {
            break;
        }
    }
    r
}

/// M4: compare the declared carryforward-in for a year against the prior year's computed
/// carryforward-out. Returns a human warning when they differ; `None` when they match or the
/// prior year is unavailable. Non-gating advisory — caller must never use this to block
/// computation or change the exit code (Task 10 / B.5).
pub fn carryforward_consistency(
    prior_out: Option<&Carryforward>,
    this_in: &Carryforward,
) -> Option<String> {
    match prior_out {
        Some(p) if p != this_in => Some(format!(
            "carryforward_in (short {} / long {}) does not match prior-year carryforward_out \
             (short {} / long {}) — verify your prior return",
            this_in.short, this_in.long, p.short, p.long
        )),
        _ => None,
    }
}

/// §B.4 (B-I1): the projection-wide Hard-blocker gate. Returns the FIRST unresolved blocker whose
/// `severity() == Severity::Hard`, anywhere in `state.blockers`. `state.blockers` is in deterministic
/// projection order (NFR4) and `.find` returns the first, so the chosen blocker — hence the
/// `TaxYearNotComputable` detail/event — is deterministic.
fn first_hard_blocker(state: &LedgerState) -> Option<&Blocker> {
    state
        .blockers
        .iter()
        .find(|b| b.kind.severity() == Severity::Hard)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tax::tables::{LtcgBreakpoints, OrdinaryBracket, OrdinarySchedule};
    use rust_decimal_macros::dec;

    fn sched() -> OrdinarySchedule {
        OrdinarySchedule {
            brackets: vec![
                OrdinaryBracket {
                    lower: dec!(0),
                    rate: dec!(0.10),
                },
                OrdinaryBracket {
                    lower: dec!(10000),
                    rate: dec!(0.20),
                },
                OrdinaryBracket {
                    lower: dec!(40000),
                    rate: dec!(0.30),
                },
            ],
        }
    }

    #[test]
    fn ordinary_tax_sums_marginal_brackets_exactly() {
        // 0 → 0
        assert_eq!(ordinary_tax_on(&sched(), dec!(0)), dec!(0.00));
        // exactly at a boundary: $10,000 all at 10% = $1,000.00
        assert_eq!(ordinary_tax_on(&sched(), dec!(10000)), dec!(1000.00));
        // $25,000 = 10%·10,000 + 20%·15,000 = 1,000 + 3,000 = 4,000.00
        assert_eq!(ordinary_tax_on(&sched(), dec!(25000)), dec!(4000.00));
        // into the open-ended top: $50,000 = 1,000 + 20%·30,000(=6,000) + 30%·10,000(=3,000) = 10,000.00
        assert_eq!(ordinary_tax_on(&sched(), dec!(50000)), dec!(10000.00));
    }

    fn bp() -> LtcgBreakpoints {
        LtcgBreakpoints {
            max_zero: dec!(48350),
            max_fifteen: dec!(533400),
        }
    }

    #[test]
    fn preferential_zero_then_fifteen() {
        // bottom 40,000 ordinary, pref 20,000 LT → 8,350 @ 0%, 11,650 @ 15% = 1,747.50
        let s = preferential_tax(&bp(), dec!(40000), dec!(20000));
        assert_eq!(s.at_0, dec!(8350));
        assert_eq!(s.at_15, dec!(11650));
        assert_eq!(s.at_20, dec!(0));
        assert_eq!(s.tax, dec!(1747.50));
    }

    #[test]
    fn preferential_fifteen_then_twenty() {
        // bottom 500,000 ordinary, pref 100,000 → 33,400 @ 15% + 66,600 @ 20% = 5,010 + 13,320 = 18,330.00
        let s = preferential_tax(&bp(), dec!(500000), dec!(100000));
        assert_eq!(s.at_0, dec!(0));
        assert_eq!(s.at_15, dec!(33400));
        assert_eq!(s.at_20, dec!(66600));
        assert_eq!(s.tax, dec!(18330.00));
    }

    #[test]
    fn preferential_all_zero_when_under_max_zero() {
        // bottom 10,000, pref 20,000, top 30,000 < 48,350 → all 0%
        let s = preferential_tax(&bp(), dec!(10000), dec!(20000));
        assert_eq!(s.at_0, dec!(20000));
        assert_eq!(s.tax, dec!(0.00));
    }

    #[test]
    fn preferential_zero_pref_is_zero_tax() {
        assert_eq!(
            preferential_tax(&bp(), dec!(100000), dec!(0)).tax,
            dec!(0.00)
        );
    }
}

#[cfg(test)]
mod net_tests {
    use super::*;
    use rust_decimal_macros::dec;
    fn lim() -> Usd {
        dec!(3000)
    }

    #[test]
    fn both_gains_no_crossnet() {
        let n = net_1222(dec!(5000), dec!(8000), dec!(0), dec!(0), dec!(0), lim());
        assert_eq!(n.ordinary_gain, dec!(5000));
        assert_eq!(n.preferential_gain, dec!(8000));
        assert_eq!(n.loss_deduction, dec!(0));
    }

    #[test]
    fn within_character_then_crossnet_order() {
        // ST gain 10,000; LT loss 4,000 → LT loss offsets ST gain → net ST gain 6,000, no preferential.
        let n = net_1222(dec!(10000), dec!(-4000), dec!(0), dec!(0), dec!(0), lim());
        assert_eq!(n.st_net, dec!(10000));
        assert_eq!(n.lt_net, dec!(-4000));
        assert_eq!(n.ordinary_gain, dec!(6000));
        assert_eq!(n.preferential_gain, dec!(0));
        assert_eq!(n.loss_deduction, dec!(0));
    }

    #[test]
    fn st_loss_offsets_lt_gain_to_preferential() {
        // ST loss 3,000; LT gain 9,000 → net capital gain 6,000 (preferential), no ordinary.
        let n = net_1222(dec!(-3000), dec!(9000), dec!(0), dec!(0), dec!(0), lim());
        assert_eq!(n.ordinary_gain, dec!(0));
        assert_eq!(n.preferential_gain, dec!(6000));
    }

    #[test]
    fn loss_year_3k_limit_st_first_carryforward() {
        // ST loss 5,000; LT loss 2,000 → total loss 7,000; deduct 3,000 (ST-first); carry 2,000 ST + 2,000 LT.
        let n = net_1222(dec!(-5000), dec!(-2000), dec!(0), dec!(0), dec!(0), lim());
        assert_eq!(n.loss_deduction, dec!(3000));
        assert_eq!(n.st_carry, dec!(2000)); // §1212(b): the $3k came out of ST loss first
        assert_eq!(n.lt_carry, dec!(2000));
    }

    #[test]
    fn loss_limit_is_mfs_1500() {
        let n = net_1222(dec!(-5000), dec!(0), dec!(0), dec!(0), dec!(0), dec!(1500));
        assert_eq!(n.loss_deduction, dec!(1500));
        assert_eq!(n.st_carry, dec!(3500));
        assert_eq!(n.lt_carry, dec!(0));
    }

    #[test]
    fn multi_year_carryforward_preserves_character() {
        // Year 1: ST loss 5,000 + LT loss 2,000 → carry {short:2000, long:2000} (from prior test).
        let y1 = net_1222(dec!(-5000), dec!(-2000), dec!(0), dec!(0), dec!(0), lim());
        // Year 2: LT gain 10,000, no crypto ST; carry-in {short:2000, long:2000}.
        // st_net = 0 - 2000 = -2000; lt_net = 10000 - 2000 = 8000; cross-net: ST loss offsets LT gain →
        // preferential 6,000, no loss.
        let y2 = net_1222(
            dec!(0),
            dec!(10000),
            dec!(0),
            y1.st_carry,
            y1.lt_carry,
            lim(),
        );
        assert_eq!(y2.preferential_gain, dec!(6000));
        assert_eq!(y2.ordinary_gain, dec!(0));
        assert_eq!(y2.loss_deduction, dec!(0));
    }

    #[test]
    fn st_loss_only_3k_all_st_character() {
        let n = net_1222(dec!(-10000), dec!(0), dec!(0), dec!(0), dec!(0), lim());
        assert_eq!(n.loss_deduction, dec!(3000));
        assert_eq!(n.st_carry, dec!(7000));
        assert_eq!(n.lt_carry, dec!(0));
    }
}

/// M4 carryforward-consistency unit tests (Task 10).
#[cfg(test)]
mod consistency_tests {
    use super::*;
    use crate::tax::types::Carryforward;
    use rust_decimal_macros::dec;

    /// A carryforward_in that matches prior carryforward_out → no warning (None).
    #[test]
    fn carryforward_match_is_silent() {
        let c = Carryforward {
            short: dec!(2000),
            long: dec!(2000),
        };
        assert_eq!(carryforward_consistency(Some(&c), &c), None);
    }

    /// A carryforward_in that differs from prior carryforward_out → warning containing "does not match".
    #[test]
    fn carryforward_mismatch_warns() {
        let prior = Carryforward {
            short: dec!(2000),
            long: dec!(2000),
        };
        let declared = Carryforward {
            short: dec!(0),
            long: dec!(2000),
        };
        assert!(carryforward_consistency(Some(&prior), &declared)
            .unwrap()
            .contains("does not match"));
    }

    /// No prior-year data (prior_out == None) → None regardless of this_in.
    #[test]
    fn no_prior_is_silent() {
        assert_eq!(
            carryforward_consistency(None, &Carryforward::default()),
            None
        );
    }
}