Files
wanderbooks/crates/ledger-core/tests/spec_tax_basis.rs
T
swaitsandClaude Opus 5.5 6e6f9d65b2
check / check (push) Waiting to run
Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
- core: TaxBasisRule (cost IRC 1012, carryover IRC 723, personal-use conversion Treas. Reg. 1.168(i)-4(b)), TaxBasis with the contributor's adjusted basis, Asset::tax_basis and ::built_in_gain (IRC 704(c)); the register gains tax rule, contributor basis, tax basis, built-in gain, total tax basis, missing count (rules.md B-25; sources vendored in docs/spec/sources/irs-partnership-basis.md)
- store: asset.tax_basis_set journaled, replayed, projected (schema v10)
- mcp: books.set_asset_tax_basis; register_asset takes tax_basis_rule and contributor_basis, checked before anything registers (found by the spec-blind suite); list_assets and the register export (json, csv) carry tax basis; instructions say to record it for every asset
- import: the register's XLSX and PDF gain the federal tax basis table with citations
- viewer: tax basis on the Assets page, the asset page (a Tax basis card), the report, the journal, and Needs attention for assets without one
- just: check and the mutation recipes sweep this user's test book dirs older than an hour (killed runs had left 23 GB in RAM-backed /tmp)
- docs: the parties, attachments, TIN vault, completeness engine, and updates designs (docs/parties-and-attachments.md)
- tests: spec-blind tax basis suites in core, store, MCP; register PDF/XLSX and viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:30:09 -06:00

