From 008b5d2d52845c5346dd7628201f45f65ec57ceb Mon Sep 17 00:00:00 2001 From: Stephen Waits <1+swaits@noreply.git.swaits.com> Date: Sun, 11 Oct 2026 02:25:13 +0000 Subject: [PATCH] asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs --- crates/ledger-core/src/assets.rs | 313 +++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 crates/ledger-core/src/assets.rs diff --git a/crates/ledger-core/src/assets.rs b/crates/ledger-core/src/assets.rs new file mode 100644 index 0000000..19f806a --- /dev/null +++ b/crates/ledger-core/src/assets.rs @@ -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 { + 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 { + 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(()) +}