//! 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`](crate::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, /// The label on the thing itself, e.g. "WL-0001" (see /// [`check_asset_tag`]); `None` until one is set. #[serde(default)] pub tag: Option, /// The manufacturer's serial number, if recorded. #[serde(default)] pub serial: Option, /// The posted entry that put it on the books (its purchase, or a /// member's contribution of it), if linked /// ([`crate::Ledger::set_asset_acquisition`]). #[serde(default)] pub acquired_by: Option, /// Its federal tax basis, if recorded ([`crate::Ledger::set_asset_tax_basis`]). /// Book cost is [`Asset::cost`]; tax basis can differ (a member's /// contribution carries the member's basis, not its market value). #[serde(default)] pub tax: Option, } /// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AssetCorrection { /// Capitalized cost. pub cost: Money, /// Residual value. pub salvage_value: Money, /// Useful life in months. pub useful_life_months: u32, /// In-service date (depreciation starts here). pub purchase_date: NaiveDate, } /// Which rule sets an asset's federal tax basis. /// /// ```text /// SOURCE: docs/spec/sources/irs-partnership-basis.md: IRC §1012(a) (cost), /// IRC §723 (property a partner contributes keeps the partner's /// adjusted basis), Treas. Reg. §1.168(i)-4(b) (property converted /// from personal use depreciates from the lesser of its fair market /// value and its adjusted basis), IRC §704(c)(1)(C) (built-in loss). /// ``` #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TaxBasisRule { /// Bought by the company: its cost (IRC §1012(a)), the book cost. Cost, /// Contributed by a member from business use: the member's adjusted /// basis carries over (IRC §723). Carryover, /// Contributed by a member from personal use: depreciable basis is the /// lesser of fair market value (the book cost) and the member's /// adjusted basis (Treas. Reg. §1.168(i)-4(b), with IRC §723). PersonalUseConversion, } impl TaxBasisRule { /// Its wire name: `cost`, `carryover`, `personal_use_conversion`. pub fn key(self) -> &'static str { match self { TaxBasisRule::Cost => "cost", TaxBasisRule::Carryover => "carryover", TaxBasisRule::PersonalUseConversion => "personal_use_conversion", } } /// The rule named `key`. pub fn parse(key: &str) -> Option { [Self::Cost, Self::Carryover, Self::PersonalUseConversion] .into_iter() .find(|r| r.key() == key) } } /// An asset's federal tax basis: the rule and, for a member's /// contribution, the member's adjusted basis at the time. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TaxBasis { /// The rule. pub rule: TaxBasisRule, /// The contributing member's adjusted basis at contribution (cost less /// any depreciation they took). `None` for [`TaxBasisRule::Cost`]. pub contributor_basis: Option, } impl TaxBasis { /// Whether the record fits its rule: Cost takes no contributor basis; /// Carryover and PersonalUseConversion require a non-negative one in /// cents at most. Returns why not. Needs no asset, so a caller can check /// before registering one. pub fn check(&self) -> Result<(), String> { use TaxBasisRule::*; match (self.rule, self.contributor_basis) { (Cost, Some(_)) => { Err("a purchase's tax basis is its cost; give no contributor basis".into()) } (Carryover | PersonalUseConversion, None) => { Err("a contribution needs the contributing member's adjusted basis".into()) } (_, Some(b)) if b.is_sign_negative() && !b.is_zero() => { Err("a basis can't be negative".into()) } (_, Some(b)) if b.normalize().scale() > 2 => Err("a basis is in cents at most".into()), _ => Ok(()), } } } impl Asset { /// Its depreciable federal tax basis, by its rule, or `None` if not /// recorded. /// /// ```text /// INVARIANTS: /// 1. Cost: the book cost. /// 2. Carryover: the contributor's adjusted basis. /// 3. PersonalUseConversion: min(book cost, contributor's basis); the /// book cost of a contribution is its fair market value. /// ``` pub fn tax_basis(&self) -> Option { let t = self.tax?; Some(match (t.rule, t.contributor_basis) { (TaxBasisRule::Cost, _) => self.cost.amount, (TaxBasisRule::Carryover, Some(b)) => b, (TaxBasisRule::PersonalUseConversion, Some(b)) => b.min(self.cost.amount), (_, None) => return None, }) } /// Built-in gain (positive) or loss (negative) at contribution, for /// IRC §704(c): fair market value (the book cost) less the contributor's /// adjusted basis. `None` for a purchase or when not recorded. A /// built-in loss counts only for the contributing member /// (§704(c)(1)(C)). pub fn built_in_gain(&self) -> Option { let b = self.tax?.contributor_basis?; Some(self.cost.amount - b) } } /// The longest asset tag. pub const MAX_ASSET_TAG_LEN: usize = 32; /// The longest serial number. pub const MAX_SERIAL_LEN: usize = 64; /// Check an asset tag's form: 1 to [`MAX_ASSET_TAG_LEN`] characters, each /// an ASCII letter or digit or one of `-` `_` `.` `/`, at least one of /// them a letter or digit. Returns why not. /// /// ```text /// GOAL: a tag prints on a small label, reads aloud, scans from a QR code, /// and types back in exactly. /// SOURCE: DECISION (Stephen, 2026-10-15): generated tags ("WL-0001") or /// the human's own. /// INVARIANTS: /// 1. Accepted tags contain no spaces, no control or non-ASCII /// characters, and at least one letter or digit. /// ``` pub fn check_asset_tag(tag: &str) -> Result<(), String> { if tag.is_empty() { return Err("a tag can't be empty".into()); } if tag.len() > MAX_ASSET_TAG_LEN { return Err(format!("a tag is at most {MAX_ASSET_TAG_LEN} characters")); } if let Some(c) = tag .chars() .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))) { return Err(format!( "{c:?} can't be in a tag (letters, digits, - _ . / only)" )); } if !tag.chars().any(|c| c.is_ascii_alphanumeric()) { return Err("a tag needs a letter or digit".into()); } Ok(()) } /// Check a serial number's form: 1 to [`MAX_SERIAL_LEN`] characters, no /// control characters, no leading or trailing space. Returns why not. pub fn check_serial(serial: &str) -> Result<(), String> { if serial.trim().is_empty() { return Err("a serial number can't be empty".into()); } if serial.chars().count() > MAX_SERIAL_LEN { return Err(format!( "a serial number is at most {MAX_SERIAL_LEN} characters" )); } if serial.trim() != serial { return Err("a serial number can't start or end with a space".into()); } if serial.chars().any(char::is_control) { return Err("a serial number can't contain control characters".into()); } Ok(()) } /// The key tags are compared by: tags are unique ignoring case /// ("wl-0001" is "WL-0001"). pub fn tag_key(tag: &str) -> String { tag.to_ascii_uppercase() } /// 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 /// `through` (clamped to the depreciation end): one line per month, the /// last prorated by days when `through` falls mid-month. /// /// ```text /// GOAL: how much of an asset's cost has been used up, month by month. /// SOURCE: docs/spec/rules.md B-15 (straight line; salvage never /// depreciated; partial months by days); DECISION (Stephen, /// 2026-10-13): a schedule through a mid-month date prorates that /// month by days, as partial first and last months do. /// INVARIANTS: /// 1. A full schedule MUST sum to exactly cost - salvage, to the cent. /// 2. Accumulated depreciation NEVER falls; book value NEVER rises and /// NEVER drops below salvage. /// SCENARIOS: /// 1. IF $12,000 over 12 months from Jan 1, through Jun 15: /// - SHOULD end with June at 500.00 (15 of 30 days), accumulated /// 5,500.00 /// BECAUSE depreciation accrues by the day within a month. /// ``` /// /// 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. /// A disposal may fall on the purchase date (one day held, accrual being /// inclusive, B-15 in docs/spec/rules.md); one before it is an error. 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(()) }