Fixed assets, fully featured: tags, serials, acquisition links, label sheets, the fixed asset register, and an Assets section in the viewer; fix a flaky verify
- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost) - store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history - config: [books] asset_tag_prefix, default the company's initials without legal-form words - import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF - mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package - viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items - fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test) - tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
4e96b5f54d
commit
cd085108c1
53 files changed
+7872
-104
No files matched your search
@@ -68,6 +68,83 @@ pub struct Asset {
|
||||
pub expense_account: AccountId,
|
||||
/// Lifecycle state.
|
||||
pub status: AssetStatus,
|
||||
/// The label on the thing itself, e.g. "FL-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>,
|
||||
}
|
||||
|
||||
/// 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 (owner, 2026-10-15): generated tags ("FL-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
|
||||
/// ("fl-0001" is "FL-0001").
|
||||
pub fn tag_key(tag: &str) -> String {
|
||||
tag.to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Parameters for registering an asset. A struct (not eight arguments)
|
||||
|
||||
@@ -116,6 +116,41 @@ pub enum LedgerError {
|
||||
placeholder: bool,
|
||||
},
|
||||
|
||||
/// An asset tag of the wrong form (see `assets::check_asset_tag`).
|
||||
#[error("bad asset tag {tag:?}: {why}")]
|
||||
InvalidAssetTag {
|
||||
/// The tag given.
|
||||
tag: String,
|
||||
/// Why it was refused.
|
||||
why: String,
|
||||
},
|
||||
|
||||
/// Asset tags are never shared or reused: another asset holds, or
|
||||
/// held, this one.
|
||||
#[error("asset tag {tag:?} belongs to asset {asset}; tags are never reused, so choose another")]
|
||||
AssetTagTaken {
|
||||
/// The tag given.
|
||||
tag: String,
|
||||
/// The asset that holds or held it.
|
||||
asset: u64,
|
||||
},
|
||||
|
||||
/// An asset's acquisition entry must be a posted, unreversed entry
|
||||
/// that debits the asset's cost account.
|
||||
#[error("entry {entry} can't be asset {asset}'s acquisition: {why}")]
|
||||
AcquisitionEntryMismatch {
|
||||
/// The asset.
|
||||
asset: u64,
|
||||
/// The entry given.
|
||||
entry: u64,
|
||||
/// Why not.
|
||||
why: String,
|
||||
},
|
||||
|
||||
/// A serial number of the wrong form (see `assets::check_serial`).
|
||||
#[error("bad serial number: {0}")]
|
||||
InvalidSerial(String),
|
||||
|
||||
/// No control role has that name (see `ledger_core::control_accounts`).
|
||||
#[error(
|
||||
"no role {0:?}; the roles are receivables, revenue, payables, general_expense, \
|
||||
|
||||
@@ -258,6 +258,9 @@ pub struct Ledger {
|
||||
/// Control roles assigned an account ([`Ledger::assign_role`]), by
|
||||
/// [`ControlAccount::key`]. A role not here uses its code.
|
||||
roles: std::collections::BTreeMap<&'static str, AccountId>,
|
||||
/// Every asset tag ever held ([`assets::tag_key`] → the asset that
|
||||
/// held it): a tag never moves to another asset.
|
||||
asset_tags: std::collections::BTreeMap<String, AssetId>,
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
@@ -296,6 +299,7 @@ impl Ledger {
|
||||
invoice_payments: HashMap::new(),
|
||||
bill_payments: HashMap::new(),
|
||||
roles: std::collections::BTreeMap::new(),
|
||||
asset_tags: std::collections::BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +592,8 @@ impl Ledger {
|
||||
self.invoice_payments == other.invoice_payments,
|
||||
),
|
||||
("bill_payments", self.bill_payments == other.bill_payments),
|
||||
("roles", self.roles == other.roles),
|
||||
("asset_tags", self.asset_tags == other.asset_tags),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, same)| !same)
|
||||
@@ -1450,6 +1456,9 @@ impl Ledger {
|
||||
accum_depr_account: new.accum_depr_account,
|
||||
expense_account: new.expense_account,
|
||||
status: AssetStatus::Active,
|
||||
tag: None,
|
||||
serial: None,
|
||||
acquired_by: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
@@ -1467,6 +1476,168 @@ impl Ledger {
|
||||
ids.into_iter().filter_map(|id| self.assets.get(&id))
|
||||
}
|
||||
|
||||
/// Give an asset a tag (or a new one): the label on the thing itself.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: find an asset in the books from the label on it, and the
|
||||
/// label from the books.
|
||||
/// SOURCE: DECISION (owner, 2026-10-15).
|
||||
/// INVARIANTS:
|
||||
/// 1. Tags are unique among all assets, ignoring case.
|
||||
/// 2. A tag is never reused by another asset, even after the asset
|
||||
/// that held it was retagged or disposed: an old label never
|
||||
/// points at the wrong thing. The same asset may take back a tag
|
||||
/// it held.
|
||||
/// 3. A refusal MUST leave the books unchanged.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the asset is unknown: SHOULD refuse.
|
||||
/// 2. IF the tag's form is wrong (assets::check_asset_tag): SHOULD
|
||||
/// refuse with InvalidAssetTag.
|
||||
/// 3. IF another asset holds or held the tag: SHOULD refuse with
|
||||
/// AssetTagTaken.
|
||||
/// 4. IF the asset already has that tag: SHOULD accept (no change).
|
||||
/// ```
|
||||
pub fn set_asset_tag(&mut self, id: AssetId, tag: &str) -> Result<(), LedgerError> {
|
||||
self.check_asset_tag_for(Some(id), tag)?;
|
||||
self.asset_tags.insert(assets::tag_key(tag), id);
|
||||
self.assets.get_mut(&id).expect("checked").tag = Some(tag.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `tag` can go on asset `id` (`None`: on an asset not yet
|
||||
/// registered), for stores that journal before mutating.
|
||||
pub fn check_asset_tag_for(&self, id: Option<AssetId>, tag: &str) -> Result<(), LedgerError> {
|
||||
if let Some(id) = id
|
||||
&& !self.assets.contains_key(&id)
|
||||
{
|
||||
return Err(LedgerError::UnknownAsset(id));
|
||||
}
|
||||
assets::check_asset_tag(tag).map_err(|why| LedgerError::InvalidAssetTag {
|
||||
tag: tag.to_string(),
|
||||
why,
|
||||
})?;
|
||||
match self.asset_tags.get(&assets::tag_key(tag)) {
|
||||
Some(&holder) if Some(holder) != id => Err(LedgerError::AssetTagTaken {
|
||||
tag: tag.to_string(),
|
||||
asset: holder,
|
||||
}),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record (or, with `None`, clear) an asset's serial number.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: keep the manufacturer's serial with the asset, for warranty,
|
||||
/// insurance, and proving which thing the books mean.
|
||||
/// SOURCE: DECISION (owner, 2026-10-15).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the asset is unknown, or the serial's form is wrong
|
||||
/// (assets::check_serial): SHOULD refuse, changing nothing.
|
||||
/// ```
|
||||
pub fn set_asset_serial(
|
||||
&mut self,
|
||||
id: AssetId,
|
||||
serial: Option<&str>,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.check_asset_serial(id, serial)?;
|
||||
self.assets.get_mut(&id).expect("checked").serial = serial.map(str::to_string);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::set_asset_serial`].
|
||||
pub fn check_asset_serial(&self, id: AssetId, serial: Option<&str>) -> Result<(), LedgerError> {
|
||||
if !self.assets.contains_key(&id) {
|
||||
return Err(LedgerError::UnknownAsset(id));
|
||||
}
|
||||
if let Some(s) = serial {
|
||||
assets::check_serial(s).map_err(LedgerError::InvalidSerial)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Link an asset to the posted entry that put it on the books: its
|
||||
/// purchase, or a member's contribution of it.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: from an asset, its source document and capital entry; from
|
||||
/// the entry, the asset.
|
||||
/// SOURCE: DECISION (owner, 2026-10-15).
|
||||
/// INVARIANTS:
|
||||
/// 1. The linked entry is posted, is not a reversal, has not been
|
||||
/// reversed, and debits the asset's cost account.
|
||||
/// 2. A refusal MUST leave the books unchanged.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the asset or entry is unknown, or the entry fails 1: SHOULD
|
||||
/// refuse with AcquisitionEntryMismatch (or UnknownAsset /
|
||||
/// UnknownEntry).
|
||||
/// 2. IF a later correction reverses the linked entry: link the
|
||||
/// replacement (a reversed link stays as history in the journal).
|
||||
/// ```
|
||||
pub fn set_asset_acquisition(
|
||||
&mut self,
|
||||
id: AssetId,
|
||||
entry: EntryId,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.check_asset_acquisition(id, entry)?;
|
||||
self.assets.get_mut(&id).expect("checked").acquired_by = Some(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The validation behind [`Self::set_asset_acquisition`].
|
||||
pub fn check_asset_acquisition(&self, id: AssetId, entry: EntryId) -> Result<(), LedgerError> {
|
||||
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
|
||||
let e = self
|
||||
.entries
|
||||
.get(&entry)
|
||||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||||
let why = if e.status() != crate::EntryStatus::Posted {
|
||||
Some("it isn't posted")
|
||||
} else if e.reversal_of().is_some() {
|
||||
Some("it is a reversal")
|
||||
} else if self
|
||||
.entries
|
||||
.values()
|
||||
.any(|x| x.reversal_of() == Some(entry))
|
||||
{
|
||||
Some("it has been reversed")
|
||||
} else if !e
|
||||
.lines()
|
||||
.iter()
|
||||
.any(|l| l.account == asset.cost_account && l.side == Side::Debit)
|
||||
{
|
||||
Some("it doesn't debit the asset's cost account")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match why {
|
||||
Some(why) => Err(LedgerError::AcquisitionEntryMismatch {
|
||||
asset: id,
|
||||
entry,
|
||||
why: why.to_string(),
|
||||
}),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The asset whose current tag is `tag`, ignoring case.
|
||||
pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> {
|
||||
let id = self.asset_tags.get(&assets::tag_key(tag))?;
|
||||
self.assets
|
||||
.get(id)
|
||||
.filter(|a| a.tag.as_deref().map(assets::tag_key) == Some(assets::tag_key(tag)))
|
||||
}
|
||||
|
||||
/// The first free generated tag, `PREFIX-0001`, `PREFIX-0002`, ...:
|
||||
/// the lowest number no asset holds or held under that prefix.
|
||||
/// Numbers past 9999 widen.
|
||||
pub fn next_asset_tag(&self, prefix: &str) -> String {
|
||||
(1u64..)
|
||||
.map(|n| format!("{prefix}-{n:04}"))
|
||||
.find(|t| !self.asset_tags.contains_key(&assets::tag_key(t)))
|
||||
.expect("some number is free")
|
||||
}
|
||||
|
||||
/// Mark an asset disposed. The disposal *entry* is posted through the
|
||||
/// normal machinery by the caller; this records the lifecycle change.
|
||||
///
|
||||
|
||||
@@ -61,11 +61,12 @@ pub use periods::{
|
||||
CloseTo, MemberClose, MemberCloseRecord, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
|
||||
};
|
||||
pub use reports::{
|
||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
||||
MemberEquityColumn, MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement,
|
||||
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger,
|
||||
members_equity, profit_and_loss_for_period, trial_balance_as_of,
|
||||
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
|
||||
CashFlowParams, CashFlowStatement, ClassProfitAndLoss, GeneralLedger, LedgerAccount,
|
||||
LedgerPosting, MemberCapital, MemberEquityColumn, MembersEquityStatement, ProfitAndLoss,
|
||||
ProfitAndLossStatement, StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of,
|
||||
cash_flow, fixed_asset_register, general_ledger, members_equity, profit_and_loss_for_period,
|
||||
trial_balance_as_of,
|
||||
};
|
||||
pub use schedules::{
|
||||
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
|
||||
|
||||
@@ -1038,27 +1038,32 @@ pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
|
||||
let tb = trial_balance(ledger);
|
||||
let pl = unclosed_earnings(ledger)?;
|
||||
let bs = balance_sheet(ledger)?;
|
||||
// By value, not by written scale: a sum taken in hash-map order can
|
||||
// write the same zero as "0" or "0.00", and verify compared the
|
||||
// strings, so two equal ledgers could differ at random (found
|
||||
// 2026-10-15 by a flaky store test).
|
||||
let v = |d: Decimal| d.normalize().to_string();
|
||||
Ok(serde_json::json!({
|
||||
"trial_balance": {
|
||||
"lines": tb.lines.iter().map(|l| serde_json::json!({
|
||||
"code": l.code,
|
||||
"name": l.name,
|
||||
"debit": l.debit.to_string(),
|
||||
"credit": l.credit.to_string(),
|
||||
"debit": v(l.debit),
|
||||
"credit": v(l.credit),
|
||||
})).collect::<Vec<_>>(),
|
||||
"total_debits": tb.total_debits.to_string(),
|
||||
"total_credits": tb.total_credits.to_string(),
|
||||
"total_debits": v(tb.total_debits),
|
||||
"total_credits": v(tb.total_credits),
|
||||
},
|
||||
"profit_and_loss": {
|
||||
"revenue": pl.revenue.to_string(),
|
||||
"expenses": pl.expenses.to_string(),
|
||||
"net_income": pl.net_income().to_string(),
|
||||
"revenue": v(pl.revenue),
|
||||
"expenses": v(pl.expenses),
|
||||
"net_income": v(pl.net_income()),
|
||||
},
|
||||
"balance_sheet": {
|
||||
"assets": bs.assets.to_string(),
|
||||
"liabilities": bs.liabilities.to_string(),
|
||||
"equity": bs.equity.to_string(),
|
||||
"current_earnings": bs.current_earnings.to_string(),
|
||||
"assets": v(bs.assets),
|
||||
"liabilities": v(bs.liabilities),
|
||||
"equity": v(bs.equity),
|
||||
"current_earnings": v(bs.current_earnings),
|
||||
"balanced": bs.balanced(),
|
||||
},
|
||||
}))
|
||||
@@ -1524,3 +1529,159 @@ pub fn cash_flow(
|
||||
cash_ending,
|
||||
})
|
||||
}
|
||||
|
||||
/// One asset on the fixed asset register.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetRegisterRow {
|
||||
/// Asset id.
|
||||
pub asset_id: crate::AssetId,
|
||||
/// Its tag, if it has one.
|
||||
pub tag: Option<String>,
|
||||
/// What it is.
|
||||
pub description: String,
|
||||
/// The manufacturer's serial number, if recorded.
|
||||
pub serial: Option<String>,
|
||||
/// Placed in service (depreciation starts).
|
||||
pub in_service: NaiveDate,
|
||||
/// Capitalized cost.
|
||||
pub cost: Decimal,
|
||||
/// Salvage value.
|
||||
pub salvage: Decimal,
|
||||
/// Useful life in months.
|
||||
pub useful_life_months: u32,
|
||||
/// Accumulated depreciation by its schedule through the date (zero once
|
||||
/// disposed: it left the books with the asset).
|
||||
pub accumulated: Decimal,
|
||||
/// Cost less accumulated (zero once disposed).
|
||||
pub book_value: Decimal,
|
||||
/// Disposal date and proceeds, if disposed by the date.
|
||||
pub disposed: Option<(NaiveDate, Decimal)>,
|
||||
}
|
||||
|
||||
/// The fixed asset register as of a date, reconciled to the general ledger.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetRegister {
|
||||
/// The date.
|
||||
pub as_of: NaiveDate,
|
||||
/// Every asset registered by the date, in registration order; disposed
|
||||
/// ones carry their disposal and no cost on the books.
|
||||
pub rows: Vec<AssetRegisterRow>,
|
||||
/// Total cost of the assets still held.
|
||||
pub total_cost: Decimal,
|
||||
/// Their accumulated depreciation by schedule.
|
||||
pub total_accumulated: Decimal,
|
||||
/// Their book value.
|
||||
pub total_book_value: Decimal,
|
||||
/// The general ledger's balance (debit positive) of the assets' cost
|
||||
/// accounts on the date.
|
||||
pub ledger_cost: Decimal,
|
||||
/// The general ledger's accumulated depreciation (credit positive) in
|
||||
/// the assets' accumulated depreciation accounts on the date.
|
||||
pub ledger_accumulated: Decimal,
|
||||
}
|
||||
|
||||
impl AssetRegister {
|
||||
/// Depreciation the schedules say is due by the date but the books
|
||||
/// don't carry yet (run `run_depreciation`); negative if the books
|
||||
/// carry more.
|
||||
pub fn unposted_depreciation(&self) -> Decimal {
|
||||
self.total_accumulated - self.ledger_accumulated
|
||||
}
|
||||
|
||||
/// Cost in the ledger's cost accounts that no held asset accounts for
|
||||
/// (equipment bought but not registered), or negative if registered
|
||||
/// cost isn't in the books.
|
||||
pub fn unregistered_cost(&self) -> Decimal {
|
||||
self.ledger_cost - self.total_cost
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed asset register as of a date.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one list of everything the company owns as a fixed asset: what,
|
||||
/// which label, cost, depreciation, book value, and whether the
|
||||
/// books agree.
|
||||
/// SOURCE: docs/spec/rules.md B-15 (depreciation); DECISION (owner,
|
||||
/// 2026-10-15): a fully featured asset register.
|
||||
/// INVARIANTS:
|
||||
/// 1. Rows: the assets registered on or before as_of, in registration
|
||||
/// order; an asset disposed on or before as_of shows its disposal,
|
||||
/// zero accumulated and book value, and counts in no total.
|
||||
/// 2. accumulated is the asset's schedule through as_of
|
||||
/// (assets::accumulated_depreciation); book_value = cost −
|
||||
/// accumulated.
|
||||
/// 3. Totals are the sums over the held assets, exactly.
|
||||
/// 4. ledger_cost and ledger_accumulated are the general ledger's
|
||||
/// balances on as_of of the distinct cost and accumulated
|
||||
/// depreciation accounts of the registered assets, so
|
||||
/// unposted_depreciation and unregistered_cost are zero exactly when
|
||||
/// register and books agree.
|
||||
/// ```
|
||||
pub fn fixed_asset_register(ledger: &Ledger, as_of: NaiveDate) -> AssetRegister {
|
||||
use crate::AssetStatus;
|
||||
let mut rows = Vec::new();
|
||||
let (mut cost, mut accumulated) = (Decimal::ZERO, Decimal::ZERO);
|
||||
let mut cost_accounts = std::collections::BTreeSet::new();
|
||||
let mut accum_accounts = std::collections::BTreeSet::new();
|
||||
for a in ledger
|
||||
.registered_assets()
|
||||
.filter(|a| a.purchase_date <= as_of)
|
||||
{
|
||||
cost_accounts.insert(a.cost_account);
|
||||
accum_accounts.insert(a.accum_depr_account);
|
||||
let disposed = match &a.status {
|
||||
AssetStatus::Disposed { date, proceeds } if *date <= as_of => {
|
||||
Some((*date, proceeds.amount))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let acc = if disposed.is_some() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
crate::assets::accumulated_depreciation(a, as_of)
|
||||
};
|
||||
if disposed.is_none() {
|
||||
cost += a.cost.amount;
|
||||
accumulated += acc;
|
||||
}
|
||||
rows.push(AssetRegisterRow {
|
||||
asset_id: a.id,
|
||||
tag: a.tag.clone(),
|
||||
description: a.description.clone(),
|
||||
serial: a.serial.clone(),
|
||||
in_service: a.purchase_date,
|
||||
cost: a.cost.amount,
|
||||
salvage: a.salvage_value.amount,
|
||||
useful_life_months: a.useful_life_months,
|
||||
accumulated: acc,
|
||||
book_value: if disposed.is_some() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
a.cost.amount - acc
|
||||
},
|
||||
disposed,
|
||||
});
|
||||
}
|
||||
let tb = trial_balance_as_of(ledger, as_of);
|
||||
let net = |id: crate::AccountId| -> Decimal {
|
||||
let code = match ledger.chart().get(id) {
|
||||
Some(a) => &a.code,
|
||||
None => return Decimal::ZERO,
|
||||
};
|
||||
tb.lines
|
||||
.iter()
|
||||
.find(|l| &l.code == code)
|
||||
.map(|l| l.debit - l.credit)
|
||||
.unwrap_or(Decimal::ZERO)
|
||||
};
|
||||
AssetRegister {
|
||||
as_of,
|
||||
rows,
|
||||
total_cost: cost,
|
||||
total_accumulated: accumulated,
|
||||
total_book_value: cost - accumulated,
|
||||
ledger_cost: cost_accounts.iter().map(|&id| net(id)).sum(),
|
||||
ledger_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::<Decimal>(),
|
||||
}
|
||||
}
|
||||
@@ -417,3 +417,67 @@ fn disposal_on_the_purchase_date_is_allowed() {
|
||||
Err(ledger_core::LedgerError::DisposalBeforePurchase(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// An asset links to the posted entry that acquired it, if that entry
|
||||
/// debits its cost account and isn't a reversal or reversed; a correction
|
||||
/// (reverse, repost) links the replacement. Refusals change nothing.
|
||||
#[test]
|
||||
fn acquisition_links_only_to_a_live_entry_debiting_the_cost_account() {
|
||||
use ledger_core::{LedgerError, Side};
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let capital = ledger
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let id = van_asset(&mut ledger, 1650, cost, accum, expense);
|
||||
let post = |l: &mut Ledger, dr: u64, cr: u64| {
|
||||
let e = l.create_entry(date(2026, 1, 15), "contribution");
|
||||
l.add_line(e, dr, Side::Debit, usd("1650")).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, usd("1650")).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
let original = post(&mut ledger, cost, capital);
|
||||
let wrong = post(&mut ledger, expense, capital);
|
||||
let draft = ledger.create_entry(date(2026, 1, 15), "draft");
|
||||
let refused = |l: &mut Ledger, entry: u64| {
|
||||
let before = l.clone();
|
||||
let err = l.set_asset_acquisition(id, entry).unwrap_err();
|
||||
assert_eq!(*l, before);
|
||||
err
|
||||
};
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, wrong),
|
||||
LedgerError::AcquisitionEntryMismatch { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, draft),
|
||||
LedgerError::AcquisitionEntryMismatch { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, 999),
|
||||
LedgerError::UnknownEntry(999)
|
||||
));
|
||||
assert!(matches!(
|
||||
ledger.set_asset_acquisition(99, original),
|
||||
Err(LedgerError::UnknownAsset(99))
|
||||
));
|
||||
ledger.set_asset_acquisition(id, original).unwrap();
|
||||
assert_eq!(ledger.get_asset(id).unwrap().acquired_by, Some(original));
|
||||
|
||||
// Corrected: reversed, reposted. Neither the original nor its
|
||||
// reversal can be the link; the replacement can.
|
||||
let reversal = ledger
|
||||
.reverse_entry(original, date(2026, 1, 16), "joint, not one member's")
|
||||
.unwrap();
|
||||
let replacement = post(&mut ledger, cost, capital);
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, original),
|
||||
LedgerError::AcquisitionEntryMismatch { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, reversal),
|
||||
LedgerError::AcquisitionEntryMismatch { .. }
|
||||
));
|
||||
ledger.set_asset_acquisition(id, replacement).unwrap();
|
||||
assert_eq!(ledger.get_asset(id).unwrap().acquired_by, Some(replacement));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -58,6 +58,9 @@ fn asset(cost: &str, salvage: &str, life: u32, purchase: NaiveDate) -> Asset {
|
||||
accum_depr_account: 11,
|
||||
expense_account: 12,
|
||||
status: AssetStatus::Active,
|
||||
tag: None,
|
||||
serial: None,
|
||||
acquired_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user