asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs
This commit is contained in:
1 parent
7098ac08e5
commit
1d211c8faa
1 file changed
+235
@@ -0,0 +1,235 @@
|
|||||||
|
//! Fixed-asset store tests: journaling, idempotency, disposal, and verify.
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::{AccountType, Currency, Money, NewAsset, Side};
|
||||||
|
use ledger_store::Store;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn usd(amount: &str) -> Money {
|
||||||
|
Money::new(amount.parse().unwrap(), Currency::USD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_store() -> (tempfile::TempDir, Store) {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let store = Store::create(dir.path(), Currency::USD).unwrap();
|
||||||
|
(dir, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asset_accounts(store: &mut Store) -> (u64, u64, u64, u64, u64) {
|
||||||
|
let cost = store
|
||||||
|
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let accum = store
|
||||||
|
.add_account(
|
||||||
|
"1501",
|
||||||
|
"Accumulated Depreciation",
|
||||||
|
AccountType::Asset,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let expense = store
|
||||||
|
.add_account(
|
||||||
|
"6100",
|
||||||
|
"Depreciation Expense",
|
||||||
|
AccountType::Expense,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bank = store
|
||||||
|
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let gain = store
|
||||||
|
.add_account(
|
||||||
|
"4900",
|
||||||
|
"Gain on Disposal",
|
||||||
|
AccountType::Revenue,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(cost, accum, expense, bank, gain)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn van(store: &mut Store, dollars: u64, cost_acct: u64, accum_acct: u64, expense_acct: u64) -> u64 {
|
||||||
|
store
|
||||||
|
.register_asset(NewAsset {
|
||||||
|
description: "Sprinter van".to_string(),
|
||||||
|
purchase_date: date(2026, 1, 15),
|
||||||
|
cost: usd(&dollars.to_string()),
|
||||||
|
salvage_value: usd("0"),
|
||||||
|
useful_life_months: 60,
|
||||||
|
cost_account: cost_acct,
|
||||||
|
accum_depr_account: accum_acct,
|
||||||
|
expense_account: expense_acct,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_survives_reopen_with_stable_ids() {
|
||||||
|
let (dir, mut store) = open_store();
|
||||||
|
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||||
|
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let mut store = Store::open(dir.path()).unwrap();
|
||||||
|
let asset = store.ledger().get_asset(id).expect("asset must survive");
|
||||||
|
assert_eq!(asset.description, "Sprinter van");
|
||||||
|
assert_eq!(asset.cost.amount, Decimal::from(60_000));
|
||||||
|
// Allocator stability: the next registration continues the sequence.
|
||||||
|
let id2 = van(&mut store, 1_000, cost, accum, expense);
|
||||||
|
assert_eq!(id2, id + 1);
|
||||||
|
store.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn depreciation_run_is_idempotent() {
|
||||||
|
let (_dir, mut store) = open_store();
|
||||||
|
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||||
|
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||||
|
|
||||||
|
let run1 = store.run_depreciation(id, "2026-01").unwrap();
|
||||||
|
assert!(!run1.already_posted);
|
||||||
|
assert_eq!(run1.amount, Decimal::new(54839, 2)); // 1000 * 17/31
|
||||||
|
|
||||||
|
// Second run of the same period is a no-op returning the same entry.
|
||||||
|
let run2 = store.run_depreciation(id, "2026-01").unwrap();
|
||||||
|
assert!(run2.already_posted);
|
||||||
|
assert_eq!(run2.entry_id, run1.entry_id);
|
||||||
|
assert_eq!(run2.amount, run1.amount);
|
||||||
|
|
||||||
|
// A later period posts its own entry.
|
||||||
|
let run3 = store.run_depreciation(id, "2026-02").unwrap();
|
||||||
|
assert!(!run3.already_posted);
|
||||||
|
assert_eq!(run3.amount, Decimal::from(1_000));
|
||||||
|
|
||||||
|
// The depreciation entries hit the right accounts.
|
||||||
|
let entry = store.ledger().get_entry(run3.entry_id).unwrap();
|
||||||
|
assert!(entry.lines().iter().any(|l| l.account == expense
|
||||||
|
&& l.side == Side::Debit
|
||||||
|
&& l.amount.amount == Decimal::from(1_000)));
|
||||||
|
store.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn depreciation_rejects_period_outside_life() {
|
||||||
|
let (_dir, mut store) = open_store();
|
||||||
|
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||||
|
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||||
|
// Before purchase.
|
||||||
|
assert!(store.run_depreciation(id, "2025-12").is_err());
|
||||||
|
// After the 60-month life ends (2031-01).
|
||||||
|
assert!(store.run_depreciation(id, "2031-06").is_err());
|
||||||
|
// Malformed period.
|
||||||
|
assert!(store.run_depreciation(id, "2026-13").is_err());
|
||||||
|
assert!(store.run_depreciation(id, "january").is_err());
|
||||||
|
// Unknown asset.
|
||||||
|
assert!(store.run_depreciation(999, "2026-01").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disposal_posts_balanced_entry_and_marks_asset() {
|
||||||
|
let (_dir, mut store) = open_store();
|
||||||
|
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||||
|
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||||
|
store.run_depreciation(id, "2026-01").unwrap();
|
||||||
|
store.run_depreciation(id, "2026-02").unwrap();
|
||||||
|
|
||||||
|
// Book value after two months: 60000 − (548.39 + 1000) = 58451.61.
|
||||||
|
// Sell for 60,000 → gain of 1548.39.
|
||||||
|
let entry_id = store
|
||||||
|
.dispose_asset(id, date(2026, 2, 28), usd("60000"), bank, gain)
|
||||||
|
.unwrap();
|
||||||
|
let entry = store.ledger().get_entry(entry_id).unwrap();
|
||||||
|
let debits: Decimal = entry
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.side == Side::Debit)
|
||||||
|
.map(|l| l.amount.amount)
|
||||||
|
.sum();
|
||||||
|
let credits: Decimal = entry
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.side == Side::Credit)
|
||||||
|
.map(|l| l.amount.amount)
|
||||||
|
.sum();
|
||||||
|
assert_eq!(debits, credits, "disposal entry must balance");
|
||||||
|
assert_eq!(debits, Decimal::new(154839, 2) + Decimal::from(60_000));
|
||||||
|
|
||||||
|
// Asset is marked disposed; further depreciation is refused.
|
||||||
|
assert!(matches!(
|
||||||
|
store.ledger().get_asset(id).unwrap().status,
|
||||||
|
ledger_core::AssetStatus::Disposed { .. }
|
||||||
|
));
|
||||||
|
assert!(store.run_depreciation(id, "2026-03").is_err());
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.dispose_asset(id, date(2026, 3, 1), usd("1"), bank, gain)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
store.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disposal_at_loss_uses_debit_side() {
|
||||||
|
let (_dir, mut store) = open_store();
|
||||||
|
let (cost, accum, expense, bank, _) = asset_accounts(&mut store);
|
||||||
|
let loss = store
|
||||||
|
.add_account(
|
||||||
|
"6900",
|
||||||
|
"Loss on Disposal",
|
||||||
|
AccountType::Expense,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||||
|
// Dispose 2026-01-31 with no prior depreciation runs: the final-period
|
||||||
|
// depreciation (Jan, 548.39) is auto-posted first. Book value 59451.61,
|
||||||
|
// sell for 50,000 → loss 9451.61.
|
||||||
|
let entry_id = store
|
||||||
|
.dispose_asset(id, date(2026, 1, 31), usd("50000"), bank, loss)
|
||||||
|
.unwrap();
|
||||||
|
let entry = store.ledger().get_entry(entry_id).unwrap();
|
||||||
|
let loss_line = entry.lines().iter().find(|l| l.account == loss).unwrap();
|
||||||
|
assert_eq!(loss_line.side, Side::Debit);
|
||||||
|
assert_eq!(loss_line.amount.amount, Decimal::new(945161, 2));
|
||||||
|
// The auto-posted depreciation entry exists for the disposal month.
|
||||||
|
let posted = store
|
||||||
|
.ledger()
|
||||||
|
.posted_entries()
|
||||||
|
.filter(|e| e.description().contains("(final)"))
|
||||||
|
.count();
|
||||||
|
assert_eq!(posted, 1);
|
||||||
|
store.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_asset_registration_journals_nothing() {
|
||||||
|
let (_dir, mut store) = open_store();
|
||||||
|
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||||
|
let bad = NewAsset {
|
||||||
|
description: "Bad".to_string(),
|
||||||
|
purchase_date: date(2026, 1, 1),
|
||||||
|
cost: usd("1000"),
|
||||||
|
salvage_value: usd("1000"), // salvage >= cost: invalid
|
||||||
|
useful_life_months: 12,
|
||||||
|
cost_account: cost,
|
||||||
|
accum_depr_account: accum,
|
||||||
|
expense_account: expense,
|
||||||
|
};
|
||||||
|
assert!(store.register_asset(bad).is_err());
|
||||||
|
// Journal holds only the setup lines; no asset.registered.
|
||||||
|
let events = store.events_since(0, 100).unwrap();
|
||||||
|
assert!(events.iter().all(|e| e.event_type != "asset.registered"));
|
||||||
|
// And the allocator did not advance.
|
||||||
|
let id = van(&mut store, 1_000, cost, accum, expense);
|
||||||
|
assert_eq!(id, 1);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user