asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs

This commit is contained in:
swaits committed 2026-10-11 02:25:08 +00:00
1 parent e0214fcc4c
commit 1f9f441a16
1 file changed
+125 -3
+125 -3
View File
@@ -7,10 +7,10 @@
//! audit trail and what will make the future oracle harness trustworthy. //! audit trail and what will make the future oracle harness trustworthy.
use crate::{ use crate::{
AccountId, AccountType, ChartOfAccounts, Currency, EntryId, EntryStatus, JournalEntry, AccountId, AccountType, Asset, AssetId, AssetStatus, ChartOfAccounts, Currency, EntryId,
JournalLine, LedgerError, Money, Side, EntryStatus, JournalEntry, JournalLine, LedgerError, Money, NewAsset, Side, assets,
}; };
use chrono::NaiveDate; use chrono::{Months, NaiveDate};
use rust_decimal::Decimal; use rust_decimal::Decimal;
use std::collections::HashMap; use std::collections::HashMap;
@@ -22,8 +22,11 @@ pub struct Ledger {
entries: HashMap<EntryId, JournalEntry>, entries: HashMap<EntryId, JournalEntry>,
/// Entry ids in posting order: the append-only log. /// Entry ids in posting order: the append-only log.
order: Vec<EntryId>, order: Vec<EntryId>,
/// Fixed-asset register: master data, journaled like accounts.
assets: HashMap<AssetId, Asset>,
next_account_id: AccountId, next_account_id: AccountId,
next_entry_id: EntryId, next_entry_id: EntryId,
next_asset_id: AssetId,
} }
impl Ledger { impl Ledger {
@@ -34,8 +37,10 @@ impl Ledger {
chart: ChartOfAccounts::default(), chart: ChartOfAccounts::default(),
entries: HashMap::new(), entries: HashMap::new(),
order: Vec::new(), order: Vec::new(),
assets: HashMap::new(),
next_account_id: 1, next_account_id: 1,
next_entry_id: 1, next_entry_id: 1,
next_asset_id: 1,
} }
} }
@@ -54,6 +59,13 @@ impl Ledger {
self.next_entry_id self.next_entry_id
} }
/// The id the next fixed asset registered will receive.
///
/// See [`Self::next_account_id`].
pub fn next_asset_id(&self) -> AssetId {
self.next_asset_id
}
/// The currency every account and line in this ledger uses. /// The currency every account and line in this ledger uses.
pub fn base_currency(&self) -> Currency { pub fn base_currency(&self) -> Currency {
self.base_currency self.base_currency
@@ -198,6 +210,116 @@ impl Ledger {
self.order.iter().filter_map(|id| self.entries.get(id)) self.order.iter().filter_map(|id| self.entries.get(id))
} }
// ------------------------------------------------------------------
// Fixed assets
// ------------------------------------------------------------------
/// Pure pre-validation for [`Self::register_asset`]: every check that
/// does not mutate, so journaling stores can validate before journaling.
pub fn validate_new_asset(&self, new: &NewAsset) -> Result<(), LedgerError> {
if new.description.trim().is_empty() {
return Err(LedgerError::EmptyAssetDescription);
}
if new.cost.amount <= Decimal::ZERO {
return Err(LedgerError::InvalidAssetCost(new.cost.amount));
}
if new.cost.currency != self.base_currency {
return Err(LedgerError::CurrencyMismatch {
expected: self.base_currency.code().to_string(),
found: new.cost.currency.code().to_string(),
});
}
if new.salvage_value.amount < Decimal::ZERO || new.salvage_value.amount >= new.cost.amount {
return Err(LedgerError::InvalidSalvageValue(new.salvage_value.amount));
}
if new.salvage_value.currency != self.base_currency {
return Err(LedgerError::CurrencyMismatch {
expected: self.base_currency.code().to_string(),
found: new.salvage_value.currency.code().to_string(),
});
}
if new.useful_life_months < 1 {
return Err(LedgerError::InvalidUsefulLife(new.useful_life_months));
}
// The schedule walks every month of the asset's life; a purchase
// date near the end of the calendar would overflow date arithmetic
// (and hang or panic the schedule). Reject it up front.
if new
.purchase_date
.checked_add_months(Months::new(new.useful_life_months))
.is_none()
{
return Err(LedgerError::AssetDateOverflow);
}
assets::validate_asset_accounts(
&self.chart,
new.cost_account,
new.accum_depr_account,
new.expense_account,
)
}
/// Register a fixed asset. Validates the economics (positive cost,
/// salvage below cost, at least one month of life, base currency) and
/// the account links (asset/asset/expense types, three distinct
/// accounts).
pub fn register_asset(&mut self, new: NewAsset) -> Result<AssetId, LedgerError> {
self.validate_new_asset(&new)?;
let id = self.next_asset_id;
self.next_asset_id += 1;
self.assets.insert(
id,
Asset {
id,
description: new.description,
purchase_date: new.purchase_date,
cost: new.cost,
salvage_value: new.salvage_value,
useful_life_months: new.useful_life_months,
method: crate::DepreciationMethod::StraightLine,
cost_account: new.cost_account,
accum_depr_account: new.accum_depr_account,
expense_account: new.expense_account,
status: AssetStatus::Active,
},
);
Ok(id)
}
/// Look up a fixed asset.
pub fn get_asset(&self, id: AssetId) -> Option<&Asset> {
self.assets.get(&id)
}
/// All registered assets, in registration order.
pub fn registered_assets(&self) -> impl Iterator<Item = &Asset> + '_ {
let mut ids: Vec<AssetId> = self.assets.keys().copied().collect();
ids.sort_unstable();
ids.into_iter().filter_map(|id| self.assets.get(&id))
}
/// Mark an asset disposed. The disposal *entry* is posted through the
/// normal machinery by the caller; this records the lifecycle change.
pub fn dispose_asset(
&mut self,
id: AssetId,
date: NaiveDate,
proceeds: Money,
) -> Result<(), LedgerError> {
if proceeds.currency != self.base_currency {
return Err(LedgerError::CurrencyMismatch {
expected: self.base_currency.code().to_string(),
found: proceeds.currency.code().to_string(),
});
}
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
// Pure math validates activeness, proceeds sign, and date order.
crate::disposal_amounts(asset, date, proceeds)?;
self.assets.get_mut(&id).expect("asset exists").status =
AssetStatus::Disposed { date, proceeds };
Ok(())
}
/// Signed net movement of an account across all posted entries: /// Signed net movement of an account across all posted entries:
/// debits minus credits. Positive means net debit movement. /// debits minus credits. Positive means net debit movement.
pub fn account_movement(&self, account: AccountId) -> Decimal { pub fn account_movement(&self, account: AccountId) -> Decimal {