check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
536 lines
20 KiB
Rust
536 lines
20 KiB
Rust
//! 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<String>,
|
||
/// The manufacturer's serial number, if recorded.
|
||
#[serde(default)]
|
||
pub serial: Option<String>,
|
||
/// 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<crate::EntryId>,
|
||
/// 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<TaxBasis>,
|
||
}
|
||
|
||
/// 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> {
|
||
[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<Decimal>,
|
||
}
|
||
|
||
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<Decimal> {
|
||
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<Decimal> {
|
||
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<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.
|
||
/// 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<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(())
|
||
}
|