694 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Spec-derived tests for an asset's federal tax basis (rules.md B-25):
//! `TaxBasisRule`, `Ledger::set_asset_tax_basis` / `check_asset_tax_basis`,
//! `Asset::tax_basis`, `Asset::built_in_gain`, and the tax fields of
//! `reports::fixed_asset_register`.
//!
//! Every expected value comes from the written specification: the doc
//! comments and INVARIANTS/SCENARIOS blocks on those items, and the
//! vendored sources (`docs/spec/sources/irs-partnership-basis.md`): cost
//! basis (IRC §1012(a)), carryover of the contributor's adjusted basis
//! (IRC §723), the lesser of fair market value and adjusted basis on a
//! conversion from personal use (Treas. Reg. §1.168(i)-4(b)(1)), and
//! built-in gain or loss as fair market value less the contributor's basis
//! (IRC §704(c)). Amounts are worked by hand.
use std::str::FromStr;
use chrono::NaiveDate;
use ledger_core::{
AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side,
TaxBasis, TaxBasisRule, fixed_asset_register,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
// ---------------------------------------------------------------- helpers
fn d(s: &str) -> Decimal {
Decimal::from_str(s).expect("decimal literal")
}
fn usd(s: &str) -> Money {
Money::new(d(s), Currency::USD)
}
fn date(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).expect("valid date")
}
struct Accts {
equipment: AccountId,
accum: AccountId,
depr_expense: AccountId,
}
/// A USD ledger with a fixed-asset chart, funded with $50,000 cash.
fn fixture() -> (Ledger, Accts) {
let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, name: &str, ty: AccountType| {
l.add_account(code, name, ty, None, false).unwrap()
};
let cash = acct("1000", "Cash", AccountType::Asset);
let equipment = acct("1500", "Equipment", AccountType::Asset);
let accum = acct("1510", "Accumulated Depreciation", AccountType::Asset);
let capital = acct("3000", "Owner Capital", AccountType::Equity);
let depr_expense = acct("6200", "Depreciation Expense", AccountType::Expense);
let e = l.try_create_entry(date(2026, 1, 1), "funding").unwrap();
l.add_line(e, cash, Side::Debit, usd("50000.00")).unwrap();
l.add_line(e, capital, Side::Credit, usd("50000.00"))
.unwrap();
l.post_entry(e).unwrap();
(
l,
Accts {
equipment,
accum,
depr_expense,
},
)
}
fn register(l: &mut Ledger, a: &Accts, description: &str, cost: &str) -> AssetId {
l.register_asset(NewAsset {
description: description.to_string(),
purchase_date: date(2026, 1, 1),
cost: usd(cost),
salvage_value: usd("0.00"),
useful_life_months: 36,
cost_account: a.equipment,
accum_depr_account: a.accum,
expense_account: a.depr_expense,
})
.unwrap()
}
fn tb(rule: TaxBasisRule, basis: Option<&str>) -> TaxBasis {
TaxBasis {
rule,
contributor_basis: basis.map(d),
}
}
/// A ledger with one asset of book cost (fair market value) `cost`.
fn one(cost: &str) -> (Ledger, AssetId) {
let (mut l, a) = fixture();
let id = register(&mut l, &a, "Laptop", cost);
(l, id)
}
fn set(l: &mut Ledger, id: AssetId, rule: TaxBasisRule, basis: Option<&str>) {
l.set_asset_tax_basis(id, tb(rule, basis)).unwrap();
}
const RULES: [TaxBasisRule; 3] = [
TaxBasisRule::Cost,
TaxBasisRule::Carryover,
TaxBasisRule::PersonalUseConversion,
];
// ---------------------------------------------------------------- TaxBasisRule
/// TaxBasisRule::key: the wire names `cost`, `carryover`,
/// `personal_use_conversion`.
#[test]
fn rule_keys_are_the_documented_wire_names() {
assert_eq!(TaxBasisRule::Cost.key(), "cost");
assert_eq!(TaxBasisRule::Carryover.key(), "carryover");
assert_eq!(
TaxBasisRule::PersonalUseConversion.key(),
"personal_use_conversion"
);
}
/// TaxBasisRule::parse: the rule named `key`; round-trips with key().
#[test]
fn rule_parse_round_trips_with_key() {
for r in RULES {
assert_eq!(TaxBasisRule::parse(r.key()), Some(r));
}
}
/// Anything other than a documented wire name is no rule.
#[test]
fn rule_parse_refuses_other_names() {
for bad in [
"",
"Cost",
"COST",
" cost",
"cost ",
"carry_over",
"personal-use-conversion",
"PersonalUseConversion",
"fmv",
] {
assert_eq!(TaxBasisRule::parse(bad), None, "{bad:?}");
}
}
/// The serde names are snake_case, the same as the wire names.
#[test]
fn rule_serde_names_are_the_wire_names() {
for r in RULES {
let j = serde_json::to_value(r).unwrap();
assert_eq!(j, serde_json::Value::String(r.key().to_string()));
let back: TaxBasisRule = serde_json::from_value(j).unwrap();
assert_eq!(back, r);
}
}
// ---------------------------------------------------------------- set / check
/// SCENARIO 1: an unknown asset is refused with UnknownAsset.
#[test]
fn an_unknown_asset_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
let unknown = id + 99;
for t in [
tb(TaxBasisRule::Cost, None),
tb(TaxBasisRule::Carryover, Some("500.00")),
] {
assert!(matches!(
l.check_asset_tax_basis(unknown, t),
Err(LedgerError::UnknownAsset(x)) if x == unknown
));
assert!(matches!(
l.set_asset_tax_basis(unknown, t),
Err(LedgerError::UnknownAsset(x)) if x == unknown
));
}
assert_eq!(l, before, "INVARIANT 3");
}
/// INVARIANT 1: Cost takes no contributor basis.
#[test]
fn cost_with_a_contributor_basis_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
for b in ["0.00", "500.00", "1000.00"] {
let t = tb(TaxBasisRule::Cost, Some(b));
assert!(matches!(
l.check_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
));
assert!(matches!(
l.set_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
));
}
assert_eq!(l, before, "INVARIANT 3");
assert_eq!(l.get_asset(id).unwrap().tax, None);
}
/// INVARIANT 1: Carryover and PersonalUseConversion require one.
#[test]
fn a_contribution_without_a_contributor_basis_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
let t = tb(r, None);
assert!(matches!(
l.check_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
));
assert!(matches!(
l.set_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
));
}
assert_eq!(l, before, "INVARIANT 3");
}
/// INVARIANT 2: a contributor basis is non-negative.
#[test]
fn a_negative_contributor_basis_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
for b in ["-0.01", "-1", "-500.00"] {
let t = tb(r, Some(b));
assert!(
matches!(
l.set_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
),
"{r:?} {b}"
);
assert!(l.check_asset_tax_basis(id, t).is_err());
}
}
assert_eq!(l, before, "INVARIANT 3");
}
/// INVARIANT 2: in cents at most.
#[test]
fn more_than_two_decimal_places_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
for b in ["500.001", "0.005", "1.2345"] {
let t = tb(r, Some(b));
assert!(
matches!(
l.set_asset_tax_basis(id, t),
Err(LedgerError::InvalidTaxBasis(_))
),
"{r:?} {b}"
);
assert!(l.check_asset_tax_basis(id, t).is_err());
}
}
assert_eq!(l, before, "INVARIANT 3");
}
/// Valid records: zero is non-negative; whole dollars and one decimal
/// place are "in cents at most". check agrees with set, and a valid set
/// changes nothing but the asset's tax record (book figures don't change).
#[test]
fn valid_records_are_accepted_and_book_figures_stay() {
for (r, b) in [
(TaxBasisRule::Cost, None),
(TaxBasisRule::Carryover, Some("0")),
(TaxBasisRule::Carryover, Some("0.00")),
(TaxBasisRule::Carryover, Some("750")),
(TaxBasisRule::PersonalUseConversion, Some("750.5")),
(TaxBasisRule::PersonalUseConversion, Some("750.50")),
(TaxBasisRule::PersonalUseConversion, Some("2500.00")),
] {
let (mut l, id) = one("1000.00");
let t = tb(r, b);
l.check_asset_tax_basis(id, t).unwrap();
let before = l.get_asset(id).unwrap().clone();
l.set_asset_tax_basis(id, t).unwrap();
let after = l.get_asset(id).unwrap();
assert_eq!(after.tax, Some(t), "{r:?} {b:?}");
// Book figures don't change.
let mut same = after.clone();
same.tax = before.tax;
assert_eq!(same, before, "{r:?} {b:?}");
}
}
/// SCENARIO 2: set again, the new record replaces the old.
#[test]
fn setting_again_replaces() {
let (mut l, id) = one("1000.00");
set(&mut l, id, TaxBasisRule::Cost, None);
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("1000.00")));
set(&mut l, id, TaxBasisRule::Carryover, Some("400.00"));
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax, Some(tb(TaxBasisRule::Carryover, Some("400.00"))));
assert_eq!(a.tax_basis(), Some(d("400.00")));
set(
&mut l,
id,
TaxBasisRule::PersonalUseConversion,
Some("1200.00"),
);
let a = l.get_asset(id).unwrap();
assert_eq!(
a.tax,
Some(tb(TaxBasisRule::PersonalUseConversion, Some("1200.00")))
);
assert_eq!(a.tax_basis(), Some(d("1000.00")));
set(&mut l, id, TaxBasisRule::Cost, None);
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax, Some(tb(TaxBasisRule::Cost, None)));
assert_eq!(a.built_in_gain(), None);
}
/// INVARIANT 3: a refusal after a valid record leaves that record.
#[test]
fn a_refusal_keeps_the_earlier_record() {
let (mut l, id) = one("1000.00");
set(&mut l, id, TaxBasisRule::Carryover, Some("300.00"));
let before = l.clone();
assert!(
l.set_asset_tax_basis(id, tb(TaxBasisRule::Cost, Some("1.00")))
.is_err()
);
assert!(
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, None))
.is_err()
);
assert!(
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("-1.00")))
.is_err()
);
assert!(
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("1.001")))
.is_err()
);
assert_eq!(l, before);
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("300.00")));
}
/// Setting one asset's record leaves the others alone.
#[test]
fn a_record_belongs_to_one_asset() {
let (mut l, a) = fixture();
let x = register(&mut l, &a, "Laptop", "1000.00");
let y = register(&mut l, &a, "Desk", "400.00");
set(&mut l, x, TaxBasisRule::Carryover, Some("800.00"));
assert_eq!(l.get_asset(y).unwrap().tax, None);
assert_eq!(l.get_asset(y).unwrap().tax_basis(), None);
}
// ---------------------------------------------------------------- tax_basis
/// Not recorded: None (and no built-in gain).
#[test]
fn no_record_means_no_tax_basis() {
let (l, id) = one("1000.00");
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax, None);
assert_eq!(a.tax_basis(), None);
assert_eq!(a.built_in_gain(), None);
}
/// INVARIANT 1: Cost: the book cost (IRC §1012(a)).
#[test]
fn cost_basis_is_the_book_cost() {
let (mut l, id) = one("1234.56");
set(&mut l, id, TaxBasisRule::Cost, None);
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax_basis(), Some(d("1234.56")));
// A purchase has no built-in gain.
assert_eq!(a.built_in_gain(), None);
}
/// INVARIANT 2: Carryover: the contributor's adjusted basis (IRC §723),
/// whether above or below fair market value.
#[test]
fn carryover_basis_is_the_contributors_basis() {
for (fmv, basis, gain) in [
("1000.00", "600.00", "400.00"), // built-in gain
("1000.00", "1500.00", "-500.00"), // built-in loss
("1000.00", "1000.00", "0.00"), // boundary
("1000.00", "0.00", "1000.00"), // fully depreciated
] {
let (mut l, id) = one(fmv);
set(&mut l, id, TaxBasisRule::Carryover, Some(basis));
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax_basis(), Some(d(basis)), "{fmv} {basis}");
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
}
}
/// INVARIANT 3: PersonalUseConversion: min(book cost, contributor's basis)
/// (Treas. Reg. §1.168(i)-4(b)(1): "the lesser of its fair market value or
/// its adjusted depreciable basis").
#[test]
fn personal_use_conversion_is_the_lesser_of_value_and_basis() {
for (fmv, basis, tax, gain) in [
// Paid 2,000, worth 1,200 now: basis 1,200; built-in loss −800.
("1200.00", "2000.00", "1200.00", "-800.00"),
// Paid 300, worth 450: basis 300; built-in gain 150.
("450.00", "300.00", "300.00", "150.00"),
// Boundary: value equals basis.
("999.99", "999.99", "999.99", "0.00"),
// A cent either side of the boundary.
("1000.00", "1000.01", "1000.00", "-0.01"),
("1000.00", "999.99", "999.99", "0.01"),
] {
let (mut l, id) = one(fmv);
set(&mut l, id, TaxBasisRule::PersonalUseConversion, Some(basis));
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax_basis(), Some(d(tax)), "{fmv} {basis}");
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
}
}
/// built_in_gain: FMV (book cost) less the contributor's basis; positive
/// for a gain, negative for a loss, zero at FMV == basis, regardless of
/// which contribution rule (the definition does not depend on the rule).
#[test]
fn built_in_gain_sign_and_boundary() {
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
let (mut l, id) = one("800.00");
set(&mut l, id, r, Some("500.00"));
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
assert_eq!(g, d("300.00"));
assert!(g > Decimal::ZERO, "{r:?}: FMV > basis is a gain");
set(&mut l, id, r, Some("1100.00"));
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
assert_eq!(g, d("-300.00"));
assert!(g < Decimal::ZERO, "{r:?}: basis > FMV is a loss");
set(&mut l, id, r, Some("800.00"));
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
assert!(g.is_zero(), "{r:?}: FMV == basis is neither");
}
}
// ---------------------------------------------------------------- register
struct RegisterBooks {
l: Ledger,
bought: AssetId,
carried: AssetId,
converted: AssetId,
unrecorded: AssetId,
sold: AssetId,
}
/// Five assets: a purchase at cost, a carryover contribution, a
/// personal-use conversion, one with no tax basis, and one disposed (with
/// a tax basis, which the totals must leave out).
fn register_books() -> RegisterBooks {
let (mut l, a) = fixture();
let bought = register(&mut l, &a, "Server", "3000.00");
let carried = register(&mut l, &a, "Printer", "500.00");
let converted = register(&mut l, &a, "Laptop", "1200.00");
let unrecorded = register(&mut l, &a, "Desk", "700.00");
let sold = register(&mut l, &a, "Old monitor", "360.00");
set(&mut l, bought, TaxBasisRule::Cost, None);
set(&mut l, carried, TaxBasisRule::Carryover, Some("650.25"));
set(
&mut l,
converted,
TaxBasisRule::PersonalUseConversion,
Some("2000.00"),
);
set(&mut l, sold, TaxBasisRule::Cost, None);
l.dispose_asset(sold, date(2026, 3, 31), usd("100.00"))
.unwrap();
RegisterBooks {
l,
bought,
carried,
converted,
unrecorded,
sold,
}
}
/// INVARIANT 5: each row's tax fields are the asset's.
#[test]
fn register_rows_carry_the_assets_tax_fields() {
let b = register_books();
let r = fixed_asset_register(&b.l, date(2026, 6, 30));
assert_eq!(r.rows.len(), 5);
for row in &r.rows {
let a = b.l.get_asset(row.asset_id).unwrap();
assert_eq!(row.tax_rule, a.tax.map(|t| t.rule));
assert_eq!(
row.contributor_basis,
a.tax.and_then(|t| t.contributor_basis)
);
assert_eq!(row.tax_basis, a.tax_basis());
assert_eq!(row.built_in_gain, a.built_in_gain());
}
let row = |id| r.rows.iter().find(|x| x.asset_id == id).unwrap();
// By hand.
let x = row(b.bought);
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
assert_eq!(x.contributor_basis, None);
assert_eq!(x.tax_basis, Some(d("3000.00")));
assert_eq!(x.built_in_gain, None);
let x = row(b.carried);
assert_eq!(x.tax_rule, Some(TaxBasisRule::Carryover));
assert_eq!(x.contributor_basis, Some(d("650.25")));
assert_eq!(x.tax_basis, Some(d("650.25")));
assert_eq!(x.built_in_gain, Some(d("-150.25")));
let x = row(b.converted);
assert_eq!(x.tax_rule, Some(TaxBasisRule::PersonalUseConversion));
assert_eq!(x.contributor_basis, Some(d("2000.00")));
assert_eq!(x.tax_basis, Some(d("1200.00")));
assert_eq!(x.built_in_gain, Some(d("-800.00")));
let x = row(b.unrecorded);
assert_eq!(x.tax_rule, None);
assert_eq!(x.contributor_basis, None);
assert_eq!(x.tax_basis, None);
assert_eq!(x.built_in_gain, None);
// A disposed asset's row still shows its record.
let x = row(b.sold);
assert!(x.disposed.is_some());
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
assert_eq!(x.tax_basis, Some(d("360.00")));
}
/// INVARIANT 3: total_tax_basis sums held assets with a basis, exactly;
/// missing_tax_basis counts held ones without; disposed excluded.
#[test]
fn register_tax_totals_cover_held_assets_only() {
let b = register_books();
let r = fixed_asset_register(&b.l, date(2026, 6, 30));
// 3000.00 + 650.25 + 1200.00 (the disposed 360.00 left out).
assert_eq!(r.total_tax_basis, d("4850.25"));
assert_eq!(r.missing_tax_basis, 1);
// Before the disposal date the old monitor is still held.
let r = fixed_asset_register(&b.l, date(2026, 2, 28));
assert_eq!(r.total_tax_basis, d("5210.25"));
assert_eq!(r.missing_tax_basis, 1);
}
/// A disposed asset without a tax basis isn't counted as missing.
#[test]
fn a_disposed_asset_without_a_basis_is_not_missing() {
let (mut l, a) = fixture();
let x = register(&mut l, &a, "Laptop", "1000.00");
let y = register(&mut l, &a, "Desk", "400.00");
l.dispose_asset(y, date(2026, 3, 31), usd("0.00")).unwrap();
let r = fixed_asset_register(&l, date(2026, 6, 30));
assert_eq!(r.missing_tax_basis, 1);
assert_eq!(r.total_tax_basis, Decimal::ZERO);
set(&mut l, x, TaxBasisRule::Carryover, Some("900.00"));
let r = fixed_asset_register(&l, date(2026, 6, 30));
assert_eq!(r.missing_tax_basis, 0);
assert_eq!(r.total_tax_basis, d("900.00"));
}
/// No assets: zero total, none missing.
#[test]
fn an_empty_register_has_no_tax_basis() {
let (l, _) = fixture();
let r = fixed_asset_register(&l, date(2026, 6, 30));
assert!(r.rows.is_empty());
assert_eq!(r.total_tax_basis, Decimal::ZERO);
assert_eq!(r.missing_tax_basis, 0);
}
/// Recording tax basis leaves the book totals alone.
#[test]
fn tax_basis_does_not_move_book_totals() {
let (mut l, a) = fixture();
let x = register(&mut l, &a, "Laptop", "1200.00");
let before = fixed_asset_register(&l, date(2026, 6, 30));
set(
&mut l,
x,
TaxBasisRule::PersonalUseConversion,
Some("2000.00"),
);
let after = fixed_asset_register(&l, date(2026, 6, 30));
assert_eq!(after.total_cost, before.total_cost);
assert_eq!(after.total_accumulated, before.total_accumulated);
assert_eq!(after.total_book_value, before.total_book_value);
assert_eq!(after.ledger_cost, before.ledger_cost);
assert_eq!(after.ledger_accumulated, before.ledger_accumulated);
}
// ---------------------------------------------------------------- proptest
fn cents() -> impl Strategy<Value = Decimal> {
(0i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
}
fn positive_cents() -> impl Strategy<Value = Decimal> {
(1i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Asset::tax_basis INVARIANTS 1–3 and built_in_gain over random
/// costs, bases, and rules; register totals agree.
#[test]
fn prop_tax_basis_follows_its_rule(
assets in proptest::collection::vec((positive_cents(), cents(), 0usize..4), 1..8)
) {
let (mut l, a) = fixture();
let mut expected_total = Decimal::ZERO;
let mut expected_missing = 0usize;
for (i, (cost, basis, rule)) in assets.iter().enumerate() {
let id = register(&mut l, &a, &format!("Asset {i}"), &cost.to_string());
let want = match rule {
0 => {
set(&mut l, id, TaxBasisRule::Cost, None);
Some((*cost, None))
}
1 => {
l.set_asset_tax_basis(id, TaxBasis {
rule: TaxBasisRule::Carryover,
contributor_basis: Some(*basis),
}).unwrap();
Some((*basis, Some(*cost - *basis)))
}
2 => {
l.set_asset_tax_basis(id, TaxBasis {
rule: TaxBasisRule::PersonalUseConversion,
contributor_basis: Some(*basis),
}).unwrap();
Some((if cost < basis { *cost } else { *basis }, Some(*cost - *basis)))
}
_ => None,
};
let asset = l.get_asset(id).unwrap();
match want {
Some((tax, gain)) => {
prop_assert_eq!(asset.tax_basis(), Some(tax));
prop_assert_eq!(asset.built_in_gain(), gain);
expected_total += tax;
}
None => {
prop_assert_eq!(asset.tax_basis(), None);
expected_missing += 1;
}
}
if let Some(t) = asset.tax_basis() {
// Personal-use conversion never exceeds fair market value.
if *rule == 2 {
prop_assert!(t <= *cost);
prop_assert!(t <= *basis);
}
}
}
let r = fixed_asset_register(&l, date(2026, 6, 30));
prop_assert_eq!(r.total_tax_basis, expected_total);
prop_assert_eq!(r.missing_tax_basis, expected_missing);
}
/// check agrees with set, and a refused record changes nothing.
#[test]
fn prop_check_agrees_with_set(
cents in -1_000_000i64..1_000_000,
scale in 0u32..5,
rule in 0usize..3,
some in any::<bool>(),
) {
let (mut l, id) = one("1000.00");
let basis = some.then(|| Decimal::new(cents, scale));
let t = TaxBasis { rule: RULES[rule], contributor_basis: basis };
let before = l.clone();
let checked = l.check_asset_tax_basis(id, t);
let done = l.set_asset_tax_basis(id, t);
prop_assert_eq!(checked.is_ok(), done.is_ok());
// The spec, independently: INVARIANTS 1 and 2.
let valid = match (RULES[rule], basis) {
(TaxBasisRule::Cost, None) => true,
(TaxBasisRule::Cost, Some(_)) => false,
(_, None) => false,
(_, Some(b)) => b >= Decimal::ZERO && b.normalize().scale() <= 2,
};
prop_assert_eq!(done.is_ok(), valid, "{:?} {:?}", RULES[rule], basis);
if done.is_err() {
prop_assert_eq!(&l, &before);
} else {
prop_assert_eq!(l.get_asset(id).unwrap().tax, Some(t));
}
}
}