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

This commit is contained in:
swaits committed 2026-10-11 02:25:13 +00:00
1 parent eb28e87662
commit 008b5d2d52
1 file changed
+313
+313
View File
@@ -0,0 +1,313 @@
//! Fixed assets and depreciation: the asset register and the math.
//!
//! Scope is deliberate: **book depreciation only** (straight-line, GAAP
//! style). Tax depreciation — MACRS, Section 179, bonus depreciation — is a
//! separate set of books with its own rules and stays with the CPA. This
//! module never pretends to do tax.
//!
//! The register is master data owned by [`Ledger`]: assets are journaled
//! like accounts (see `ledger-store`), and ids are allocator-deterministic
//! for replay. Depreciation itself is pure math — [`depreciation_schedule`]
//! — and postings go through the normal entry machinery, so every
//! accounting invariant holds.
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money};
use chrono::{Datelike, Months, NaiveDate};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Opaque handle for a fixed asset. Never forged: ids come from the ledger's
/// allocator.
pub type AssetId = u64;
/// Depreciation method. Straight-line is the whole v1; the enum reserves
/// room for declining-balance later.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DepreciationMethod {
/// Even allocation over the useful life: (cost − salvage) / months,
/// prorated by days for partial first and last months.
StraightLine,
}
/// Lifecycle state of an asset.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AssetStatus {
/// In service.
Active,
/// Retired or sold.
Disposed {
/// Disposal date.
date: NaiveDate,
/// Cash (or receivable) received.
proceeds: Money,
},
}
/// A fixed asset on the books.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Asset {
/// Opaque handle.
pub id: AssetId,
/// What it is, e.g. "2023 Sprinter van".
pub description: String,
/// Purchase date. Depreciation starts here.
pub purchase_date: NaiveDate,
/// Capitalized cost.
pub cost: Money,
/// Expected residual value at end of life.
pub salvage_value: Money,
/// Useful life in months.
pub useful_life_months: u32,
/// Depreciation method.
pub method: DepreciationMethod,
/// Asset account carrying the cost.
pub cost_account: AccountId,
/// Contra-asset account carrying accumulated depreciation.
pub accum_depr_account: AccountId,
/// Expense account for the periodic charge.
pub expense_account: AccountId,
/// Lifecycle state.
pub status: AssetStatus,
}
/// Parameters for registering an asset. A struct (not eight arguments)
/// because the list is long and stable.
#[derive(Clone, Debug)]
pub struct NewAsset {
/// What it is.
pub description: String,
/// Purchase date.
pub purchase_date: NaiveDate,
/// Capitalized cost. Must be positive, in the ledger's base currency.
pub cost: Money,
/// Residual value. Non-negative, less than cost, base currency.
pub salvage_value: Money,
/// Useful life in months, at least 1.
pub useful_life_months: u32,
/// Asset account for the cost.
pub cost_account: AccountId,
/// Contra-asset account for accumulated depreciation.
pub accum_depr_account: AccountId,
/// Expense account for the charge.
pub expense_account: AccountId,
}
/// One month of a depreciation schedule.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScheduleLine {
/// Calendar month, "YYYY-MM".
pub period: String,
/// Depreciation for the month, exact cents.
pub amount: Decimal,
/// Accumulated depreciation through this month, exact cents.
pub accumulated: Decimal,
/// Book value after this month: cost − accumulated.
pub book_value: Decimal,
}
fn days_in_month(year: i32, month: u32) -> u32 {
let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month");
let next = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid month")
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid month")
};
(next - first).num_days() as u32
}
/// The last day depreciation accrues: purchase + life, or disposal, whichever
/// is earlier. The accrual window is [purchase_date, depreciation_end).
fn depreciation_end(asset: &Asset) -> NaiveDate {
let natural = asset
.purchase_date
.checked_add_months(Months::new(asset.useful_life_months))
.expect("useful life does not overflow dates");
match &asset.status {
AssetStatus::Active => natural,
AssetStatus::Disposed { date, .. } => (*date).min(natural),
}
}
/// Straight-line depreciation schedule from the purchase month through the
/// month containing `through` (clamped to the depreciation end).
///
/// Partial first and last months are prorated by days held. Each month is
/// rounded to cents via the running-total method, and the final month
/// trues up: partial months in different-length calendar months don't
/// complement exactly (17/31 + 14/30 ≠ 1), so the last month absorbs the
/// drift and the lifetime total is exactly (cost − salvage) — never a
/// penny off.
pub fn depreciation_schedule(asset: &Asset, through: NaiveDate) -> Vec<ScheduleLine> {
if through < asset.purchase_date {
return Vec::new();
}
let depreciable = asset.cost.amount - asset.salvage_value.amount;
if depreciable <= Decimal::ZERO {
return Vec::new();
}
let end = depreciation_end(asset);
// Accrual is inclusive of `through`: work with an exclusive end.
let accrual_end = through.succ_opt().unwrap_or(through).min(end);
if accrual_end <= asset.purchase_date {
return Vec::new();
}
let monthly_rate = depreciable / Decimal::from(asset.useful_life_months);
let mut out = Vec::new();
let mut cumulative_exact = Decimal::ZERO;
let mut accumulated_rounded = Decimal::ZERO;
// Walk calendar months from the purchase month.
let (mut year, mut month) = (asset.purchase_date.year(), asset.purchase_date.month());
loop {
let month_start = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month");
if month_start >= accrual_end {
break;
}
let dim = days_in_month(year, month);
let month_end_excl = month_start
.checked_add_months(Months::new(1))
.expect("month arithmetic");
// Days held this month: overlap of [purchase_date, accrual_end) with the month.
let held_start = asset.purchase_date.max(month_start);
let held_end = accrual_end.min(month_end_excl);
let days_held = (held_end - held_start).num_days().max(0) as u32;
if days_held > 0 {
let exact = monthly_rate * Decimal::from(days_held) / Decimal::from(dim);
cumulative_exact += exact;
let rounded_cumulative = cumulative_exact
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
let amount = rounded_cumulative - accumulated_rounded;
accumulated_rounded = rounded_cumulative;
out.push(ScheduleLine {
period: format!("{year:04}-{month:02}"),
amount,
accumulated: accumulated_rounded,
book_value: asset.cost.amount - accumulated_rounded,
});
}
if month == 12 {
year += 1;
month = 1;
} else {
month += 1;
}
}
// True-up: if the schedule reaches the final month of the asset's life,
// absorb any month-length drift so the lifetime total is exactly
// (cost − salvage). The drift is bounded by a fraction of one month's
// charge, so the final month stays positive.
if let Some(last) = out.last_mut()
&& let Some(day_before_end) = end.pred_opt()
&& last.period == day_before_end.format("%Y-%m").to_string()
{
let plug = depreciable - accumulated_rounded;
last.amount += plug;
last.accumulated = depreciable;
last.book_value = asset.cost.amount - depreciable;
}
out
}
/// Accumulated depreciation through `as_of` (inclusive), exact cents.
pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Decimal {
depreciation_schedule(asset, as_of)
.last()
.map(|l| l.accumulated)
.unwrap_or(Decimal::ZERO)
}
/// Book value (cost − accumulated depreciation) as of a date.
pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Decimal {
asset.cost.amount - accumulated_depreciation(asset, as_of)
}
/// The amounts for a disposal entry: what to debit/credit where.
/// Gain (proceeds > book value) is positive; loss is negative.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DisposalAmounts {
/// Book value at disposal.
pub book_value: Decimal,
/// Accumulated depreciation at disposal.
pub accumulated: Decimal,
/// proceeds − book_value. Positive is a gain.
pub gain_loss: Decimal,
}
/// Compute disposal amounts. Pure math; posting is the caller's job.
pub fn disposal_amounts(
asset: &Asset,
date: NaiveDate,
proceeds: Money,
) -> Result<DisposalAmounts, LedgerError> {
if asset.status != AssetStatus::Active {
let id = asset.id;
return Err(LedgerError::AssetAlreadyDisposed(id));
}
if proceeds.amount < Decimal::ZERO {
return Err(LedgerError::NegativeProceeds(proceeds.amount));
}
if date < asset.purchase_date {
return Err(LedgerError::DisposalBeforePurchase(date.to_string()));
}
let accumulated = accumulated_depreciation(asset, date);
let book = asset.cost.amount - accumulated;
Ok(DisposalAmounts {
book_value: book,
accumulated,
gain_loss: proceeds.amount - book,
})
}
/// Validate asset account links against the chart: cost and accumulated
/// depreciation must be asset accounts, the expense account an expense
/// account, and all three distinct.
pub(crate) fn validate_asset_accounts(
chart: &ChartOfAccounts,
cost_account: AccountId,
accum_depr_account: AccountId,
expense_account: AccountId,
) -> Result<(), LedgerError> {
for (id, role, expected) in [
(cost_account, "cost", AccountType::Asset),
(
accum_depr_account,
"accumulated depreciation",
AccountType::Asset,
),
(
expense_account,
"depreciation expense",
AccountType::Expense,
),
] {
let account = chart.get(id).ok_or(LedgerError::UnknownAccount(id))?;
if account.account_type != expected {
return Err(LedgerError::AssetAccountWrongType {
code: account.code.clone(),
role,
expected: match expected {
AccountType::Asset => "asset",
AccountType::Expense => "expense",
_ => unreachable!("asset links are asset or expense accounts"),
},
found: match account.account_type {
AccountType::Asset => "asset",
AccountType::Liability => "liability",
AccountType::Equity => "equity",
AccountType::Revenue => "revenue",
AccountType::Expense => "expense",
},
});
}
}
for dup in [cost_account, accum_depr_account, expense_account] {
let uses = [cost_account, accum_depr_account, expense_account]
.iter()
.filter(|&&id| id == dup)
.count();
if uses > 1 {
return Err(LedgerError::DuplicateAssetAccount(dup));
}
}
Ok(())
}