P3: assets' posted and scheduled depreciation, their bases, and the K-1 on the tax basis (defect 13, D-41)
check / check (push) Canceled after 0s

Book value is cost less what the books posted; the schedule's figure is
apart. An asset's acquired date comes from its entry. Contributed
property keeps its section 723 carryover basis apart from its depreciable
basis (the lower of value and adjusted basis), with its 704(c) built-in
gain and method. The K-1 worksheet reports tax-basis capital (Form 1065
Item L) beside the book figures, leaving open what only the CPA knows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:12 -06:00
1 parent 3a9aa92315
commit 2f999c6050
81 files changed
+9391 -2604

No files matched your search

+3 -3
View File
@@ -111,7 +111,7 @@ fn books(dir: &Path) -> u64 {
}
s.register_asset(NewAsset {
description: "Asset".into(),
purchase_date: day(2026, 1, 16),
in_service_date: day(2026, 1, 16),
cost: usd(300_000),
salvage_value: usd(0),
useful_life_months: 3,
@@ -219,7 +219,7 @@ fn check(store: &Store, asset: u64, ops: &[Op], verify: bool) {
let a = store.ledger().get_asset(asset).unwrap().clone();
let mut active = a.clone();
active.status = AssetStatus::Active;
let sched = |on: NaiveDate| ledger_core::accumulated_depreciation(&active, on).unwrap();
let sched = |on: NaiveDate| ledger_core::accumulated_scheduled_through(&active, on).unwrap();
// INVARIANT 3: one run in effect per month.
let text = std::fs::read_to_string(store.data_dir().join(JOURNAL_FILE)).unwrap();
let mut live: Vec<String> = Vec::new();
@@ -294,7 +294,7 @@ fn check_completion(dir: &Path, asset: u64, ops: &[Op]) {
for e in ends() {
let x = balance(&store, EXPENSE, e);
assert!(
x <= ledger_core::accumulated_depreciation(&a, e).unwrap(),
x <= ledger_core::accumulated_scheduled_through(&a, e).unwrap(),
"{ops:?} then every month: {x} on {e}"
);
}
+393 -83
View File
@@ -4,10 +4,12 @@
//! SQLite projection agreeing with the journal (`Store::verify`), books
//! written before tags existed, refusals journaling nothing, and corrupt
//! `asset.serial_set` lines refused on open; and tax basis (B-25:
//! `asset.tax_basis_set` {asset_id, rule, contributor_basis | null}, the
//! v10 schema, corrupt payloads). A database at an older schema under this
//! version's file name is rebuilt, never migrated (each schema keeps a file
//! of its own: rules.md B-27, tests/schema_files.rs).
//! `asset.basis_set`, pre-P3 `asset.tax_basis_set` {asset_id, rule,
//! contributor_basis | null} replayed, the v10 schema and v13's, corrupt
//! and newer payloads). A database at an older schema under this
//! version's file name is rebuilt, never migrated, and P2's own file is
//! left as it is (each schema keeps a file of its own: rules.md B-27,
//! tests/schema_files.rs).
//!
//! Expectations come from the doc comments on `Store::set_asset_tag`,
//! `Store::set_asset_serial`, `Store::open`, `Store::verify`,
@@ -90,7 +92,7 @@ fn laptop(store: &mut Store, a: &Accts, name: &str) -> AssetId {
store
.register_asset(NewAsset {
description: name.to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd("1200.00"),
salvage_value: usd("0.00"),
useful_life_months: 12,
@@ -571,98 +573,123 @@ fn a_description_change_replays_and_verifies() {
// ------------------------------------------------- tax basis (rules.md B-25)
//
// Expectations from `Store::set_asset_tax_basis` (journaled as
// `asset.tax_basis_set`), `Ledger::set_asset_tax_basis` INVARIANTS and
// SCENARIOS, and the schema v10 migration comment (tax_rule and
// contributor_basis as TEXT, exact decimal).
// Expectations from `Store::set_asset_basis` (journaled as
// `asset.basis_set`, the feature `asset_basis` declared first),
// `Ledger::set_asset_basis` INVARIANTS and SCENARIOS, the replay of
// pre-P3 `asset.tax_basis_set` lines, and the schema v10 and v13
// migration comments (tax_rule, contributor_basis, fmv_at_contribution as
// TEXT, exact decimal).
fn tax(rule: ledger_core::TaxBasisRule, basis: Option<&str>) -> ledger_core::TaxBasis {
ledger_core::TaxBasis {
fn dec(s: &str) -> rust_decimal::Decimal {
s.parse().unwrap()
}
fn contribution(
rule: ledger_core::BasisRule,
adjusted: &str,
fmv: &str,
) -> ledger_core::AssetBasis {
ledger_core::AssetBasis::contribution(rule, dec(adjusted), dec(fmv))
}
/// A pre-P3 record, as `asset.tax_basis_set` replays it.
fn legacy(rule: ledger_core::BasisRule, basis: Option<&str>) -> ledger_core::AssetBasis {
ledger_core::AssetBasis {
rule,
contributor_basis: basis.map(|b| b.parse().unwrap()),
contributor_adjusted_basis: basis.map(dec),
fmv_at_contribution: None,
sec704c_method: None,
source: None,
}
}
fn tax_of(s: &Store, id: AssetId) -> Option<ledger_core::TaxBasis> {
s.ledger().get_asset(id).unwrap().tax
fn basis_of(s: &Store, id: AssetId) -> Option<ledger_core::AssetBasis> {
s.ledger().get_asset(id).unwrap().basis.clone()
}
/// Journaled as `asset.tax_basis_set` {asset_id, rule, contributor_basis}:
/// the basis a decimal string, or null for a purchase.
/// Journaled as `asset.basis_set` {asset_id, rule, contributor_adjusted_basis,
/// fmv_at_contribution, sec704c_method, source, tax_basis_723,
/// depreciable_basis, built_in_gain_704c}: the facts, and the figures for
/// the line's readers (a purchase's depreciable basis null: the cost,
/// whatever it becomes). The first declares the feature asset_basis.
#[test]
fn tax_basis_journals_asset_tax_basis_set() {
use ledger_core::TaxBasisRule::*;
fn a_basis_journals_asset_basis_set() {
use ledger_core::BasisRule::*;
let (dir, mut s, _, x, y) = fixture("journal-tax");
s.set_asset_tax_basis(x, tax(Cost, None)).unwrap();
s.set_asset_tax_basis(y, tax(PersonalUseConversion, Some("1500.25")))
.unwrap();
s.set_asset_tax_basis(x, tax(Carryover, Some("800.00")))
s.set_asset_basis(x, ledger_core::AssetBasis::purchase())
.unwrap();
let mut b = contribution(PersonalUseConversion, "1500.25", "1200.00");
b.sec704c_method = Some(ledger_core::Sec704cMethod::Traditional);
s.set_asset_basis(y, b.clone()).unwrap();
let j = journal(&dir);
let with_feature = |p: &Value| {
p["features"]
.as_array()
.is_some_and(|f| f.contains(&json!("asset_basis")))
};
let declared: Vec<usize> = j
.iter()
.enumerate()
.filter(|(_, (t, p))| t == "journal.format_set" && with_feature(p))
.map(|(i, _)| i)
.collect();
let first = j.iter().position(|(t, _)| t == "asset.basis_set").unwrap();
assert_eq!(declared.len(), 1, "declared once");
assert!(declared[0] < first, "before the first asset.basis_set");
let set: Vec<&Value> = j
.iter()
.filter(|(t, _)| t == "asset.tax_basis_set")
.filter(|(t, _)| t == "asset.basis_set")
.map(|(_, p)| p)
.collect();
assert_eq!(set.len(), 3);
assert_eq!(set.len(), 2);
assert_eq!(
set[0],
&json!({"asset_id": x, "rule": "cost", "contributor_basis": null})
&json!({"asset_id": x, "rule": "purchase", "contributor_adjusted_basis": null,
"fmv_at_contribution": null, "sec704c_method": null, "source": null,
"tax_basis_723": null, "depreciable_basis": null, "built_in_gain_704c": null})
);
assert_eq!(set[1]["asset_id"], json!(y));
assert_eq!(set[1]["rule"], "personal_use_conversion");
let b = set[1]["contributor_basis"]
.as_str()
.expect("a decimal string");
assert_eq!(
b.parse::<rust_decimal::Decimal>().unwrap(),
"1500.25".parse().unwrap()
);
assert_eq!(set[2]["rule"], "carryover");
let b = set[2]["contributor_basis"]
.as_str()
.expect("a decimal string");
assert_eq!(
b.parse::<rust_decimal::Decimal>().unwrap(),
"800.00".parse().unwrap()
);
// Set again replaces (the journal keeps both).
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("800.00"))));
assert_eq!(
s.ledger().get_asset(y).unwrap().tax_basis(),
Some("1200.00".parse().unwrap()),
"lesser of the 1200.00 book cost and the 1500.25 basis"
set[1],
&json!({"asset_id": y, "rule": "personal_use_conversion",
"contributor_adjusted_basis": "1500.25", "fmv_at_contribution": "1200.00",
"sec704c_method": "traditional", "source": null,
"tax_basis_723": "1500.25", "depreciable_basis": "1200.00",
"built_in_gain_704c": "-300.25"})
);
assert_eq!(basis_of(&s, y), Some(b));
s.verify().unwrap();
}
/// A refused tax basis (Ledger::set_asset_tax_basis INVARIANT 3) journals
/// nothing and changes nothing, now or on reopen.
/// A refused basis (Ledger::set_asset_basis INVARIANT 3) journals nothing
/// (not even the feature declaration) and changes nothing, now or on
/// reopen. A contribution's value is required.
#[test]
fn a_refused_tax_basis_journals_nothing() {
use ledger_core::TaxBasisRule::*;
fn a_refused_basis_journals_nothing() {
use ledger_core::BasisRule::*;
let (dir, mut s, _, x, _) = fixture("refused-tax");
s.set_asset_tax_basis(x, tax(Carryover, Some("100.00")))
.unwrap();
let lines = journal(&dir).len();
let before = s.ledger().clone();
let mut purchase_with_basis = ledger_core::AssetBasis::purchase();
purchase_with_basis.contributor_adjusted_basis = Some(dec("100.00"));
let mut no_fmv = contribution(Carryover723, "100.00", "100.00");
no_fmv.fmv_at_contribution = None;
for bad in [
tax(Cost, Some("100.00")),
tax(Carryover, None),
tax(PersonalUseConversion, None),
tax(Carryover, Some("-0.01")),
tax(PersonalUseConversion, Some("10.001")),
purchase_with_basis,
no_fmv,
legacy(PersonalUseConversion, None),
contribution(Carryover723, "-0.01", "1.00"),
contribution(PersonalUseConversion, "10.001", "1.00"),
] {
assert!(
matches!(
s.set_asset_tax_basis(x, bad),
s.set_asset_basis(x, bad.clone()),
Err(StoreError::Ledger(LedgerError::InvalidTaxBasis(_)))
),
"{bad:?}"
);
}
assert!(matches!(
s.set_asset_tax_basis(x + 99, tax(Cost, None)),
s.set_asset_basis(x + 99, ledger_core::AssetBasis::purchase()),
Err(StoreError::Ledger(LedgerError::UnknownAsset(_)))
));
assert_eq!(journal(&dir).len(), lines, "nothing journaled");
@@ -674,18 +701,27 @@ fn a_refused_tax_basis_journals_nothing() {
s.verify().unwrap();
}
/// Tax basis replays on reopen, with the database and rebuilt from the
/// journal alone, and verifies each time.
/// A basis replays on reopen, with the database and rebuilt from the
/// journal alone, and in the viewer, and verifies each time (its facts and
/// source included).
#[test]
fn tax_basis_replays_on_reopen() {
use ledger_core::TaxBasisRule::*;
fn asset_basis_set_journals_replays_and_reloads() {
use ledger_core::BasisRule::*;
let (dir, mut s, a, x, y) = fixture("replay-tax");
let z = laptop(&mut s, &a, "Laptop C");
s.set_asset_tax_basis(x, tax(Carryover, Some("900.00")))
s.set_asset_basis(x, contribution(Carryover723, "900.00", "1200.00"))
.unwrap();
s.set_asset_tax_basis(x, tax(PersonalUseConversion, Some("2000.00")))
let mut b = contribution(PersonalUseConversion, "2000.00", "1200.00");
b.sec704c_method = Some(ledger_core::Sec704cMethod::Remedial);
b.source = Some(ledger_core::settings::Source {
decided_by: "Tobin Harlow".into(),
decided_on: date(2026, 10, 20),
citation: Some("entry 3".into()),
note: Some("value as booked".into()),
});
s.set_asset_basis(x, b.clone()).unwrap();
s.set_asset_basis(y, ledger_core::AssetBasis::purchase())
.unwrap();
s.set_asset_tax_basis(y, tax(Cost, None)).unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
@@ -696,34 +732,223 @@ fn tax_basis_replays_on_reopen() {
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(s.ledger(), &live, "rebuild={rebuild}");
assert_eq!(
tax_of(&s, x),
Some(tax(PersonalUseConversion, Some("2000.00")))
);
assert_eq!(tax_of(&s, y), Some(tax(Cost, None)));
assert_eq!(tax_of(&s, z), None);
assert_eq!(basis_of(&s, x), Some(b.clone()));
assert_eq!(basis_of(&s, y), Some(ledger_core::AssetBasis::purchase()));
assert_eq!(basis_of(&s, z), None);
let ax = s.ledger().get_asset(x).unwrap();
assert_eq!(ax.tax_basis(), Some("1200.00".parse().unwrap()));
assert_eq!(ax.built_in_gain(), Some("-800.00".parse().unwrap()));
assert_eq!(ax.depreciable_basis(), Some(dec("1200.00")));
assert_eq!(ax.built_in_gain_704c(), Some(dec("-800.00")));
drop(s);
}
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger(), &live, "the viewer");
}
/// Pre-P3 `asset.tax_basis_set` lines replay to the figures that version
/// gave, with the database and rebuilt from the journal.
#[test]
fn legacy_tax_basis_set_lines_replay_to_todays_figures() {
use ledger_core::BasisRule::*;
let (dir, s, _, x, y) = fixture("legacy-tax");
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"asset.tax_basis_set",
json!({"asset_id": x, "rule": "personal_use_conversion", "contributor_basis": "1500.25"}),
)
.unwrap();
w.append(
"asset.tax_basis_set",
json!({"asset_id": y, "rule": "cost", "contributor_basis": null}),
)
.unwrap();
drop(w);
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(db_file())).unwrap();
}
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(
basis_of(&s, x),
Some(legacy(PersonalUseConversion, Some("1500.25")))
);
assert_eq!(basis_of(&s, y), Some(legacy(Purchase, None)));
let ax = s.ledger().get_asset(x).unwrap();
// The lesser of the 1,200.00 book cost and the 1,500.25 basis.
assert_eq!(ax.depreciable_basis(), Some(dec("1200.00")));
assert_eq!(ax.tax_basis_723(), Some(dec("1500.25")));
assert_eq!(ax.built_in_gain_704c(), Some(dec("-300.25")));
assert!(!ax.fmv_is_recorded());
drop(s);
}
}
/// Review R1: a line's figures are for its readers; replay never
/// recomputes them, so a line whose figures disagree with today's formula
/// (a later version's, or a CPA's correction) still replays, and the
/// books show today's figures from its facts.
#[test]
fn a_basis_set_whose_figures_disagree_still_replays() {
let (dir, s, _, x, _) = fixture("figures-disagree");
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["asset_basis"]}),
)
.unwrap();
w.append(
"asset.basis_set",
json!({"asset_id": x, "rule": "personal_use_conversion",
"contributor_adjusted_basis": "1500.00", "fmv_at_contribution": "1200.00",
"sec704c_method": null, "source": null,
"tax_basis_723": "1.00", "depreciable_basis": "1500.00", "built_in_gain_704c": "0"}),
)
.unwrap();
drop(w);
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(db_file())).unwrap();
}
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
let a = s.ledger().get_asset(x).unwrap();
assert_eq!(a.depreciable_basis(), Some(dec("1200.00")));
assert_eq!(a.tax_basis_723(), Some(dec("1500.00")));
assert_eq!(a.built_in_gain_704c(), Some(dec("-300.00")));
drop(s);
}
}
/// A basis rule or §704(c) method this version doesn't know was written
/// by a newer one: NewerJournal, never corrupt (rules.md B-27); a figure
/// or fact that isn't a decimal string (or null) is corrupt.
#[test]
fn an_unknown_basis_rule_or_method_is_newer() {
let base = json!({"rule": "carryover_723", "contributor_adjusted_basis": "1.00",
"fmv_at_contribution": "1.00", "sec704c_method": null, "source": null,
"tax_basis_723": "1.00", "depreciable_basis": "1.00", "built_in_gain_704c": "0.00"});
let with = |k: &str, v: Value| {
let mut p = base.clone();
p[k] = v;
p
};
for (bad, newer) in [
(with("rule", json!("gift_1015")), true),
(with("sec704c_method", json!("aggregated")), true),
(with("rule", json!(7)), false),
(with("sec704c_method", json!(1)), false),
(with("fmv_at_contribution", json!(1.5)), false),
(with("tax_basis_723", json!("one")), false),
(with("source", json!({"decided_by": 1})), false),
(with("rule", json!("cost")), true),
] {
let (dir, s, _, x, _) = fixture("newer-basis");
drop(s);
let mut p = bad.clone();
p["asset_id"] = json!(x);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["asset_basis"]}),
)
.unwrap();
w.append("asset.basis_set", p).unwrap();
drop(w);
match (Store::open(&dir), newer) {
(Err(StoreError::NewerJournal { .. }), true) => {}
(Err(StoreError::JournalCorrupt(_)), false) => {}
(Err(other), _) => panic!("{bad}: got {other:?}"),
(Ok(_), _) => panic!("{bad}: opened"),
}
}
}
/// Downgrade the database to schema v12 (none of v13's 1099 decisions or
/// payment methods, nor its basis columns; runs keyed by asset and
/// period), as P2 left it.
fn downgrade_db_to_v12(db: &Path) {
let conn = rusqlite::Connection::open(db).unwrap();
conn.execute_batch(
"DROP TABLE vendor_1099_decisions;
DROP TABLE instruments;
DROP TABLE entry_instruments;
ALTER TABLE assets DROP COLUMN fmv_at_contribution;
ALTER TABLE assets DROP COLUMN sec704c_method;
ALTER TABLE assets DROP COLUMN basis_source_json;
CREATE TABLE asset_depreciation_v12(asset_id INTEGER NOT NULL, period TEXT NOT NULL,
entry_id INTEGER NOT NULL, amount TEXT NOT NULL, PRIMARY KEY (asset_id, period));
INSERT INTO asset_depreciation_v12 SELECT asset_id, period, entry_id, amount FROM asset_depreciation;
DROP TABLE asset_depreciation;
ALTER TABLE asset_depreciation_v12 RENAME TO asset_depreciation;
UPDATE meta SET value = '12' WHERE key = 'schema_version';",
)
.unwrap();
}
/// Downgrade the database to schema v10 (none of v11's attachment links,
/// statements, reconciliations, or payroll_lines.employee_id).
fn downgrade_db_to_v10(dir: &Path) {
downgrade_db_to_v12(&dir.join(db_file()));
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
conn.execute_batch(
"DROP TABLE attachment_links;
DROP TABLE statements;
DROP TABLE reconciliations;
DROP TABLE reconciliation_lines;
DROP TABLE settings;
DROP TABLE conventions;
DROP TABLE decisions;
DROP TABLE open_items;
DROP TABLE entry_approvals;
ALTER TABLE payroll_lines DROP COLUMN employee_id;
UPDATE meta SET value = '10' WHERE key = 'schema_version';",
)
.unwrap();
}
/// P2's database (`wanderbooks.db`, schema v12) is left as it is, byte
/// for byte: this version builds its own v13 file from the journal, its
/// runs table keyed by entry with every run kept and the new columns
/// empty, and it verifies; a basis set after then reloads from it (each
/// schema in a file of its own: rules.md B-27).
#[test]
fn a_p2_database_is_left_and_v13_built_from_the_journal() {
use ledger_core::BasisRule::*;
let (dir, mut s, _, x, y) = fixture("v12");
s.run_depreciation(x, "2026-01").unwrap();
drop(s);
// As P2 left the books: its schema in the legacy file, none of this
// version's.
let legacy = dir.join(ledger_store::LEGACY_DB_FILE);
std::fs::rename(dir.join(db_file()), &legacy).unwrap();
downgrade_db_to_v12(&legacy);
let p2 = std::fs::read(&legacy).unwrap();
let mut s = Store::open(&dir).expect("P2's books open");
assert_eq!(s.database_file(), db_file());
s.verify().unwrap();
assert_eq!(s.ledger().depreciation_runs(x).len(), 1);
assert_eq!(basis_of(&s, y), None);
s.set_asset_basis(y, contribution(Carryover723, "640.00", "1200.00"))
.unwrap();
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(
basis_of(&s, y),
Some(contribution(Carryover723, "640.00", "1200.00"))
);
assert_eq!(s.ledger().depreciation_runs(x).len(), 1);
drop(s);
assert_eq!(
std::fs::read(&legacy).unwrap(),
p2,
"P2's file, byte for byte"
);
}
/// A database at schema v10 under this version's own file name is rebuilt
/// from the journal as the books open (as the v8 one above), and verifies.
#[test]
@@ -752,10 +977,10 @@ fn downgrade_db_to_v9(dir: &Path) {
}
/// A database at schema v9 under this version's own file name is rebuilt
/// as the books open (as the v8 one above), verifies, and takes tax bases.
/// as the books open (as the v8 one above), verifies, and takes bases.
#[test]
fn a_v9_database_migrates_and_verifies() {
use ledger_core::TaxBasisRule::*;
use ledger_core::BasisRule::*;
let (dir, mut s, _, x, y) = fixture("v9");
s.set_asset_tag(x, "FL-0001").unwrap();
drop(s);
@@ -764,21 +989,24 @@ fn a_v9_database_migrates_and_verifies() {
assert!(kept_aside(&dir), "rebuilt, not migrated");
s.verify().unwrap();
assert_eq!(tag_of(&s, x).as_deref(), Some("FL-0001"));
assert_eq!(tax_of(&s, x), None);
s.set_asset_tax_basis(y, tax(Carryover, Some("640.00")))
assert_eq!(basis_of(&s, x), None);
s.set_asset_basis(y, contribution(Carryover723, "640.00", "1200.00"))
.unwrap();
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(tax_of(&s, y), Some(tax(Carryover, Some("640.00"))));
assert_eq!(
basis_of(&s, y),
Some(contribution(Carryover723, "640.00", "1200.00"))
);
}
/// A v9 database behind a journal with a tax basis line is rebuilt from
/// the whole journal, that line with it, and verifies.
#[test]
fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
use ledger_core::TaxBasisRule::*;
use ledger_core::BasisRule::*;
let (dir, s, _, x, _) = fixture("v9-behind");
drop(s);
downgrade_db_to_v9(&dir);
@@ -792,7 +1020,7 @@ fn a_v9_database_behind_a_journal_with_tax_basis_catches_up() {
let s = Store::open(&dir).expect("opens and catches up");
assert!(kept_aside(&dir), "rebuilt, not migrated");
s.verify().unwrap();
assert_eq!(tax_of(&s, x), Some(tax(Carryover, Some("300.00"))));
assert_eq!(basis_of(&s, x), Some(legacy(Carryover723, Some("300.00"))));
}
/// An `asset.tax_basis_set` line with an unknown rule, or a contributor
@@ -841,7 +1069,7 @@ fn an_asset_is_corrected_until_depreciation_is_posted() {
cost: usd(cost),
salvage_value: usd("0.00"),
useful_life_months: 12,
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
};
let before = journal(&dir).len();
assert!(matches!(
@@ -903,3 +1131,85 @@ fn reversing_a_linked_acquisition_still_opens_and_verifies() {
drop(s);
Store::open(&dir).unwrap().verify().unwrap();
}
// ------------------------------------------------- depreciation runs (rules.md B-24)
/// A run is the books' record of what they carry for an asset
/// (Ledger::accumulated_posted): recorded live, replayed from the journal,
/// reloaded from the database, the same in the viewer, and verify
/// catches a run the database holds differently from the journal (review
/// R5).
#[test]
fn runs_replay_reload_and_verify_catches_a_tampered_run() {
let (dir, mut s, _, x, _) = fixture("runs");
s.run_depreciation(x, "2026-01").unwrap();
s.run_depreciation(x, "2026-02").unwrap();
let live = s.ledger().depreciation_runs(x).to_vec();
assert_eq!(live.len(), 2);
assert_eq!(
s.ledger().accumulated_posted(x, date(2026, 2, 28)).unwrap(),
dec("200.00")
);
s.verify().unwrap();
drop(s);
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(db_file())).unwrap();
}
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger().depreciation_runs(x), live.as_slice());
s.verify().unwrap();
drop(s);
}
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger().depreciation_runs(x), live.as_slice());
let conn = rusqlite::Connection::open(dir.join(db_file())).unwrap();
conn.execute(
"UPDATE asset_depreciation SET amount = '99.00' WHERE period = '2026-02'",
[],
)
.unwrap();
drop(conn);
let s = Store::open(&dir).unwrap();
assert!(matches!(s.verify(), Err(StoreError::VerifyMismatch(_))));
}
/// The writer and the viewer read an `asset.depreciation_posted` line the
/// same way: well formed and naming an asset and an entry that exist, it
/// replays (any period an earlier version journaled, rules.md B-28);
/// otherwise both refuse it as corrupt.
#[test]
fn the_viewer_and_writer_agree_on_a_bad_depreciation_posted_payload() {
for (amount, asset_known, ok) in [
("lots", true, false),
("1.00", false, false),
("0.001", true, true),
] {
let (dir, mut s, a, x, _) = fixture("bad-run");
let e = s.create_entry(date(2026, 1, 31), "depreciation").unwrap();
s.add_line(e, a.expense, ledger_core::Side::Debit, usd("1.00"))
.unwrap();
s.add_line(e, a.accum, ledger_core::Side::Credit, usd("1.00"))
.unwrap();
s.post_entry(e).unwrap();
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"asset.depreciation_posted",
json!({"asset_id": if asset_known { x } else { x + 99 }, "period": "10000-01",
"entry_id": e, "amount": amount}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(db_file()));
let writer = Store::open(&dir).map(|s| s.ledger().depreciation_runs(x).len());
let viewer =
ledger_store::Snapshot::load(&dir).map(|s| s.ledger().depreciation_runs(x).len());
assert_eq!(writer.is_ok(), ok, "{amount} {asset_known}: {writer:?}");
assert_eq!(viewer.is_ok(), ok, "{amount} {asset_known}: {viewer:?}");
if ok {
assert_eq!(writer.unwrap(), 1);
assert_eq!(viewer.unwrap(), 1);
}
}
}
+6 -6
View File
@@ -61,7 +61,7 @@ fn van(store: &mut Store, dollars: u64, cost_acct: u64, accum_acct: u64, expense
store
.register_asset(NewAsset {
description: "Sprinter van".to_string(),
purchase_date: date(2026, 1, 15),
in_service_date: date(2026, 1, 15),
cost: usd(&dollars.to_string()),
salvage_value: usd("0"),
useful_life_months: 60,
@@ -230,7 +230,7 @@ fn rejected_asset_registration_journals_nothing() {
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
let bad = NewAsset {
description: "Bad".to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd("1000"),
salvage_value: usd("1000"), // salvage >= cost: invalid
useful_life_months: 12,
@@ -255,7 +255,7 @@ fn asset_x(store: &mut Store, cost: u64, accum: u64, expense: u64) -> u64 {
store
.register_asset(NewAsset {
description: "Asset X".to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd("12000.00"),
salvage_value: usd("0"),
useful_life_months: 12,
@@ -313,7 +313,7 @@ fn defect22_a_disposal_in_the_final_month_posts_depreciation_by_days() {
// The books' accumulated equals the disposed schedule's.
let a = store.ledger().get_asset(x).unwrap();
assert_eq!(
ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
ledger_core::accumulated_scheduled_through(a, NaiveDate::MAX).unwrap(),
dec("11322.58")
);
store.verify().unwrap();
@@ -552,7 +552,7 @@ fn run_depreciation_posts_nothing_for_a_month_that_rounds_to_nothing() {
let tiny = store
.register_asset(NewAsset {
description: "Cable".to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd("0.01"),
salvage_value: usd("0"),
useful_life_months: 3,
@@ -587,7 +587,7 @@ fn jpy_depreciation_runs_post_whole_yen() {
let id = store
.register_asset(NewAsset {
description: "Lathe".to_string(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: yen("100000"),
salvage_value: yen("0"),
useful_life_months: 12,
+1 -1
View File
@@ -302,7 +302,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec<Subject> {
let asset = s
.register_asset(NewAsset {
description: "Laptop".into(),
purchase_date: date(2026, 1, 5),
in_service_date: date(2026, 1, 5),
cost: usd("3000.00"),
salvage_value: usd("0"),
useful_life_months: 36,
+1 -1
View File
@@ -199,7 +199,7 @@ fn disposal_proceeds_larger_than_a_line_are_refused() {
let id = s
.register_asset(ledger_core::NewAsset {
description: "Laptop".into(),
purchase_date: d(2026, 1, 1),
in_service_date: d(2026, 1, 1),
cost: usd(Decimal::new(240000, 2)),
salvage_value: usd(Decimal::ZERO),
useful_life_months: 24,
+1 -1
View File
@@ -1280,7 +1280,7 @@ fn a_write_waiting_for_the_upgrade_journals_nothing() {
let lathe = |s: &mut Store, what: &str| {
s.register_asset(ledger_core::NewAsset {
description: what.into(),
purchase_date: day(2026, 1, 1),
in_service_date: day(2026, 1, 1),
cost: usd(1_200_000),
salvage_value: usd(0),
useful_life_months: 12,
+1 -1
View File
@@ -465,7 +465,7 @@ fn inv5_refused_disposal_writes_nothing() {
.store
.register_asset(NewAsset {
description: "Laptop".into(),
purchase_date: date(2026, 1, 1),
in_service_date: date(2026, 1, 1),
cost: usd(3_600),
salvage_value: usd(0),
useful_life_months: 36,
+6 -6
View File
@@ -1365,7 +1365,7 @@ mod refusals {
Op::RegisterAsset(c, d, x, cost, m) => store
.register_asset(ledger_core::NewAsset {
description: "van".into(),
purchase_date: day(0),
in_service_date: day(0),
cost: usd(cost),
salvage_value: usd(0),
useful_life_months: m,
@@ -1448,7 +1448,7 @@ mod refusals {
Op::GoodAsset(c, m) => store
.register_asset(ledger_core::NewAsset {
description: "machine".into(),
purchase_date: day(0),
in_service_date: day(0),
cost: usd(c),
salvage_value: usd(0),
useful_life_months: m,
@@ -1939,7 +1939,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
let dates: Vec<NaiveDate> = (1..l.next_entry_id())
.filter_map(|id| l.get_entry(id))
.map(|e| e.date())
.chain(l.registered_assets().map(|a| a.purchase_date))
.chain(l.registered_assets().map(|a| a.in_service_date))
.chain(l.list_periods().iter().flat_map(|p| [p.start, p.end]))
.chain(l.schedules().map(|s| s.start))
.collect();
@@ -1995,7 +1995,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
}
for a in l.registered_assets() {
tally(ledger_core::depreciation_schedule(a, d).map(|_| ()));
tally(ledger_core::book_value(a, d).map(|_| ()));
tally(ledger_core::scheduled_book_value(a, d).map(|_| ()));
tally(
ledger_core::disposal_amounts(
a,
@@ -2253,7 +2253,7 @@ fn scrapping_an_asset_for_nothing_disposes_it() {
let asset = store
.register_asset(ledger_core::NewAsset {
description: "laptop".into(),
purchase_date: day(1, 1),
in_service_date: day(1, 1),
cost: usd(1200),
salvage_value: usd(0),
useful_life_months: 12,
@@ -2442,7 +2442,7 @@ fn laptop(dir: &std::path::Path) -> (Store, u64, u64, u64) {
let asset = store
.register_asset(ledger_core::NewAsset {
description: "laptop".into(),
purchase_date: day(1, 1),
in_service_date: day(1, 1),
cost: usd(1200),
salvage_value: usd(0),
useful_life_months: 12,