Files
wanderbooks/crates/ledger-core/tests/spec_asset_basis.rs
T
swaitsandClaude Opus 5.5 6288ead747 P3 review: asset bases and the tax-basis K-1
Schema 13 is as shipped and the asset columns are schema 14 (shipped
migrations pinned by hash); the tax-basis K-1 leaves net income open (only
the return has it) and takes a contributed prepaid plan at its book amount
as its section 723 basis; a disposal of contributed property names its
704(c) gain or loss; aliases compare by meaning; fixture amounts made up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 17:43:44 -06:00

1286 lines
46 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 basis facts and tax figures
//! (rules.md B-25), its two dates and its two accumulated figures
//! (rules.md B-15, B-24): `BasisRule`, `Sec704cMethod`, `AssetBasis`
//! (`check`, `check_journaled`, `check_legacy_journaled`),
//! `Ledger::set_asset_basis` / `replay_set_asset_basis` /
//! `replay_set_asset_tax_basis`, `Asset::basis_figures`,
//! `Ledger::asset_acquired_date`, the acquisition link's dates,
//! `Ledger::accumulated_posted` and the runs, 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)), the §723 basis (the contributor's adjusted basis),
//! the lesser of fair market value and adjusted basis on a conversion
//! from personal use (Treas. Reg. §1.168(i)-4(b)(1); D-41), and the
//! §704(c) built-in gain or loss as fair market value less the §723 basis
//! at contribution (Treas. Reg. §1.704-3(a)(3)(ii)). Amounts are worked by
//! hand. Every name and figure is invented.
use std::str::FromStr;
use chrono::NaiveDate;
use ledger_core::{
AccountId, AccountType, AssetBasis, AssetCorrection, AssetId, BasisRule, Currency, Ledger,
LedgerError, Money, NewAsset, Sec704cMethod, Side, 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 {
cash: AccountId,
equipment: AccountId,
accum: AccountId,
capital: 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", "Member 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 {
cash,
equipment,
accum,
capital,
depr_expense,
},
)
}
fn new_asset(a: &Accts, description: &str, cost: &str, life: u32, on: NaiveDate) -> NewAsset {
NewAsset {
description: description.to_string(),
in_service_date: on,
cost: usd(cost),
salvage_value: usd("0.00"),
useful_life_months: life,
cost_account: a.equipment,
accum_depr_account: a.accum,
expense_account: a.depr_expense,
}
}
fn register(l: &mut Ledger, a: &Accts, description: &str, cost: &str) -> AssetId {
l.register_asset(new_asset(a, description, cost, 36, date(2026, 1, 1)))
.unwrap()
}
/// 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 contribution(rule: BasisRule, adjusted: &str, fmv: &str) -> AssetBasis {
AssetBasis::contribution(rule, d(adjusted), d(fmv))
}
/// A pre-P3 record: rule and contributor basis, no value at contribution.
fn legacy(rule: BasisRule, basis: Option<&str>) -> AssetBasis {
AssetBasis {
rule,
contributor_adjusted_basis: basis.map(d),
fmv_at_contribution: None,
sec704c_method: None,
source: None,
}
}
/// Post an entry of `lines` on `on`; returns it.
fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) -> ledger_core::EntryId {
let e = l.try_create_entry(on, "entry").unwrap();
for (acct, side, amt) in lines {
l.add_line(e, *acct, *side, usd(amt)).unwrap();
}
l.post_entry(e).unwrap();
e
}
/// One run for `asset`'s month of `on`, as a store posts and records it.
fn run(
l: &mut Ledger,
a: &Accts,
asset: AssetId,
on: NaiveDate,
amt: &str,
) -> ledger_core::EntryId {
let e = post(
l,
on,
&[
(a.depr_expense, Side::Debit, amt),
(a.accum, Side::Credit, amt),
],
);
let period = on.format("%Y-%m").to_string();
l.check_depreciation_run(asset, &period, e, d(amt)).unwrap();
l.record_depreciation_run(asset, &period, e, d(amt))
.unwrap();
e
}
const RULES: [BasisRule; 3] = [
BasisRule::Purchase,
BasisRule::Carryover723,
BasisRule::PersonalUseConversion,
];
// ------------------------------------------------------------ BasisRule
/// BasisRule::key: the wire names `purchase`, `carryover_723`,
/// `personal_use_conversion`; parse round-trips with key.
#[test]
fn rule_keys_are_the_documented_wire_names() {
assert_eq!(BasisRule::Purchase.key(), "purchase");
assert_eq!(BasisRule::Carryover723.key(), "carryover_723");
assert_eq!(
BasisRule::PersonalUseConversion.key(),
"personal_use_conversion"
);
for r in RULES {
assert_eq!(BasisRule::parse(r.key()), Some(r));
}
}
/// parse takes the wire names only; parse_legacy the pre-P3 names (for
/// replay, the projection, and the tools' aliases).
#[test]
fn legacy_names_parse_only_as_legacy() {
assert_eq!(BasisRule::parse("cost"), None);
assert_eq!(BasisRule::parse("carryover"), None);
assert_eq!(BasisRule::parse_legacy("cost"), Some(BasisRule::Purchase));
assert_eq!(
BasisRule::parse_legacy("carryover"),
Some(BasisRule::Carryover723)
);
assert_eq!(
BasisRule::parse_legacy("personal_use_conversion"),
Some(BasisRule::PersonalUseConversion)
);
assert_eq!(BasisRule::parse_legacy("purchase"), None);
assert_eq!(BasisRule::parse_legacy("carryover_723"), None);
for bad in [
"",
"Purchase",
" purchase",
"carryover723",
"personal-use-conversion",
"fmv",
] {
assert_eq!(BasisRule::parse(bad), None, "{bad:?}");
assert_eq!(BasisRule::parse_legacy(bad), None, "{bad:?}");
}
}
/// The serde names are the pre-P3 ones, frozen: the books an earlier
/// version wrote are compared with the asset's serde form.
#[test]
fn rule_serde_names_are_frozen() {
for (r, name) in [
(BasisRule::Purchase, "cost"),
(BasisRule::Carryover723, "carryover"),
(BasisRule::PersonalUseConversion, "personal_use_conversion"),
] {
let j = serde_json::to_value(r).unwrap();
assert_eq!(j, serde_json::Value::String(name.into()));
assert_eq!(serde_json::from_value::<BasisRule>(j).unwrap(), r);
}
// A pre-P3 record serializes exactly as it did.
let v = serde_json::to_value(legacy(BasisRule::Carryover723, Some("900.00"))).unwrap();
assert_eq!(
v,
serde_json::json!({"rule": "carryover", "contributor_basis": "900.00"})
);
}
/// Sec704cMethod: the three methods of Treas. Reg. §1.704-3(b)-(d).
#[test]
fn methods_are_the_three_of_1_704_3() {
let keys: Vec<&str> = Sec704cMethod::ALL.iter().map(|m| m.key()).collect();
assert_eq!(
keys,
["traditional", "traditional_with_curative", "remedial"]
);
for m in Sec704cMethod::ALL {
assert_eq!(Sec704cMethod::parse(m.key()), Some(m));
let j = serde_json::to_value(m).unwrap();
assert_eq!(j, serde_json::Value::String(m.key().into()));
}
assert_eq!(
Sec704cMethod::Traditional.citation(),
"Treas. Reg. §1.704-3(b)"
);
assert_eq!(Sec704cMethod::parse("ceiling"), None);
}
// --------------------------------------------------------- check refusals
/// INVARIANT 1 (check): a purchase takes no contributor basis, value or
/// method.
#[test]
fn a_purchase_with_contribution_facts_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
let mut with_basis = AssetBasis::purchase();
with_basis.contributor_adjusted_basis = Some(d("500.00"));
let mut with_fmv = AssetBasis::purchase();
with_fmv.fmv_at_contribution = Some(d("1000.00"));
let mut with_method = AssetBasis::purchase();
with_method.sec704c_method = Some(Sec704cMethod::Traditional);
for b in [with_basis, with_fmv, with_method] {
assert!(matches!(
l.check_asset_basis(id, &b),
Err(LedgerError::InvalidTaxBasis(_))
));
assert!(matches!(
l.set_asset_basis(id, b),
Err(LedgerError::InvalidTaxBasis(_))
));
}
assert_eq!(l, before, "a refusal changes nothing");
}
/// A contribution needs both the member's adjusted basis and the value at
/// contribution (a fact the human gives, never assumed from cost).
#[test]
fn a_contribution_missing_a_fact_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
for r in [BasisRule::Carryover723, BasisRule::PersonalUseConversion] {
let mut no_basis = contribution(r, "500.00", "1000.00");
no_basis.contributor_adjusted_basis = None;
let mut no_fmv = contribution(r, "500.00", "1000.00");
no_fmv.fmv_at_contribution = None;
for b in [no_basis, no_fmv] {
assert!(matches!(
l.set_asset_basis(id, b),
Err(LedgerError::InvalidTaxBasis(_))
));
}
}
assert_eq!(l, before);
}
/// Each fact is non-negative, in cents, at most one quadrillion.
#[test]
fn a_negative_sub_cent_or_huge_fact_is_refused() {
let (mut l, id) = one("1000.00");
let before = l.clone();
let huge = (ledger_core::kernel::MAX_LINE_AMOUNT + Decimal::ONE).to_string();
for r in [BasisRule::Carryover723, BasisRule::PersonalUseConversion] {
for (adj, fmv) in [
("-0.01", "100.00"),
("100.00", "-0.01"),
("100.001", "100.00"),
("100.00", "0.005"),
(huge.as_str(), "100.00"),
("100.00", huge.as_str()),
] {
assert!(
matches!(
l.set_asset_basis(id, contribution(r, adj, fmv)),
Err(LedgerError::InvalidTaxBasis(_))
),
"{r:?} {adj} {fmv}"
);
}
}
assert_eq!(l, before);
}
/// A source must name who gave the facts (Source::check).
#[test]
fn a_source_without_a_name_is_refused() {
let (mut l, id) = one("1000.00");
let mut b = contribution(BasisRule::Carryover723, "500.00", "1000.00");
b.source = Some(ledger_core::settings::Source {
decided_by: " ".into(),
decided_on: date(2026, 10, 20),
citation: None,
note: None,
});
assert!(matches!(
l.set_asset_basis(id, b),
Err(LedgerError::InvalidTaxBasis(_))
));
}
/// A zero adjusted basis (fully depreciated) and a zero value (worthless
/// at contribution) are facts like any other (R21).
#[test]
fn zero_basis_and_zero_value_are_accepted() {
for (adj, fmv) in [("0.00", "300.00"), ("300.00", "0"), ("0", "0.00")] {
let (mut l, id) = one("1000.00");
l.set_asset_basis(id, contribution(BasisRule::Carryover723, adj, fmv))
.unwrap();
let f = l.get_asset(id).unwrap().basis_figures().unwrap().unwrap();
assert_eq!(f.tax_basis_723, Some(d(adj)));
assert_eq!(f.built_in_gain_704c, Some(d(fmv) - d(adj)));
}
}
/// Valid records: check agrees with set, and a valid set changes nothing
/// but the asset's basis record (book figures don't change).
#[test]
fn valid_records_are_accepted_and_book_figures_stay() {
let mut src = contribution(BasisRule::PersonalUseConversion, "750.50", "700.00");
src.sec704c_method = Some(Sec704cMethod::Traditional);
src.source = Some(ledger_core::settings::Source {
decided_by: "Morgan Ellis".into(),
decided_on: date(2026, 10, 20),
citation: Some("entry 2".into()),
note: None,
});
for b in [
AssetBasis::purchase(),
contribution(BasisRule::Carryover723, "750", "1000.00"),
contribution(BasisRule::PersonalUseConversion, "750.5", "1000"),
src,
] {
let (mut l, id) = one("1000.00");
l.check_asset_basis(id, &b).unwrap();
let before = l.get_asset(id).unwrap().clone();
l.set_asset_basis(id, b.clone()).unwrap();
let after = l.get_asset(id).unwrap();
assert_eq!(after.basis, Some(b.clone()));
let mut same = after.clone();
same.basis = before.basis.clone();
assert_eq!(same, before, "book figures don't change: {b:?}");
}
}
/// Set again, the new record replaces the old; a refusal keeps the old.
#[test]
fn setting_again_replaces_and_a_refusal_keeps() {
let (mut l, id) = one("1000.00");
l.set_asset_basis(id, AssetBasis::purchase()).unwrap();
assert_eq!(
l.get_asset(id).unwrap().depreciable_basis(),
Some(d("1000.00"))
);
l.set_asset_basis(
id,
contribution(BasisRule::Carryover723, "400.00", "1000.00"),
)
.unwrap();
assert_eq!(
l.get_asset(id).unwrap().depreciable_basis(),
Some(d("400.00"))
);
let before = l.clone();
let mut bad = AssetBasis::purchase();
bad.contributor_adjusted_basis = Some(d("1.00"));
assert!(l.set_asset_basis(id, bad).is_err());
assert_eq!(l, before);
}
// ------------------------------------------------------------ the figures
/// No record: no figures.
#[test]
fn no_record_means_no_figures() {
let (l, id) = one("1000.00");
let a = l.get_asset(id).unwrap();
assert_eq!(a.basis_figures().unwrap(), None);
assert_eq!(a.tax_basis_723(), None);
assert_eq!(a.depreciable_basis(), None);
assert_eq!(a.built_in_gain_704c(), None);
assert!(!a.sec704c_1c());
assert_eq!(a.fmv_at_contribution(), None);
}
/// A purchase: depreciable = cost (IRC §1012(a)); no §723 basis, no
/// built-in gain (not property contributed).
#[test]
fn a_purchase_depreciates_from_cost() {
let (mut l, id) = one("1234.56");
l.set_asset_basis(id, AssetBasis::purchase()).unwrap();
let a = l.get_asset(id).unwrap();
assert_eq!(a.depreciable_basis(), Some(d("1234.56")));
assert_eq!(a.tax_basis_723(), None);
assert_eq!(a.built_in_gain_704c(), None);
assert_eq!(a.fmv_at_contribution(), None);
}
/// Named regression 4 (the brief's reproduction 5, anonymized): a laptop
/// worth 1,440.00 that the member held at 1,688.40 from personal use:
/// §723 basis 1,688.40, depreciable basis 1,440.00 (the lesser, D-41),
/// built-in loss 248.40, which counts only for the contributor
/// (§704(c)(1)(C)). Before P3 its "tax basis" was the 1,440.00.
#[test]
fn personal_use_contribution_keeps_its_723_basis_apart_from_depreciable_basis() {
let (mut l, id) = one("1440.00");
l.set_asset_basis(
id,
contribution(BasisRule::PersonalUseConversion, "1688.40", "1440.00"),
)
.unwrap();
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax_basis_723(), Some(d("1688.40")));
assert_eq!(a.depreciable_basis(), Some(d("1440.00")));
assert_eq!(a.built_in_gain_704c(), Some(d("-248.40")));
assert!(a.sec704c_1c());
assert!(a.fmv_is_recorded());
}
/// Named regression 5 (the brief's reproduction 4): value 1,000.00 and
/// adjusted basis 400.00 give a built-in gain of 600.00, fixed at
/// contribution (Treas. Reg. §1.704-3(a)(3)(ii)): correcting the book cost
/// to 1,100.00 leaves it 600.00. A record from before P3 (no value
/// recorded, read as the book cost) moves with the cost, as it did then.
#[test]
fn built_in_gain_is_fixed_at_contribution_not_book_cost() {
let (mut l, id) = one("1000.00");
l.set_asset_basis(
id,
contribution(BasisRule::Carryover723, "400.00", "1000.00"),
)
.unwrap();
assert_eq!(
l.get_asset(id).unwrap().built_in_gain_704c(),
Some(d("600.00"))
);
let fix = AssetCorrection {
cost: usd("1100.00"),
salvage_value: usd("0.00"),
useful_life_months: 36,
in_service_date: date(2026, 1, 1),
};
l.correct_asset(id, fix).unwrap();
let a = l.get_asset(id).unwrap();
assert_eq!(a.cost.amount, d("1100.00"));
assert_eq!(a.built_in_gain_704c(), Some(d("600.00")));
assert_eq!(a.fmv_at_contribution(), Some(d("1000.00")));
// The pre-P3 record: value read as the book cost.
let (mut l, id) = one("1000.00");
l.replay_set_asset_tax_basis(id, BasisRule::Carryover723, Some(d("400.00")))
.unwrap();
assert_eq!(
l.get_asset(id).unwrap().built_in_gain_704c(),
Some(d("600.00"))
);
l.correct_asset(id, fix).unwrap();
let a = l.get_asset(id).unwrap();
assert_eq!(a.built_in_gain_704c(), Some(d("700.00")));
assert!(!a.fmv_is_recorded());
}
/// The figure table (kernel::basis_figures INVARIANTS 2-4) on the ledger:
/// value equal to basis (no built-in gain: not §704(c) property); value
/// above basis for personal use (depreciable = basis); carryover above
/// and below.
#[test]
fn the_figures_follow_the_rule() {
for (rule, fmv, adj, dep, gain) in [
(
BasisRule::Carryover723,
"1000.00",
"600.00",
"600.00",
"400.00",
),
(
BasisRule::Carryover723,
"1000.00",
"1500.00",
"1500.00",
"-500.00",
),
(
BasisRule::Carryover723,
"1000.00",
"1000.00",
"1000.00",
"0.00",
),
(
BasisRule::PersonalUseConversion,
"1200.00",
"2000.00",
"1200.00",
"-800.00",
),
(
BasisRule::PersonalUseConversion,
"450.00",
"300.00",
"300.00",
"150.00",
),
(
BasisRule::PersonalUseConversion,
"999.99",
"999.99",
"999.99",
"0.00",
),
(
BasisRule::PersonalUseConversion,
"1000.00",
"1000.01",
"1000.00",
"-0.01",
),
(
BasisRule::PersonalUseConversion,
"1000.00",
"999.99",
"999.99",
"0.01",
),
] {
let (mut l, id) = one(fmv);
l.set_asset_basis(id, contribution(rule, adj, fmv)).unwrap();
let a = l.get_asset(id).unwrap();
assert_eq!(a.tax_basis_723(), Some(d(adj)), "{rule:?} {fmv} {adj}");
assert_eq!(a.depreciable_basis(), Some(d(dep)), "{rule:?} {fmv} {adj}");
assert_eq!(
a.built_in_gain_704c(),
Some(d(gain)),
"{rule:?} {fmv} {adj}"
);
assert_eq!(a.sec704c_1c(), d(gain) < Decimal::ZERO);
}
}
/// The four-contributed-devices shape: two members contribute four devices
/// from personal use, each at the lower of its value and its basis: a
/// laptop and a phone worth less than their basis (built-in losses,
/// §704(c)(1)(C)), a camera worth more (a built-in gain: depreciable is its
/// basis), a monitor at its basis (neither). Register totals by hand:
/// book cost 1,400.00 + 900.00 + 1,300.00 + 500.00 = 4,100.00; §723 basis
/// 1,750.40 + 1,129.99 + 1,080.00 + 500.00 = 4,460.39; depreciable basis
/// 1,400.00 + 900.00 + 1,080.00 + 500.00 = 3,880.00; built-in gain
/// −350.40 − 229.99 + 220.00 + 0.00 = −360.39 = 4,100.00 − 4,460.39.
#[test]
fn four_contributed_devices_each_take_the_lower_of_value_and_basis() {
let (mut l, a) = fixture();
let devices = [
("Laptop", "1400.00", "1750.40", "1400.00", "-350.40", true),
("Phone", "900.00", "1129.99", "900.00", "-229.99", true),
("Camera", "1300.00", "1080.00", "1080.00", "220.00", false),
("Monitor", "500.00", "500.00", "500.00", "0.00", false),
];
let mut ids = Vec::new();
for (name, fmv, adj, _, _, _) in devices {
let id = register(&mut l, &a, name, fmv);
let mut b = contribution(BasisRule::PersonalUseConversion, adj, fmv);
b.sec704c_method = Some(Sec704cMethod::Traditional);
l.set_asset_basis(id, b).unwrap();
ids.push(id);
}
for (id, (name, _, adj, dep, gain, loss)) in ids.iter().zip(devices) {
let x = l.get_asset(*id).unwrap();
assert_eq!(x.tax_basis_723(), Some(d(adj)), "{name}");
assert_eq!(x.depreciable_basis(), Some(d(dep)), "{name}");
assert_eq!(x.built_in_gain_704c(), Some(d(gain)), "{name}");
assert_eq!(x.sec704c_1c(), loss, "{name}");
}
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
assert_eq!(r.total_cost, d("4100.00"));
assert_eq!(r.total_tax_basis_723, d("4460.39"));
assert_eq!(r.total_depreciable_basis, d("3880.00"));
assert_eq!(r.missing_basis, 0);
let gain: Decimal = r.rows.iter().filter_map(|x| x.built_in_gain_704c).sum();
assert_eq!(gain, d("-360.39"));
assert_eq!(gain, r.total_cost - r.total_tax_basis_723);
assert_eq!(r.rows.iter().filter(|x| x.sec704c_1c).count(), 2);
assert!(
r.rows
.iter()
.all(|x| x.sec704c_method == Some(Sec704cMethod::Traditional))
);
}
// -------------------------------------------------- legacy (pre-P3) lines
/// A pre-P3 `asset.tax_basis_set` replays to the figures that version
/// gave: cost → the book cost; carryover → the contributor's basis;
/// personal-use conversion → min(book cost, basis); built-in gain = book
/// cost − basis; the value read as the book cost, not recorded.
#[test]
fn legacy_records_give_the_figures_they_gave() {
for (rule, basis, cost, dep, gain) in [
(BasisRule::Purchase, None, "1234.56", "1234.56", None),
(
BasisRule::Carryover723,
Some("650.25"),
"500.00",
"650.25",
Some("-150.25"),
),
(
BasisRule::PersonalUseConversion,
Some("2000.00"),
"1200.00",
"1200.00",
Some("-800.00"),
),
(
BasisRule::PersonalUseConversion,
Some("300.00"),
"450.00",
"300.00",
Some("150.00"),
),
] {
let (mut l, id) = one(cost);
l.replay_set_asset_tax_basis(id, rule, basis.map(d))
.unwrap();
let a = l.get_asset(id).unwrap();
assert_eq!(a.depreciable_basis(), Some(d(dep)), "{rule:?}");
assert_eq!(a.built_in_gain_704c(), gain.map(d), "{rule:?}");
assert_eq!(
a.tax_basis_723(),
basis.filter(|_| rule.is_contribution()).map(d)
);
assert!(!a.fmv_is_recorded());
assert_eq!(a.basis, Some(legacy(rule, basis)));
}
}
/// The legacy checks (as written through 2026-10-16) at replay: a purchase
/// takes no basis, a contribution needs one, non-negative, in cents; no
/// upper bound.
#[test]
fn legacy_replay_checks_what_it_checked() {
let (mut l, id) = one("1000.00");
for (rule, basis) in [
(BasisRule::Purchase, Some("1.00")),
(BasisRule::Carryover723, None),
(BasisRule::PersonalUseConversion, Some("-1.00")),
(BasisRule::Carryover723, Some("1.001")),
] {
assert!(matches!(
l.replay_set_asset_tax_basis(id, rule, basis.map(d)),
Err(LedgerError::InvalidTaxBasis(_))
));
}
l.replay_set_asset_tax_basis(id, BasisRule::Carryover723, Some(Decimal::MAX))
.unwrap();
}
/// An `asset.basis_set` at replay: its shape only (the facts fit the rule,
/// none negative), no bound on size or places (a later version may allow
/// more).
#[test]
fn basis_set_replay_checks_its_shape_only() {
let (mut l, id) = one("1000.00");
let mut purchase_with_fmv = AssetBasis::purchase();
purchase_with_fmv.fmv_at_contribution = Some(d("1.00"));
let mut no_fmv = contribution(BasisRule::Carryover723, "1.00", "1.00");
no_fmv.fmv_at_contribution = None;
for b in [
purchase_with_fmv,
no_fmv,
contribution(BasisRule::Carryover723, "-1.00", "1.00"),
contribution(BasisRule::Carryover723, "1.00", "-1.00"),
] {
assert!(matches!(
l.replay_set_asset_basis(id, b),
Err(LedgerError::InvalidTaxBasis(_))
));
}
let wide = AssetBasis::contribution(BasisRule::Carryover723, d("1.0001"), Decimal::MAX);
l.replay_set_asset_basis(id, wide.clone()).unwrap();
assert_eq!(l.get_asset(id).unwrap().basis, Some(wide));
}
// ------------------------------------------------------------- the dates
/// Named regression 3 (the brief's reproduction 3): equipment bought
/// 2026-03-10 for 3,000.00 and placed in service 2026-04-01, 36 months.
/// Linked to its purchase, it is on the register from 2026-03-10: as of
/// 2026-03-31 its row is there with nothing accrued and the books' cost is
/// all registered; its schedule starts in April (a March charge would be
/// 3,000/36 × 22/31 = 59.14: none).
#[test]
fn an_asset_bought_before_it_is_in_service_is_on_the_register_from_acquisition() {
let (mut l, a) = fixture();
let bought = post(
&mut l,
date(2026, 3, 10),
&[
(a.equipment, Side::Debit, "3000.00"),
(a.cash, Side::Credit, "3000.00"),
],
);
let id = l
.register_asset(new_asset(&a, "Kiln", "3000.00", 36, date(2026, 4, 1)))
.unwrap();
// Unlinked, it would join the register only on 2026-04-01, while the
// books carry its cost from 2026-03-10.
let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap();
assert!(r.rows.is_empty());
l.set_asset_acquisition(id, bought).unwrap();
assert_eq!(l.asset_acquired_date(id), Some(date(2026, 3, 10)));
let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap();
assert_eq!(r.rows.len(), 1);
let x = &r.rows[0];
assert_eq!(x.acquired_date, date(2026, 3, 10));
assert_eq!(x.in_service_date, date(2026, 4, 1));
assert_eq!(x.accumulated_scheduled, Decimal::ZERO);
assert_eq!(x.accumulated_posted, Decimal::ZERO);
assert_eq!(x.book_value, d("3000.00"));
assert_eq!(r.unregistered_cost(), Decimal::ZERO);
let sched =
ledger_core::depreciation_schedule(l.get_asset(id).unwrap(), date(2026, 4, 30)).unwrap();
assert_eq!(sched.len(), 1);
assert_eq!(sched[0].period, "2026-04");
assert_eq!(sched[0].amount, d("83.33"));
// Before acquisition: not on the register.
assert!(
fixed_asset_register(&l, date(2026, 3, 9))
.unwrap()
.rows
.is_empty()
);
}
/// With no entry linked the acquired date is the in-service date; a link
/// gives the entry's, never later than the in-service date.
#[test]
fn the_acquired_date_comes_from_the_linked_entry() {
let (mut l, a) = fixture();
let id = l
.register_asset(new_asset(&a, "Lathe", "900.00", 12, date(2026, 2, 1)))
.unwrap();
assert_eq!(l.asset_acquired_date(id), Some(date(2026, 2, 1)));
let e = post(
&mut l,
date(2026, 1, 20),
&[
(a.equipment, Side::Debit, "900.00"),
(a.capital, Side::Credit, "900.00"),
],
);
l.set_asset_acquisition(id, e).unwrap();
assert_eq!(l.asset_acquired_date(id), Some(date(2026, 1, 20)));
assert_eq!(l.asset_acquired_date(id + 9), None);
}
/// set_asset_acquisition INVARIANT 2: a new link to an entry dated after
/// the in-service date, or after the disposal, is refused; replay
/// accepts what earlier versions did, and the acquired date is never
/// later than the in-service date.
#[test]
fn a_link_after_service_or_disposal_is_refused_and_replays() {
let (mut l, a) = fixture();
let id = l
.register_asset(new_asset(&a, "Lathe", "900.00", 12, date(2026, 2, 1)))
.unwrap();
let late = post(
&mut l,
date(2026, 2, 2),
&[
(a.equipment, Side::Debit, "900.00"),
(a.cash, Side::Credit, "900.00"),
],
);
let before = l.clone();
assert!(matches!(
l.set_asset_acquisition(id, late),
Err(LedgerError::AcquisitionEntryMismatch { .. })
));
assert_eq!(l, before);
l.replay_set_asset_acquisition(id, late).unwrap();
assert_eq!(l.get_asset(id).unwrap().acquired_by, Some(late));
assert_eq!(l.asset_acquired_date(id), Some(date(2026, 2, 1)));
// After the disposal: bought 05-01, scrapped 05-15 before its
// in-service date 06-01; a relink to an entry of 05-20 is refused.
let (mut l, a) = fixture();
let id = l
.register_asset(new_asset(&a, "Drill", "300.00", 12, date(2026, 6, 1)))
.unwrap();
let buy = |l: &mut Ledger, on| {
post(
l,
on,
&[
(a.equipment, Side::Debit, "300.00"),
(a.cash, Side::Credit, "300.00"),
],
)
};
let first = buy(&mut l, date(2026, 5, 1));
l.set_asset_acquisition(id, first).unwrap();
l.dispose_asset(id, date(2026, 5, 15), usd("0.00")).unwrap();
let later = buy(&mut l, date(2026, 5, 20));
assert!(matches!(
l.set_asset_acquisition(id, later),
Err(LedgerError::AcquisitionEntryMismatch { .. })
));
let on_the_day = buy(&mut l, date(2026, 5, 15));
l.set_asset_acquisition(id, on_the_day).unwrap();
}
/// correct_asset INVARIANT 3: a new in-service date before the linked
/// entry's date is refused.
#[test]
fn an_in_service_date_before_the_acquisition_is_refused() {
let (mut l, a) = fixture();
let id = l
.register_asset(new_asset(&a, "Lathe", "900.00", 12, date(2026, 2, 1)))
.unwrap();
let e = post(
&mut l,
date(2026, 1, 20),
&[
(a.equipment, Side::Debit, "900.00"),
(a.cash, Side::Credit, "900.00"),
],
);
l.set_asset_acquisition(id, e).unwrap();
let fix = |on| AssetCorrection {
cost: usd("900.00"),
salvage_value: usd("0.00"),
useful_life_months: 12,
in_service_date: on,
};
let before = l.clone();
assert!(matches!(
l.correct_asset(id, fix(date(2026, 1, 19))),
Err(LedgerError::InServiceBeforeAcquisition { .. })
));
assert_eq!(l, before);
l.correct_asset(id, fix(date(2026, 1, 20))).unwrap();
assert_eq!(l.get_asset(id).unwrap().in_service_date, date(2026, 1, 20));
}
/// The disposal floor is the acquired date: a disposal between acquisition
/// and service has nothing accrued; one before acquisition is refused.
#[test]
fn a_disposal_between_acquisition_and_service_is_allowed() {
let (mut l, a) = fixture();
let id = l
.register_asset(new_asset(&a, "Kiln", "3000.00", 36, date(2026, 4, 1)))
.unwrap();
let e = post(
&mut l,
date(2026, 3, 10),
&[
(a.equipment, Side::Debit, "3000.00"),
(a.cash, Side::Credit, "3000.00"),
],
);
l.set_asset_acquisition(id, e).unwrap();
assert!(matches!(
l.dispose_asset(id, date(2026, 3, 9), usd("0.00")),
Err(LedgerError::DisposalBeforeAcquisition { .. })
));
let amounts =
ledger_core::disposal_amounts(l.get_asset(id).unwrap(), date(2026, 3, 20), usd("2500.00"))
.unwrap();
assert_eq!(amounts.accumulated, Decimal::ZERO);
assert_eq!(amounts.gain_loss, d("-500.00"));
l.dispose_asset(id, date(2026, 3, 20), usd("2500.00"))
.unwrap();
}
// ------------------------------------------------- posted and scheduled
/// Books with one asset (1,200.00 over 12 months from 2026-01-01: 100.00 a
/// month) and January's run.
fn one_run() -> (Ledger, Accts, AssetId, ledger_core::EntryId) {
let (mut l, a) = fixture();
let id = l
.register_asset(new_asset(&a, "Printer", "1200.00", 12, date(2026, 1, 1)))
.unwrap();
let e = run(&mut l, &a, id, date(2026, 1, 31), "100.00");
(l, a, id, e)
}
/// accumulated_posted INVARIANT 2: a run counts from its entry's date; a
/// reversal takes it back from the reversal's date (so a reversal dated
/// after as_of still leaves it counted).
#[test]
fn posted_follows_the_entries_dates() {
let (mut l, _, id, e) = one_run();
assert_eq!(l.accumulated_posted(id, date(2026, 1, 30)).unwrap(), d("0"));
assert_eq!(
l.accumulated_posted(id, date(2026, 1, 31)).unwrap(),
d("100.00")
);
l.reverse_entry(e, date(2026, 3, 1), "undo").unwrap();
assert_eq!(
l.accumulated_posted(id, date(2026, 2, 28)).unwrap(),
d("100.00")
);
assert_eq!(l.accumulated_posted(id, date(2026, 3, 1)).unwrap(), d("0"));
assert_eq!(l.live_depreciation_run(id, "2026-01"), None);
}
/// A reversal of the reversal reinstates the run (B-9), and the run counts
/// again: the month is not due twice (review R2).
#[test]
fn a_reinstated_run_counts_and_is_live() {
let (mut l, _, id, e) = one_run();
let undo = l.reverse_entry(e, date(2026, 2, 1), "undo").unwrap();
let redo = l.reverse_entry(undo, date(2026, 2, 2), "redo").unwrap();
assert_eq!(l.accumulated_posted(id, date(2026, 2, 1)).unwrap(), d("0"));
assert_eq!(
l.accumulated_posted(id, date(2026, 2, 2)).unwrap(),
d("100.00")
);
let live = l.live_depreciation_run(id, "2026-01").unwrap();
assert_eq!(live.entry, e);
let _ = redo;
}
/// A reversal dated before its run: between the two dates the books carry
/// −100.00, as the trial balance does (review R2).
#[test]
fn a_reversal_dated_before_its_run_matches_the_trial_balance() {
let (mut l, a, id, e) = one_run();
l.reverse_entry(e, date(2026, 1, 15), "early").unwrap();
let on = date(2026, 1, 20);
assert_eq!(l.accumulated_posted(id, on).unwrap(), d("-100.00"));
let tb = ledger_core::trial_balance_as_of(&l, on);
let accum = l.chart().get(a.accum).unwrap().code.clone();
let line = tb.lines.iter().find(|x| x.code == accum).unwrap();
assert_eq!(line.debit - line.credit, d("100.00"));
assert_eq!(l.accumulated_posted(id, date(2026, 1, 31)).unwrap(), d("0"));
}
/// accumulated_posted INVARIANT 1: zero once disposed on or before as_of;
/// counted before.
#[test]
fn posted_is_zero_once_disposed() {
let (mut l, _, id, _) = one_run();
l.dispose_asset(id, date(2026, 2, 15), usd("900.00"))
.unwrap();
assert_eq!(
l.accumulated_posted(id, date(2026, 2, 14)).unwrap(),
d("100.00")
);
assert_eq!(l.accumulated_posted(id, date(2026, 2, 15)).unwrap(), d("0"));
// Its runs stay: depreciation posted in January is still January's.
assert_eq!(
l.depreciation_posted_between(id, date(2026, 1, 1), date(2026, 12, 31))
.unwrap(),
d("100.00")
);
}
/// The register's book value is cost less posted, the scheduled figure
/// beside it, and a hand entry in the account is unattributed.
#[test]
fn the_registers_book_value_is_what_the_books_carry() {
let (mut l, a, id, _) = one_run();
let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap();
let x = &r.rows[0];
assert_eq!(x.accumulated_scheduled, d("300.00"));
assert_eq!(x.accumulated_posted, d("100.00"));
assert_eq!(x.book_value, d("1100.00"));
assert_eq!(r.total_book_value, d("1100.00"));
assert_eq!(r.unattributed_accumulated(), Decimal::ZERO);
post(
&mut l,
date(2026, 2, 28),
&[
(a.depr_expense, Side::Debit, "100.00"),
(a.accum, Side::Credit, "100.00"),
],
);
let r = fixed_asset_register(&l, date(2026, 3, 31)).unwrap();
assert_eq!(r.rows[0].book_value, d("1100.00"));
assert_eq!(r.unattributed_accumulated(), d("100.00"));
assert_eq!(r.unposted_depreciation(), d("100.00"));
let _ = id;
}
/// A second line for the same run (asset, period and entry: a journal
/// edited by hand) is the run already recorded, as the projection keys it:
/// its entry is carried once (P3 review, 2026-10-21: replay counted it
/// twice, 200.00, and a reload once).
#[test]
fn a_duplicated_run_line_is_carried_once() {
let (mut l, _, id, e) = one_run();
l.record_depreciation_run(id, "2026-01", e, d("100.00"))
.unwrap();
assert_eq!(l.depreciation_runs(id).len(), 1);
assert_eq!(
l.accumulated_posted(id, date(2026, 1, 31)).unwrap(),
d("100.00")
);
}
/// check_depreciation_run: a month, a positive amount in cents, a posted
/// entry crediting the asset's accumulated depreciation by exactly that;
/// record_depreciation_run (replay) needs only the asset and entry.
#[test]
fn a_run_must_be_its_entry() {
let (mut l, a, id, e) = one_run();
for (period, amount, why) in [
("2026-1", "100.00", "period"),
("10000-01", "100.00", "period"),
("2026-13", "100.00", "period"),
("2026-01", "0", "amount"),
("2026-01", "100.001", "amount"),
("2026-01", "99.99", "credit"),
] {
assert!(
matches!(
l.check_depreciation_run(id, period, e, d(amount)),
Err(LedgerError::InvalidDepreciationRun { .. })
),
"{why}"
);
}
assert!(matches!(
l.check_depreciation_run(id + 9, "2026-01", e, d("100.00")),
Err(LedgerError::UnknownAsset(_))
));
let draft = l.try_create_entry(date(2026, 2, 28), "draft").unwrap();
l.add_line(draft, a.depr_expense, Side::Debit, usd("100.00"))
.unwrap();
l.add_line(draft, a.accum, Side::Credit, usd("100.00"))
.unwrap();
assert!(
l.check_depreciation_run(id, "2026-02", draft, d("100.00"))
.is_err()
);
// Replay: any journaled period and amount, the asset and entry known.
l.record_depreciation_run(id, "10000-01", e, d("0.001"))
.unwrap();
assert!(matches!(
l.record_depreciation_run(id, "2026-01", e + 99, d("1")),
Err(LedgerError::UnknownEntry(_))
));
assert!(matches!(
l.record_depreciation_run(id + 9, "2026-01", e, d("1")),
Err(LedgerError::UnknownAsset(_))
));
}
// ---------------------------------------------------------- the register
/// Register rows carry each asset's facts and figures (INVARIANT 5) and
/// the totals cover held assets only (INVARIANT 3): a purchase, a
/// carryover, a personal-use conversion, one with no basis, one disposed.
/// By hand: depreciable 3,000.00 + 650.25 + 1,200.00 = 4,850.25; §723
/// 650.25 + 2,000.00 = 2,650.25; the disposed one left out.
#[test]
fn register_rows_carry_the_assets_figures_and_totals_the_held() {
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");
l.set_asset_basis(bought, AssetBasis::purchase()).unwrap();
l.set_asset_basis(
carried,
contribution(BasisRule::Carryover723, "650.25", "500.00"),
)
.unwrap();
l.set_asset_basis(
converted,
contribution(BasisRule::PersonalUseConversion, "2000.00", "1200.00"),
)
.unwrap();
l.set_asset_basis(sold, AssetBasis::purchase()).unwrap();
l.dispose_asset(sold, date(2026, 3, 31), usd("100.00"))
.unwrap();
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
for row in &r.rows {
let x = l.get_asset(row.asset_id).unwrap();
let f = x.basis_figures().unwrap();
assert_eq!(row.basis_rule, x.basis.as_ref().map(|b| b.rule));
assert_eq!(row.tax_basis_723, f.and_then(|f| f.tax_basis_723));
assert_eq!(row.depreciable_basis, f.map(|f| f.depreciable_basis));
assert_eq!(row.built_in_gain_704c, f.and_then(|f| f.built_in_gain_704c));
assert_eq!(row.fmv_at_contribution, x.fmv_at_contribution());
}
let row = |id| r.rows.iter().find(|x| x.asset_id == id).unwrap();
assert_eq!(row(carried).built_in_gain_704c, Some(d("-150.25")));
assert!(row(carried).sec704c_1c);
assert_eq!(row(converted).depreciable_basis, Some(d("1200.00")));
assert_eq!(row(converted).tax_basis_723, Some(d("2000.00")));
assert_eq!(row(unrecorded).depreciable_basis, None);
assert!(row(sold).disposed.is_some());
assert_eq!(r.total_depreciable_basis, d("4850.25"));
assert_eq!(r.total_tax_basis_723, d("2650.25"));
assert_eq!(r.missing_basis, 1);
// Before the disposal the monitor is held.
let r = fixed_asset_register(&l, date(2026, 2, 28)).unwrap();
assert_eq!(r.total_depreciable_basis, d("5210.25"));
}
/// Recording a basis leaves the book totals alone.
#[test]
fn a_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)).unwrap();
l.set_asset_basis(
x,
contribution(BasisRule::PersonalUseConversion, "2000.00", "1200.00"),
)
.unwrap();
let after = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
assert_eq!(after.total_cost, before.total_cost);
assert_eq!(
after.total_accumulated_scheduled,
before.total_accumulated_scheduled
);
assert_eq!(
after.total_accumulated_posted,
before.total_accumulated_posted
);
assert_eq!(after.total_book_value, before.total_book_value);
assert_eq!(after.ledger_cost, before.ledger_cost);
}
// ---------------------------------------------------------------- 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))]
/// The figures follow the rule over random costs, values and bases,
/// and the register's totals agree (prop_basis_figures_laws on the
/// ledger).
#[test]
fn prop_figures_follow_their_rule(
assets in proptest::collection::vec((positive_cents(), cents(), cents(), 0usize..4), 1..8)
) {
let (mut l, a) = fixture();
let (mut dep_total, mut t723_total, mut missing) = (Decimal::ZERO, Decimal::ZERO, 0usize);
for (i, (cost, adj, fmv, rule)) in assets.iter().enumerate() {
let id = register(&mut l, &a, &format!("Asset {i}"), &cost.to_string());
match rule {
0 => {
l.set_asset_basis(id, AssetBasis::purchase()).unwrap();
let x = l.get_asset(id).unwrap();
prop_assert_eq!(x.depreciable_basis(), Some(*cost));
prop_assert_eq!(x.built_in_gain_704c(), None);
dep_total += *cost;
}
1 | 2 => {
let r = RULES[*rule];
l.set_asset_basis(id, AssetBasis::contribution(r, *adj, *fmv)).unwrap();
let x = l.get_asset(id).unwrap();
prop_assert_eq!(x.tax_basis_723(), Some(*adj));
prop_assert_eq!(x.built_in_gain_704c(), Some(*fmv - *adj));
prop_assert_eq!(x.sec704c_1c(), adj > fmv);
let dep = x.depreciable_basis().unwrap();
if r == BasisRule::PersonalUseConversion {
prop_assert!(dep <= *adj && dep <= *fmv);
prop_assert!(dep == *adj || dep == *fmv);
} else {
prop_assert_eq!(dep, *adj);
}
dep_total += dep;
t723_total += *adj;
}
_ => missing += 1,
}
}
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
prop_assert_eq!(r.total_depreciable_basis, dep_total);
prop_assert_eq!(r.total_tax_basis_723, t723_total);
prop_assert_eq!(r.missing_basis, missing);
}
/// check agrees with set, and a refused record changes nothing; the
/// spec, independently: a purchase takes no facts, a contribution both,
/// each non-negative and in cents.
#[test]
fn prop_check_agrees_with_set(
adj in proptest::option::of((-1_000_000i64..1_000_000, 0u32..5)),
fmv in proptest::option::of((-1_000_000i64..1_000_000, 0u32..5)),
rule in 0usize..3,
) {
let (mut l, id) = one("1000.00");
let b = AssetBasis {
rule: RULES[rule],
contributor_adjusted_basis: adj.map(|(c, s)| Decimal::new(c, s)),
fmv_at_contribution: fmv.map(|(c, s)| Decimal::new(c, s)),
sec704c_method: None,
source: None,
};
let before = l.clone();
let checked = l.check_asset_basis(id, &b);
let done = l.set_asset_basis(id, b.clone());
prop_assert_eq!(checked.is_ok(), done.is_ok());
let fine = |v: Option<Decimal>| v.is_some_and(|v| v >= Decimal::ZERO && v.normalize().scale() <= 2);
let valid = match RULES[rule] {
BasisRule::Purchase => adj.is_none() && fmv.is_none(),
_ => fine(b.contributor_adjusted_basis) && fine(b.fmv_at_contribution),
};
prop_assert_eq!(done.is_ok(), valid, "{:?}", b);
if done.is_err() {
prop_assert_eq!(&l, &before);
} else {
prop_assert_eq!(l.get_asset(id).unwrap().basis.clone(), Some(b));
}
}
}
/// Recording a basis or a run changes no entry and no figure of
/// the trial balance (review R16: these commands are records about the
/// books, as Lean's `Step.record` is; the purity law of
/// `settings_replay_is_pure`).
#[test]
fn basis_links_and_runs_change_no_entry_or_balance() {
let (mut l, a, id, e) = one_run();
let tb = |l: &Ledger| {
serde_json::to_value(ledger_core::trial_balance_as_of(l, date(2026, 12, 31))).unwrap()
};
let entries =
|l: &Ledger| -> Vec<ledger_core::JournalEntry> { l.posted_entries().cloned().collect() };
let (before_tb, before_entries) = (tb(&l), entries(&l));
l.set_asset_basis(
id,
contribution(BasisRule::Carryover723, "1000.00", "1200.00"),
)
.unwrap();
l.replay_set_asset_tax_basis(id, BasisRule::PersonalUseConversion, Some(d("1500.00")))
.unwrap();
l.record_depreciation_run(id, "2026-01", e, d("100.00"))
.unwrap();
let _ = a;
assert_eq!(tb(&l), before_tb);
assert_eq!(entries(&l), before_entries);
}