//! 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(()) }