From ee7e205f5c7939076da39fb450dbb15800ade21c Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 15 Oct 2026 12:18:00 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- Cargo.lock | 55 + crates/ledger-cli/src/main.rs | 5 + crates/ledger-config/src/lib.rs | 68 ++ crates/ledger-config/tests/spec_config.rs | 101 ++ crates/ledger-core/src/assets.rs | 77 ++ crates/ledger-core/src/error.rs | 35 + crates/ledger-core/src/ledger.rs | 171 +++ crates/ledger-core/src/lib.rs | 11 +- crates/ledger-core/src/reports.rs | 183 ++- crates/ledger-core/tests/assets.rs | 64 + crates/ledger-core/tests/spec_asset_tags.rs | 1031 ++++++++++++++++ .../ledger-core/tests/spec_assets_payroll.rs | 3 + crates/ledger-gui/src/app.rs | 9 + crates/ledger-gui/src/attention.rs | 82 +- crates/ledger-gui/src/books.rs | 218 +++- crates/ledger-gui/src/demo.rs | 3 + crates/ledger-gui/src/nav.rs | 19 +- crates/ledger-gui/src/pages/assets.rs | 1059 +++++++++++++++++ crates/ledger-gui/src/pages/company.rs | 21 +- crates/ledger-gui/src/pages/entry.rs | 13 + crates/ledger-gui/src/pages/journal.rs | 77 +- crates/ledger-gui/src/pages/mod.rs | 3 + crates/ledger-gui/src/pages/reports.rs | 126 +- crates/ledger-gui/src/palette.rs | 67 +- crates/ledger-gui/tests/assets.rs | 614 ++++++++++ crates/ledger-gui/tests/read_only.rs | 11 + crates/ledger-gui/tests/screens.rs | 12 + crates/ledger-gui/tests/show.rs | 39 + crates/ledger-import/Cargo.toml | 2 + crates/ledger-import/src/asset_register.rs | 370 ++++++ crates/ledger-import/src/labels.rs | 247 ++++ crates/ledger-import/src/lib.rs | 6 + crates/ledger-import/src/pdf.rs | 16 +- crates/ledger-import/src/xlsx.rs | 36 +- .../templates/asset_register.typ | 149 +++ crates/ledger-import/templates/labels.typ | 91 ++ crates/ledger-import/tests/asset_register.rs | 221 ++++ crates/ledger-import/tests/labels.rs | 393 ++++++ crates/ledger-import/tests/spec_import.rs | 162 +++ crates/ledger-mcp/src/server.rs | 353 +++++- crates/ledger-mcp/src/settings.rs | 15 + crates/ledger-mcp/src/types.rs | 88 +- crates/ledger-mcp/tests/mcp_stdio.rs | 2 +- crates/ledger-mcp/tests/spec_assets.rs | 891 ++++++++++++++ crates/ledger-mcp/tests/spec_mcp.rs | 22 +- crates/ledger-store/src/schema.rs | 15 +- crates/ledger-store/src/store.rs | 113 ++ crates/ledger-store/src/viewer.rs | 9 +- crates/ledger-store/tests/asset_tags.rs | 501 ++++++++ docs/mcp-interface.md | 28 +- docs/spec/README.md | 2 +- docs/spec/rules.md | 18 + docs/spec/sources/avery-5160.md | 49 + 53 files changed, 7872 insertions(+), 104 deletions(-) create mode 100644 crates/ledger-core/tests/spec_asset_tags.rs create mode 100644 crates/ledger-gui/src/pages/assets.rs create mode 100644 crates/ledger-gui/tests/assets.rs create mode 100644 crates/ledger-import/src/asset_register.rs create mode 100644 crates/ledger-import/src/labels.rs create mode 100644 crates/ledger-import/templates/asset_register.typ create mode 100644 crates/ledger-import/templates/labels.typ create mode 100644 crates/ledger-import/tests/asset_register.rs create mode 100644 crates/ledger-import/tests/labels.rs create mode 100644 crates/ledger-mcp/tests/spec_assets.rs create mode 100644 crates/ledger-store/tests/asset_tags.rs create mode 100644 docs/spec/sources/avery-5160.md diff --git a/Cargo.lock b/Cargo.lock index d9fad58..e810414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2261,6 +2261,34 @@ dependencies = [ "slab", ] +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + [[package]] name = "generic-array" version = "0.14.7" @@ -3663,6 +3691,8 @@ dependencies = [ "csv", "ledger-core", "proptest", + "qrcode", + "rqrr", "rust_decimal", "rust_xlsxwriter", "serde", @@ -3833,6 +3863,15 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lzma-rust2" version = "0.21.0" @@ -5045,6 +5084,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "1.2.3" @@ -5517,6 +5562,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "rqrr" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d084c50c5cfa397a081850da329566c8667b0b6c4e81da16abff1fef27de93b" +dependencies = [ + "g2p", + "lru", +] + [[package]] name = "rusqlite" version = "0.32.1" diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 2f9a788..6c223e6 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -311,6 +311,11 @@ receipt_threshold = "25.00" # draws, distributions, dividends. # close_sweep = ["3100"] +# The prefix of generated asset tags (the labels on equipment: WL-0001, +# WL-0002, ...). Unset: the company name's initials, without LLC/Inc +# ("Wanderland Labs LLC" -> WL); 1 to 8 letters or digits. +# asset_tag_prefix = "WL" + # Which accounts invoices, bills, payroll, and closes post to (receivables, # payables, revenue, payroll, retained earnings) is not set here: those # roles are part of the books, journaled with books.assign_role and listed diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index 6aac8db..eaac3bb 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -108,6 +108,58 @@ pub struct BooksSettings { /// statement treats as investing (loans the company makes, investments /// it buys: ASC 230-10-20). Registered fixed assets need not be listed. pub investing_accounts: Vec, + /// `[books] asset_tag_prefix`: the prefix of generated asset tags + /// ("WL" for "WL-0001"). Unset: [`BooksSettings::asset_tag_prefix`] + /// derives it from the company name. + pub asset_tag_prefix: Option, +} + +impl BooksSettings { + /// The prefix generated asset tags use: `[books] asset_tag_prefix`, or + /// else the initials of the company name's words, legal-form words + /// (LLC, Inc, Corp, ...) left out, at most 8 ("Wanderland Labs LLC" → + /// "WL"); "FA" (fixed asset) when the name gives none. + pub fn asset_tag_prefix(&self) -> String { + if let Some(p) = &self.asset_tag_prefix { + return p.clone(); + } + const FORMS: &[&str] = &[ + "llc", + "l", + "c", + "inc", + "incorporated", + "corp", + "corporation", + "co", + "company", + "ltd", + "limited", + "llp", + "lp", + "pllc", + "pc", + "pa", + "the", + "and", + "of", + ]; + let initials: String = self + .company + .name + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|w| !w.is_empty() && !FORMS.contains(&w.to_ascii_lowercase().as_str())) + .filter_map(|w| w.chars().next()) + .filter(char::is_ascii_alphabetic) + .map(|c| c.to_ascii_uppercase()) + .take(8) + .collect(); + if initials.is_empty() || self.company.name == Company::default().name { + "FA".to_string() + } else { + initials + } + } } #[derive(Deserialize)] @@ -149,6 +201,7 @@ struct BooksSection { financing_accounts: Vec, #[serde(default)] investing_accounts: Vec, + asset_tag_prefix: Option, } /// The return a federal tax classification files (docs/spec/sources/ @@ -444,6 +497,21 @@ pub fn parse(text: &str) -> Result { cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?, financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?, investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?, + asset_tag_prefix: match raw.books.asset_tag_prefix { + Some(p) + if !p.is_empty() + && p.len() <= 8 + && p.chars().all(|c| c.is_ascii_alphanumeric()) => + { + Some(p) + } + Some(p) => { + return Err(format!( + "[books] asset_tag_prefix {p:?}: 1 to 8 letters or digits" + )); + } + None => None, + }, }) } diff --git a/crates/ledger-config/tests/spec_config.rs b/crates/ledger-config/tests/spec_config.rs index f43d31b..b8a6aa1 100644 --- a/crates/ledger-config/tests/spec_config.rs +++ b/crates/ledger-config/tests/spec_config.rs @@ -1125,3 +1125,104 @@ fn inv7_profile_keeps_simple_values_of_every_type() { ] ); } + +// ------------------------------------------------- [books] asset_tag_prefix +// +// Spec: `BooksSettings::asset_tag_prefix` doc ("`[books] asset_tag_prefix`, +// or else the initials of the company name's words, legal-form words (LLC, +// Inc, Corp, ...) left out, at most 8 ("Wanderland Labs LLC" → "WL"); "FA" +// (fixed asset) when the name gives none"), docs/mcp-interface.md +// (books.register_asset), and the parse refusal "1 to 8 letters or digits". + +fn prefix_for_name(name: &str) -> String { + ok(&company_kv("name", name)).asset_tag_prefix() +} + +#[test] +fn asset_tag_prefix_explicit_setting_wins() { + let s = ok("[company]\nname = \"Wanderland Labs LLC\"\n[books]\nasset_tag_prefix = \"LAB\"\n"); + assert_eq!(s.asset_tag_prefix.as_deref(), Some("LAB")); + assert_eq!(s.asset_tag_prefix(), "LAB"); +} + +#[test] +fn asset_tag_prefix_accepts_one_to_eight_letters_or_digits() { + for p in ["A", "7", "WL", "WL2026", "ABCDEFGH", "12345678"] { + let s = ok(&books_kv("asset_tag_prefix", p)); + assert_eq!(s.asset_tag_prefix(), p, "{p:?}"); + } +} + +#[test] +fn asset_tag_prefix_refuses_other_forms() { + for p in [ + "", + "ABCDEFGHI", + "WL-", + "W L", + "W_L", + "WL.", + "ÉL", + " WL", + "WL/1", + ] { + refused(&books_kv("asset_tag_prefix", p)); + } + // Not a string. + refused("[books]\nasset_tag_prefix = 12\n"); +} + +#[test] +fn asset_tag_prefix_unset_is_none() { + assert_eq!(ok("").asset_tag_prefix, None); + assert_eq!(ok("[books]\n").asset_tag_prefix, None); +} + +/// The doc's example. +#[test] +fn asset_tag_prefix_default_is_company_initials_without_legal_form() { + assert_eq!(prefix_for_name("Wanderland Labs LLC"), "WL"); + assert_eq!(prefix_for_name("Acme Inc"), "A"); + assert_eq!(prefix_for_name("Acme Widgets Corp"), "AW"); + assert_eq!(prefix_for_name("Blue Sky Ventures"), "BSV"); +} + +/// Initials are uppercase. +#[test] +fn asset_tag_prefix_default_is_uppercase() { + assert_eq!(prefix_for_name("wanderland labs LLC"), "WL"); +} + +/// At most 8 initials. +#[test] +fn asset_tag_prefix_default_takes_at_most_eight_initials() { + assert_eq!( + prefix_for_name("Alpha Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliet"), + "ABCDEFGH" + ); +} + +/// "FA" when the name gives none: only legal-form words, or the default +/// (unnamed) company. +#[test] +fn asset_tag_prefix_default_falls_back_to_fa() { + assert_eq!(ok("").asset_tag_prefix(), "FA"); + assert_eq!(prefix_for_name("Unnamed company"), "FA"); + assert_eq!(prefix_for_name("LLC"), "FA"); + assert_eq!(prefix_for_name("Inc"), "FA"); +} + +proptest! { + /// Whatever the name, the default is a prefix the setting itself would + /// accept: 1 to 8 ASCII letters or digits, uppercase. + #[test] + fn prop_default_asset_tag_prefix_is_a_valid_setting(name in "[ -~]{1,60}") { + let Ok(s) = parse(&company_kv("name", &name)) else { return Ok(()); }; + let p = s.asset_tag_prefix(); + prop_assert!((1..=8).contains(&p.len()), "{:?} -> {:?}", name, p); + prop_assert!(p.chars().all(|c| c.is_ascii_alphanumeric()), "{:?} -> {:?}", name, p); + prop_assert_eq!(p.to_ascii_uppercase(), p.clone()); + let back = ok(&books_kv("asset_tag_prefix", &p)); + prop_assert_eq!(back.asset_tag_prefix(), p); + } +} diff --git a/crates/ledger-core/src/assets.rs b/crates/ledger-core/src/assets.rs index c6be434..a7d65b2 100644 --- a/crates/ledger-core/src/assets.rs +++ b/crates/ledger-core/src/assets.rs @@ -68,6 +68,83 @@ pub struct Asset { 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, +} + +/// 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) diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index d2bc467..1bfb87f 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -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, \ diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index c04f9d0..8dbb4d6 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -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, } 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 (Stephen, 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, 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 (Stephen, 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 (Stephen, 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. /// diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 173cf50..9c2635d 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -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, diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index 809719c..fe3cb74 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -1038,27 +1038,32 @@ pub fn snapshot(ledger: &Ledger) -> Result { 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::>(), - "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, + /// What it is. + pub description: String, + /// The manufacturer's serial number, if recorded. + pub serial: Option, + /// 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, + /// 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 (Stephen, +/// 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::(), + } +} diff --git a/crates/ledger-core/tests/assets.rs b/crates/ledger-core/tests/assets.rs index eb54059..3a57183 100644 --- a/crates/ledger-core/tests/assets.rs +++ b/crates/ledger-core/tests/assets.rs @@ -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)); +} diff --git a/crates/ledger-core/tests/spec_asset_tags.rs b/crates/ledger-core/tests/spec_asset_tags.rs new file mode 100644 index 0000000..c2b56f9 --- /dev/null +++ b/crates/ledger-core/tests/spec_asset_tags.rs @@ -0,0 +1,1031 @@ +//! Spec-derived tests for asset tags and serial numbers (rules.md B-24) and +//! the fixed asset register (`reports::fixed_asset_register`). +//! +//! Every expected value comes from the written specification: the doc +//! comments and GOAL/SOURCE/INVARIANTS/SCENARIOS blocks on +//! `assets::check_asset_tag`, `assets::check_serial`, `assets::tag_key`, +//! `Ledger::set_asset_tag`, `check_asset_tag_for`, `set_asset_serial`, +//! `asset_by_tag`, `next_asset_tag`, `fixed_asset_register` and +//! `AssetRegister`, and `docs/spec/rules.md` B-15 and B-24. Depreciation +//! amounts are worked by hand (straight line, whole months) or, for +//! mid-month dates, taken from the already-tested building block +//! `accumulated_depreciation`. Nothing is read back from the code under +//! test and then asserted. + +use std::collections::BTreeMap; +use std::str::FromStr; + +use chrono::NaiveDate; +use ledger_core::assets::{ + MAX_ASSET_TAG_LEN, MAX_SERIAL_LEN, check_asset_tag, check_serial, tag_key, +}; +use ledger_core::{ + AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side, + accumulated_depreciation, fixed_asset_register, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +// ---------------------------------------------------------------- helpers + +fn d(s: &str) -> Decimal { + Decimal::from_str(s).expect("decimal literal") +} + +fn usd(s: &str) -> Money { + Money::new(d(s), Currency::USD) +} + +fn date(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).expect("valid date") +} + +struct Accts { + cash: AccountId, + equipment: AccountId, + accum: AccountId, + vehicles: AccountId, + vehicles_accum: AccountId, + depr_expense: AccountId, + gain: AccountId, +} + +/// A USD ledger with a fixed-asset chart, funded with $50,000 cash on +/// 2026-01-01 (Dr 1000 / Cr 3000). +fn fixture() -> (Ledger, Accts) { + let mut l = Ledger::new(Currency::USD); + let mut acct = |code: &str, name: &str, ty: AccountType| { + l.add_account(code, name, ty, None, false).unwrap() + }; + let cash = acct("1000", "Cash", AccountType::Asset); + let equipment = acct("1500", "Equipment", AccountType::Asset); + let accum = acct("1510", "Accumulated Depreciation", AccountType::Asset); + let vehicles = acct("1520", "Vehicles", AccountType::Asset); + let vehicles_accum = acct( + "1530", + "Accumulated Depreciation — Vehicles", + AccountType::Asset, + ); + let capital = acct("3000", "Owner Capital", AccountType::Equity); + let gain = acct("4900", "Gain on Disposal", AccountType::Revenue); + let depr_expense = acct("6200", "Depreciation Expense", AccountType::Expense); + post( + &mut l, + date(2026, 1, 1), + &[ + (cash, Side::Debit, "50000.00"), + (capital, Side::Credit, "50000.00"), + ], + ); + ( + l, + Accts { + cash, + equipment, + accum, + vehicles, + vehicles_accum, + depr_expense, + gain, + }, + ) +} + +fn post(l: &mut Ledger, on: NaiveDate, lines: &[(AccountId, Side, &str)]) { + let e = l.try_create_entry(on, "test entry").unwrap(); + for (a, side, amt) in lines { + l.add_line(e, *a, *side, usd(amt)).unwrap(); + } + l.post_entry(e).unwrap(); +} + +fn equipment(a: &Accts, description: &str, cost: &str, life: u32, on: NaiveDate) -> NewAsset { + NewAsset { + description: description.to_string(), + purchase_date: on, + cost: usd(cost), + salvage_value: usd("0.00"), + useful_life_months: life, + cost_account: a.equipment, + accum_depr_account: a.accum, + expense_account: a.depr_expense, + } +} + +/// A ledger with `n` registered laptops (purchased 2026-01-01). +fn with_assets(n: usize) -> (Ledger, Accts, Vec) { + let (mut l, a) = fixture(); + let ids = (0..n) + .map(|i| { + l.register_asset(equipment( + &a, + &format!("Laptop {i}"), + "1200.00", + 12, + date(2026, 1, 1), + )) + .unwrap() + }) + .collect(); + (l, a, ids) +} + +fn tag_of(l: &Ledger, id: AssetId) -> Option { + l.get_asset(id).unwrap().tag.clone() +} + +fn serial_of(l: &Ledger, id: AssetId) -> Option { + l.get_asset(id).unwrap().serial.clone() +} + +// ------------------------------------------------- check_asset_tag (form) + +/// check_asset_tag: 1 to MAX_ASSET_TAG_LEN characters of ASCII letters, +/// digits, `- _ . /`, at least one letter or digit. +#[test] +fn check_asset_tag_accepts_the_documented_forms() { + for ok in [ + "WL-0001", + "a", + "7", + "A_B.C/D-9", + "wl-0001", + "-a-", + "./x", + "LAPTOP", + ] { + assert!(check_asset_tag(ok).is_ok(), "{ok:?} should be accepted"); + } + let longest = "A".repeat(MAX_ASSET_TAG_LEN); + assert!(check_asset_tag(&longest).is_ok()); +} + +/// B-24 says 1–32 characters: the constant agrees with the rule. +#[test] +fn max_asset_tag_len_is_32_per_b24() { + assert_eq!(MAX_ASSET_TAG_LEN, 32); +} + +/// check_asset_tag INVARIANT 1: no spaces, no control or non-ASCII +/// characters, at least one letter or digit; and 1..=MAX length. +#[test] +fn check_asset_tag_refuses_wrong_forms() { + let too_long = "A".repeat(MAX_ASSET_TAG_LEN + 1); + for bad in [ + "", + "WL 0001", + " WL", + "WL\t1", + "WL\n1", + "WL\u{7}1", + "ÄB-1", + "WL–0001", // en dash, not ASCII + "WL#1", + "WL,1", + "---", + "./", + "_", + too_long.as_str(), + ] { + let r = check_asset_tag(bad); + assert!(r.is_err(), "{bad:?} should be refused"); + assert!(!r.unwrap_err().is_empty(), "refusal says why"); + } +} + +// ------------------------------------------------- check_serial (form) + +/// check_serial: 1 to MAX_SERIAL_LEN characters, no control characters, +/// no leading or trailing space. Spaces inside and non-ASCII are allowed +/// (only control characters and edge spaces are excluded). +#[test] +fn check_serial_accepts_the_documented_forms() { + for ok in ["C02XG0FDJGH5", "SN 123 456", "x", "Ünïcødé-ß", "a/b#c"] { + assert!(check_serial(ok).is_ok(), "{ok:?} should be accepted"); + } + assert!(check_serial(&"S".repeat(MAX_SERIAL_LEN)).is_ok()); + // The limit is in characters: 64 two-byte characters are 64 characters. + assert!(check_serial(&"é".repeat(MAX_SERIAL_LEN)).is_ok()); +} + +#[test] +fn max_serial_len_is_64() { + assert_eq!(MAX_SERIAL_LEN, 64); +} + +#[test] +fn check_serial_refuses_wrong_forms() { + let too_long = "S".repeat(MAX_SERIAL_LEN + 1); + for bad in [ + "", + " ", + " ", + " SN1", + "SN1 ", + "SN\n1", + "SN\u{0}1", + "SN\u{7f}1", + too_long.as_str(), + ] { + let r = check_serial(bad); + assert!(r.is_err(), "{bad:?} should be refused"); + assert!(!r.unwrap_err().is_empty(), "refusal says why"); + } +} + +// ------------------------------------------------- tag_key + +/// tag_key: "wl-0001" is "WL-0001". +#[test] +fn tag_key_ignores_case() { + assert_eq!(tag_key("wl-0001"), tag_key("WL-0001")); + assert_eq!(tag_key("Wl-0001"), tag_key("wL-0001")); + assert_ne!(tag_key("WL-0001"), tag_key("WL-0002")); +} + +// ------------------------------------------------- set_asset_tag + +/// Asset.tag doc: "None until one is set"; same for serial. +#[test] +fn a_new_asset_has_no_tag_or_serial() { + let (l, _, ids) = with_assets(1); + assert_eq!(tag_of(&l, ids[0]), None); + assert_eq!(serial_of(&l, ids[0]), None); +} + +#[test] +fn set_asset_tag_records_the_tag() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + assert_eq!(tag_of(&l, ids[0]).as_deref(), Some("WL-0001")); + assert_eq!(l.asset_by_tag("WL-0001").map(|a| a.id), Some(ids[0])); +} + +/// SCENARIO 1: unknown asset: refuse; INVARIANT 3: nothing changes. +#[test] +fn set_asset_tag_refuses_an_unknown_asset_unchanged() { + let (mut l, _, ids) = with_assets(1); + let before = l.clone(); + assert!(l.set_asset_tag(ids[0] + 100, "WL-0001").is_err()); + assert_eq!(l, before); + // ...and the tag is still free for a real asset. + l.set_asset_tag(ids[0], "WL-0001").unwrap(); +} + +/// SCENARIO 2: wrong form: InvalidAssetTag, nothing changes. +#[test] +fn set_asset_tag_refuses_a_bad_form_unchanged() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + let before = l.clone(); + for bad in ["", "WL 2", "É-1", "--"] { + let err = l.set_asset_tag(ids[0], bad).unwrap_err(); + assert!( + matches!(&err, LedgerError::InvalidAssetTag { tag, .. } if tag == bad), + "{bad:?}: {err:?}" + ); + assert_eq!(l, before); + } +} + +/// SCENARIO 3 and INVARIANT 1: another asset holds the tag (ignoring case): +/// AssetTagTaken naming the holder, nothing changes. +#[test] +fn set_asset_tag_refuses_a_tag_another_asset_holds_ignoring_case() { + let (mut l, _, ids) = with_assets(2); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + let before = l.clone(); + for t in ["WL-0001", "wl-0001", "Wl-0001"] { + let err = l.set_asset_tag(ids[1], t).unwrap_err(); + assert!( + matches!(&err, LedgerError::AssetTagTaken { asset, .. } if *asset == ids[0]), + "{t:?}: {err:?}" + ); + assert_eq!(l, before); + } +} + +/// INVARIANT 2: a tag released by a retag is never reused by another asset. +#[test] +fn a_retagged_assets_old_tag_is_never_reused() { + let (mut l, _, ids) = with_assets(2); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.set_asset_tag(ids[0], "WL-0100").unwrap(); + assert_eq!(tag_of(&l, ids[0]).as_deref(), Some("WL-0100")); + let before = l.clone(); + let err = l.set_asset_tag(ids[1], "wl-0001").unwrap_err(); + assert!( + matches!(err, LedgerError::AssetTagTaken { asset, .. } if asset == ids[0]), + "{err:?}" + ); + assert_eq!(l, before); +} + +/// INVARIANT 2: a disposed asset's tag is never reused either. +#[test] +fn a_disposed_assets_tag_is_never_reused() { + let (mut l, _, ids) = with_assets(2); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.dispose_asset(ids[0], date(2026, 6, 30), usd("100.00")) + .unwrap(); + let before = l.clone(); + let err = l.set_asset_tag(ids[1], "WL-0001").unwrap_err(); + assert!( + matches!(err, LedgerError::AssetTagTaken { asset, .. } if asset == ids[0]), + "{err:?}" + ); + assert_eq!(l, before); +} + +/// INVARIANT 2: "The same asset may take back a tag it held." +#[test] +fn an_asset_may_take_back_a_tag_it_held() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.set_asset_tag(ids[0], "WL-0002").unwrap(); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + assert_eq!(tag_of(&l, ids[0]).as_deref(), Some("WL-0001")); + assert_eq!(l.asset_by_tag("WL-0001").map(|a| a.id), Some(ids[0])); + assert!(l.asset_by_tag("WL-0002").is_none()); +} + +/// SCENARIO 4: the asset already has that tag: accept, no change. +#[test] +fn setting_the_same_tag_again_changes_nothing() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + let before = l.clone(); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + assert_eq!(l, before); +} + +/// Unique ignoring case is about *other* assets: an asset's own tag in +/// another case is its own (INVARIANT 1 is about two assets). +#[test] +fn an_asset_may_take_its_own_tag_in_another_case() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.set_asset_tag(ids[0], "wl-0001").unwrap(); + assert_eq!(l.asset_by_tag("WL-0001").map(|a| a.id), Some(ids[0])); +} + +// ------------------------------------------------- check_asset_tag_for + +/// check_asset_tag_for mirrors set_asset_tag's refusals and changes nothing. +#[test] +fn check_asset_tag_for_mirrors_set_asset_tag() { + let (mut l, _, ids) = with_assets(2); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.set_asset_tag(ids[0], "WL-0002").unwrap(); + let before = l.clone(); + // An existing asset. + assert!(l.check_asset_tag_for(Some(ids[1]), "WL-0003").is_ok()); + assert!(matches!( + l.check_asset_tag_for(Some(ids[1]), "wl-0001"), + Err(LedgerError::AssetTagTaken { asset, .. }) if asset == ids[0] + )); + assert!(matches!( + l.check_asset_tag_for(Some(ids[1]), "bad tag"), + Err(LedgerError::InvalidAssetTag { .. }) + )); + assert!(l.check_asset_tag_for(Some(ids[0]), "WL-0001").is_ok()); + assert!(l.check_asset_tag_for(Some(ids[1] + 50), "WL-0009").is_err()); + // An asset not yet registered: any tag ever held is taken. + assert!(l.check_asset_tag_for(None, "WL-0003").is_ok()); + assert!(matches!( + l.check_asset_tag_for(None, "WL-0002"), + Err(LedgerError::AssetTagTaken { asset, .. }) if asset == ids[0] + )); + assert!(matches!( + l.check_asset_tag_for(None, "WL-0001"), + Err(LedgerError::AssetTagTaken { asset, .. }) if asset == ids[0] + )); + assert!(matches!( + l.check_asset_tag_for(None, ""), + Err(LedgerError::InvalidAssetTag { .. }) + )); + assert_eq!(l, before, "checking changes nothing"); +} + +// ------------------------------------------------- asset_by_tag + +/// asset_by_tag: the asset whose *current* tag is `tag`, ignoring case. +#[test] +fn asset_by_tag_finds_current_tags_ignoring_case() { + let (mut l, _, ids) = with_assets(2); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.set_asset_tag(ids[1], "Desk/7").unwrap(); + assert_eq!(l.asset_by_tag("wl-0001").map(|a| a.id), Some(ids[0])); + assert_eq!(l.asset_by_tag("DESK/7").map(|a| a.id), Some(ids[1])); + assert!(l.asset_by_tag("WL-0002").is_none()); + assert!(l.asset_by_tag("").is_none()); + l.set_asset_tag(ids[0], "WL-0005").unwrap(); + assert!( + l.asset_by_tag("WL-0001").is_none(), + "an old tag is no longer current" + ); + assert_eq!(l.asset_by_tag("WL-0005").map(|a| a.id), Some(ids[0])); +} + +/// A disposed asset keeps its tag (nothing in the spec takes it away). +#[test] +fn asset_by_tag_still_finds_a_disposed_asset() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.dispose_asset(ids[0], date(2026, 6, 30), usd("100.00")) + .unwrap(); + assert_eq!(l.asset_by_tag("WL-0001").map(|a| a.id), Some(ids[0])); +} + +// ------------------------------------------------- next_asset_tag + +#[test] +fn next_asset_tag_starts_at_0001() { + let (l, _, _) = with_assets(0); + assert_eq!(l.next_asset_tag("WL"), "WL-0001"); + assert_eq!(l.next_asset_tag("FA"), "FA-0001"); +} + +/// The lowest number no asset holds or held under that prefix. +#[test] +fn next_asset_tag_is_the_lowest_never_held_number() { + let (mut l, _, ids) = with_assets(3); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.set_asset_tag(ids[1], "WL-0003").unwrap(); + assert_eq!(l.next_asset_tag("WL"), "WL-0002"); + l.set_asset_tag(ids[2], "WL-0002").unwrap(); + assert_eq!(l.next_asset_tag("WL"), "WL-0004"); + // Retagging WL-0001 away doesn't free it. + l.set_asset_tag(ids[0], "Laptop-A").unwrap(); + assert_eq!(l.next_asset_tag("WL"), "WL-0004"); + // Other prefixes are independent. + assert_eq!(l.next_asset_tag("FA"), "FA-0001"); +} + +/// Tags are unique ignoring case, so a lower-case held tag is held. +#[test] +fn next_asset_tag_skips_numbers_held_in_another_case() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_tag(ids[0], "wl-0001").unwrap(); + assert_eq!(l.next_asset_tag("WL"), "WL-0002"); +} + +/// A disposed asset's tag stays held. +#[test] +fn next_asset_tag_skips_a_disposed_assets_tag() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.dispose_asset(ids[0], date(2026, 3, 31), usd("0.00")) + .unwrap(); + assert_eq!(l.next_asset_tag("WL"), "WL-0002"); +} + +/// "Numbers past 9999 widen." +#[test] +fn next_asset_tag_widens_past_9999() { + let (mut l, _, ids) = with_assets(1); + for n in 1..=9999 { + l.set_asset_tag(ids[0], &format!("WL-{n:04}")).unwrap(); + } + assert_eq!(l.next_asset_tag("WL"), "WL-10000"); +} + +/// A generated tag is always one the ledger would accept for a new asset. +#[test] +fn next_asset_tag_is_accepted() { + let (mut l, _, ids) = with_assets(2); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + let t = l.next_asset_tag("WL"); + assert!(l.check_asset_tag_for(None, &t).is_ok()); + l.set_asset_tag(ids[1], &t).unwrap(); +} + +// ------------------------------------------------- set_asset_serial + +#[test] +fn set_asset_serial_records_and_clears() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_serial(ids[0], Some("C02XG0FDJGH5")).unwrap(); + assert_eq!(serial_of(&l, ids[0]).as_deref(), Some("C02XG0FDJGH5")); + l.set_asset_serial(ids[0], Some("SN 2")).unwrap(); + assert_eq!(serial_of(&l, ids[0]).as_deref(), Some("SN 2")); + l.set_asset_serial(ids[0], None).unwrap(); + assert_eq!(serial_of(&l, ids[0]), None); +} + +/// SCENARIO 1: unknown asset or bad form: refuse, changing nothing. +#[test] +fn set_asset_serial_refusals_change_nothing() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_serial(ids[0], Some("SN-1")).unwrap(); + let before = l.clone(); + assert!(l.set_asset_serial(ids[0] + 9, Some("SN-2")).is_err()); + assert!(l.set_asset_serial(ids[0] + 9, None).is_err()); + assert_eq!(l, before); + for bad in ["", " SN", "SN\n"] { + assert!( + matches!( + l.set_asset_serial(ids[0], Some(bad)), + Err(LedgerError::InvalidSerial(_)) + ), + "{bad:?}" + ); + assert_eq!(l, before); + } + // check_asset_serial agrees and changes nothing. + assert!(l.check_asset_serial(ids[0], Some("ok")).is_ok()); + assert!(l.check_asset_serial(ids[0], None).is_ok()); + assert!(matches!( + l.check_asset_serial(ids[0], Some(" x")), + Err(LedgerError::InvalidSerial(_)) + )); + assert!(l.check_asset_serial(ids[0] + 9, Some("ok")).is_err()); + assert_eq!(l, before); +} + +/// Serials don't touch tags. +#[test] +fn a_serial_is_independent_of_the_tag() { + let (mut l, _, ids) = with_assets(1); + l.set_asset_tag(ids[0], "WL-0001").unwrap(); + l.set_asset_serial(ids[0], Some("WL-0002")).unwrap(); + assert_eq!(tag_of(&l, ids[0]).as_deref(), Some("WL-0001")); + assert_eq!(l.next_asset_tag("WL"), "WL-0002"); +} + +// ------------------------------------------------- proptest: tag commands + +const POOL: &[&str] = &[ + "T-0001", "t-0001", "T-0002", "T-0003", "t-0003", "X/1", "x/1", "", "a b", "--", "Ü-1", +]; +const SERIALS: &[&str] = &["SN-1", "sn 2", "", " lead", "trail ", "ctl\u{1}"]; + +#[derive(Clone, Debug)] +enum Cmd { + /// Tag asset (index; 3 is unknown) with POOL[i]. + Tag(usize, usize), + /// Serial (asset index; 3 is unknown) Some(SERIALS[i]) or None. + Serial(usize, Option), + /// Dispose asset (index; 3 is unknown). + Dispose(usize), +} + +fn arb_cmd() -> impl Strategy { + prop_oneof![ + 4 => (0usize..4, 0..POOL.len()).prop_map(|(a, t)| Cmd::Tag(a, t)), + 2 => (0usize..4, proptest::option::of(0..SERIALS.len())) + .prop_map(|(a, s)| Cmd::Serial(a, s)), + 1 => (0usize..4).prop_map(Cmd::Dispose), + ] +} + +/// The model, from the spec alone. +#[derive(Default)] +struct Model { + /// tag key → asset that holds or held it. + ever: BTreeMap, + current: [Option; 3], + serial: [Option; 3], + disposed: [bool; 3], +} + +fn form_ok(t: &str) -> bool { + (1..=32).contains(&t.len()) + && t.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')) + && t.chars().any(|c| c.is_ascii_alphanumeric()) +} + +fn serial_ok(s: &str) -> bool { + let n = s.chars().count(); + (1..=64).contains(&n) + && !s.starts_with(' ') + && !s.ends_with(' ') + && !s.chars().any(char::is_control) + && !s.chars().all(|c| c == ' ') +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// B-24 / set_asset_tag INVARIANTS 1-3 and set_asset_serial SCENARIO 1 + /// over random command sequences, against a model built from the spec. + #[test] + fn prop_tag_commands_keep_the_invariants(cmds in proptest::collection::vec(arb_cmd(), 1..40)) { + let (mut l, _, ids) = with_assets(3); + let unknown = ids[2] + 1000; + let id_of = |i: usize| if i < 3 { ids[i] } else { unknown }; + let mut m = Model::default(); + for cmd in cmds { + let before = l.clone(); + match cmd { + Cmd::Tag(ai, ti) => { + let t = POOL[ti]; + let r = l.set_asset_tag(id_of(ai), t); + if ai == 3 { + prop_assert!(r.is_err(), "unknown asset accepted"); + prop_assert!(l == before); + } else if !form_ok(t) { + prop_assert!( + matches!(r, Err(LedgerError::InvalidAssetTag { .. })), + "bad form {:?}: {:?}", t, r + ); + prop_assert!(l == before); + } else if let Some(&holder) = m.ever.get(&t.to_ascii_uppercase()) + && holder != ai + { + prop_assert!( + matches!(r, Err(LedgerError::AssetTagTaken { asset, .. }) if asset == ids[holder]), + "taken {:?}: {:?}", t, r + ); + prop_assert!(l == before); + } else { + prop_assert!(r.is_ok(), "{:?} on {}: {:?}", t, ai, r); + m.ever.insert(t.to_ascii_uppercase(), ai); + m.current[ai] = Some(t.to_string()); + } + } + Cmd::Serial(ai, si) => { + let s = si.map(|i| SERIALS[i]); + let r = l.set_asset_serial(id_of(ai), s); + if ai == 3 { + prop_assert!(r.is_err()); + prop_assert!(l == before); + } else if s.is_some_and(|s| !serial_ok(s)) { + prop_assert!(matches!(r, Err(LedgerError::InvalidSerial(_))), "{:?}", r); + prop_assert!(l == before); + } else { + prop_assert!(r.is_ok(), "{:?}", r); + m.serial[ai] = s.map(str::to_string); + } + } + Cmd::Dispose(ai) => { + let r = l.dispose_asset(id_of(ai), date(2026, 6, 30), usd("100.00")); + if ai == 3 || m.disposed[ai] { + prop_assert!(r.is_err()); + prop_assert!(l == before); + } else { + prop_assert!(r.is_ok(), "{:?}", r); + m.disposed[ai] = true; + } + } + } + // State agrees with the model. + for (i, &id) in ids.iter().enumerate() { + prop_assert_eq!(tag_of(&l, id), m.current[i].clone()); + prop_assert_eq!(serial_of(&l, id), m.serial[i].clone()); + } + // INVARIANT 1: current tags unique ignoring case. + let keys: Vec = (0..3) + .filter_map(|i| tag_of(&l, ids[i]).map(|t| tag_key(&t))) + .collect(); + let mut dedup = keys.clone(); + dedup.sort(); + dedup.dedup(); + prop_assert_eq!(keys.len(), dedup.len()); + // asset_by_tag: current tag, ignoring case. + for t in POOL { + let want = (0..3).find(|&i| { + m.current[i].as_deref().map(|c| c.to_ascii_uppercase()) + == Some(t.to_ascii_uppercase()) + }); + prop_assert_eq!(l.asset_by_tag(t).map(|a| a.id), want.map(|i| ids[i])); + } + // next_asset_tag: lowest T-NNNN never held. + let want_next = (1..) + .map(|n| format!("T-{n:04}")) + .find(|t| !m.ever.contains_key(t)) + .unwrap(); + prop_assert_eq!(l.next_asset_tag("T"), want_next); + } + } +} + +// ================================================= fixed asset register + +/// Depreciation for asset ledger postings: Dr 6200 / Cr accumulated. +fn depreciate(l: &mut Ledger, a: &Accts, accum: AccountId, on: NaiveDate, amt: &str) { + post( + l, + on, + &[ + (a.depr_expense, Side::Debit, amt), + (accum, Side::Credit, amt), + ], + ); +} + +/// Buy equipment for cash: Dr cost account / Cr 1000. +fn buy(l: &mut Ledger, a: &Accts, account: AccountId, on: NaiveDate, amt: &str) { + post( + l, + on, + &[(account, Side::Debit, amt), (a.cash, Side::Credit, amt)], + ); +} + +fn month_end(m: u32) -> NaiveDate { + let next = if m == 12 { + date(2027, 1, 1) + } else { + date(2026, m + 1, 1) + }; + next.pred_opt().unwrap() +} + +/// Laptop: $12,000, no salvage, 12 months from 2026-01-01 → $1,000/month. +/// Server: $2,400, no salvage, 24 months from 2026-01-01 → $100/month. +/// Both in 1500/1510, bought for cash, depreciation posted Jan–Mar. +fn register_fixture() -> (Ledger, Accts, AssetId, AssetId) { + let (mut l, a) = fixture(); + buy(&mut l, &a, a.equipment, date(2026, 1, 1), "12000.00"); + let laptop = l + .register_asset(equipment(&a, "Laptop", "12000.00", 12, date(2026, 1, 1))) + .unwrap(); + buy(&mut l, &a, a.equipment, date(2026, 1, 1), "2400.00"); + let server = l + .register_asset(equipment(&a, "Server", "2400.00", 24, date(2026, 1, 1))) + .unwrap(); + for m in 1..=3 { + depreciate(&mut l, &a, a.accum, month_end(m), "1100.00"); + } + (l, a, laptop, server) +} + +/// INVARIANT 3: totals are the sums over held (not disposed) rows. +fn assert_totals_are_sums(r: &ledger_core::AssetRegister) { + let held = r.rows.iter().filter(|x| x.disposed.is_none()); + let (mut c, mut acc, mut bv) = (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO); + for x in held { + c += x.cost; + acc += x.accumulated; + bv += x.book_value; + } + assert_eq!(r.total_cost, c); + assert_eq!(r.total_accumulated, acc); + assert_eq!(r.total_book_value, bv); + for x in &r.rows { + // INVARIANT 2 (held) / INVARIANT 1 (disposed). + if x.disposed.is_some() { + assert_eq!(x.accumulated, Decimal::ZERO); + assert_eq!(x.book_value, Decimal::ZERO); + } else { + assert_eq!(x.book_value, x.cost - x.accumulated); + } + } +} + +#[test] +fn register_agrees_with_the_books_when_depreciation_is_posted() { + let (mut l, _, laptop, server) = register_fixture(); + l.set_asset_tag(laptop, "WL-0001").unwrap(); + l.set_asset_serial(laptop, Some("C02XG0FDJGH5")).unwrap(); + let r = fixed_asset_register(&l, date(2026, 3, 31)); + assert_eq!(r.as_of, date(2026, 3, 31)); + assert_eq!(r.rows.len(), 2); + // INVARIANT 1: registration order. + assert_eq!(r.rows[0].asset_id, laptop); + assert_eq!(r.rows[1].asset_id, server); + let x = &r.rows[0]; + assert_eq!(x.tag.as_deref(), Some("WL-0001")); + assert_eq!(x.serial.as_deref(), Some("C02XG0FDJGH5")); + assert_eq!(x.description, "Laptop"); + assert_eq!(x.in_service, date(2026, 1, 1)); + assert_eq!(x.cost, d("12000.00")); + assert_eq!(x.salvage, d("0.00")); + assert_eq!(x.useful_life_months, 12); + assert_eq!(x.accumulated, d("3000.00")); + assert_eq!(x.book_value, d("9000.00")); + assert_eq!(x.disposed, None); + let y = &r.rows[1]; + assert_eq!(y.tag, None); + assert_eq!(y.serial, None); + assert_eq!(y.accumulated, d("300.00")); + assert_eq!(y.book_value, d("2100.00")); + assert_eq!(r.total_cost, d("14400.00")); + assert_eq!(r.total_accumulated, d("3300.00")); + assert_eq!(r.total_book_value, d("11100.00")); + // INVARIANT 4: the GL, each shared account counted once. + assert_eq!(r.ledger_cost, d("14400.00")); + assert_eq!(r.ledger_accumulated, d("3300.00")); + assert_eq!(r.unposted_depreciation(), Decimal::ZERO); + assert_eq!(r.unregistered_cost(), Decimal::ZERO); + assert_totals_are_sums(&r); +} + +/// unposted_depreciation: due by schedule but not in the books. +#[test] +fn register_shows_unposted_depreciation() { + let (l, _, _, _) = register_fixture(); + let r = fixed_asset_register(&l, date(2026, 4, 30)); + assert_eq!(r.total_accumulated, d("4400.00")); + assert_eq!(r.ledger_accumulated, d("3300.00")); + assert_eq!(r.unposted_depreciation(), d("1100.00")); + assert_eq!(r.unregistered_cost(), Decimal::ZERO); + assert_totals_are_sums(&r); +} + +/// Negative unposted_depreciation when the books carry more. +#[test] +fn register_shows_overposted_depreciation_as_negative() { + let (mut l, a, _, _) = register_fixture(); + depreciate(&mut l, &a, a.accum, date(2026, 3, 31), "50.00"); + let r = fixed_asset_register(&l, date(2026, 3, 31)); + assert_eq!(r.ledger_accumulated, d("3350.00")); + assert_eq!(r.unposted_depreciation(), d("-50.00")); +} + +/// The GL balances are on as_of: later postings don't count. +#[test] +fn register_ledger_balances_are_as_of_the_date() { + let (l, _, _, _) = register_fixture(); + let r = fixed_asset_register(&l, date(2026, 2, 28)); + assert_eq!(r.total_accumulated, d("2200.00")); + assert_eq!(r.ledger_accumulated, d("2200.00")); + assert_eq!(r.ledger_cost, d("14400.00")); + assert_eq!(r.unposted_depreciation(), Decimal::ZERO); +} + +/// INVARIANT 2 mid-month: accumulated is accumulated_depreciation through +/// as_of. +#[test] +fn register_accumulated_mid_month_follows_the_schedule() { + let (l, _, laptop, server) = register_fixture(); + let on = date(2026, 5, 17); + let r = fixed_asset_register(&l, on); + let la = accumulated_depreciation(l.get_asset(laptop).unwrap(), on); + let sa = accumulated_depreciation(l.get_asset(server).unwrap(), on); + assert_eq!(r.rows[0].accumulated, la); + assert_eq!(r.rows[1].accumulated, sa); + assert_eq!(r.rows[0].book_value, d("12000.00") - la); + assert_eq!(r.total_accumulated, la + sa); + assert_totals_are_sums(&r); +} + +/// unregistered_cost: equipment in the books that no asset accounts for. +#[test] +fn register_shows_unregistered_cost() { + let (mut l, a, _, _) = register_fixture(); + buy(&mut l, &a, a.equipment, date(2026, 2, 10), "500.00"); + let r = fixed_asset_register(&l, date(2026, 3, 31)); + assert_eq!(r.ledger_cost, d("14900.00")); + assert_eq!(r.total_cost, d("14400.00")); + assert_eq!(r.unregistered_cost(), d("500.00")); + // Registered cost not in the books goes negative. + let (mut l2, a2) = fixture(); + l2.register_asset(equipment(&a2, "Unpaid", "800.00", 8, date(2026, 1, 1))) + .unwrap(); + let r2 = fixed_asset_register(&l2, date(2026, 1, 31)); + assert_eq!(r2.ledger_cost, Decimal::ZERO); + assert_eq!(r2.unregistered_cost(), d("-800.00")); + assert_eq!(r2.unposted_depreciation(), d("100.00")); +} + +/// INVARIANT 1: only assets registered (placed in service) by as_of. +#[test] +fn register_leaves_out_assets_not_yet_in_service() { + let (mut l, a, laptop, server) = register_fixture(); + buy(&mut l, &a, a.equipment, date(2026, 6, 1), "600.00"); + let chair = l + .register_asset(equipment(&a, "Chair", "600.00", 6, date(2026, 6, 1))) + .unwrap(); + let r = fixed_asset_register(&l, date(2026, 5, 31)); + let ids: Vec = r.rows.iter().map(|x| x.asset_id).collect(); + assert_eq!(ids, vec![laptop, server]); + assert_eq!(r.ledger_cost, d("14400.00")); + let r = fixed_asset_register(&l, date(2026, 6, 1)); + let ids: Vec = r.rows.iter().map(|x| x.asset_id).collect(); + assert_eq!(ids, vec![laptop, server, chair]); + assert_eq!( + r.rows[2].accumulated, + accumulated_depreciation(l.get_asset(chair).unwrap(), date(2026, 6, 1)) + ); + // Before anything: empty and zero. + let r = fixed_asset_register(&l, date(2025, 12, 31)); + assert!(r.rows.is_empty()); + assert_eq!(r.total_cost, Decimal::ZERO); + assert_eq!(r.total_accumulated, Decimal::ZERO); + assert_eq!(r.total_book_value, Decimal::ZERO); +} + +/// Disposal: Server sold 2026-06-30 for $2,000 after six months ($600) +/// depreciation: book value 1,800, gain 200. Entry: Dr 1000 2,000, +/// Dr 1510 600, Cr 1500 2,400, Cr 4900 200. +fn dispose_server(l: &mut Ledger, a: &Accts, server: AssetId) { + for m in 4..=6 { + depreciate(l, a, a.accum, month_end(m), "1100.00"); + } + l.dispose_asset(server, date(2026, 6, 30), usd("2000.00")) + .unwrap(); + post( + l, + date(2026, 6, 30), + &[ + (a.cash, Side::Debit, "2000.00"), + (a.accum, Side::Debit, "600.00"), + (a.equipment, Side::Credit, "2400.00"), + (a.gain, Side::Credit, "200.00"), + ], + ); +} + +#[test] +fn register_shows_a_disposed_asset_outside_the_totals() { + let (mut l, a, laptop, server) = register_fixture(); + l.set_asset_tag(server, "WL-0002").unwrap(); + dispose_server(&mut l, &a, server); + let r = fixed_asset_register(&l, date(2026, 6, 30)); + assert_eq!(r.rows.len(), 2, "a disposed asset still has a row"); + let s = r.rows.iter().find(|x| x.asset_id == server).unwrap(); + assert_eq!(s.disposed, Some((date(2026, 6, 30), d("2000.00")))); + assert_eq!(s.accumulated, Decimal::ZERO); + assert_eq!(s.book_value, Decimal::ZERO); + assert_eq!(s.tag.as_deref(), Some("WL-0002")); + assert_eq!(s.cost, d("2400.00")); + let lp = r.rows.iter().find(|x| x.asset_id == laptop).unwrap(); + assert_eq!(lp.accumulated, d("6000.00")); + assert_eq!(r.total_cost, d("12000.00")); + assert_eq!(r.total_accumulated, d("6000.00")); + assert_eq!(r.total_book_value, d("6000.00")); + assert_eq!(r.ledger_cost, d("12000.00")); + assert_eq!(r.ledger_accumulated, d("6000.00")); + assert_eq!(r.unposted_depreciation(), Decimal::ZERO); + assert_eq!(r.unregistered_cost(), Decimal::ZERO); + assert_totals_are_sums(&r); +} + +/// Before its disposal date, a later-disposed asset is still held. +#[test] +fn register_before_the_disposal_date_shows_the_asset_held() { + let (mut l, a, _, server) = register_fixture(); + dispose_server(&mut l, &a, server); + let r = fixed_asset_register(&l, date(2026, 5, 31)); + let s = r.rows.iter().find(|x| x.asset_id == server).unwrap(); + assert_eq!(s.disposed, None); + assert_eq!(s.accumulated, d("500.00")); + assert_eq!(s.book_value, d("1900.00")); + assert_eq!(r.total_cost, d("14400.00")); + assert_eq!(r.total_accumulated, d("5500.00")); + assert_eq!(r.ledger_cost, d("14400.00")); + assert_eq!(r.ledger_accumulated, d("5500.00")); + assert_eq!(r.unposted_depreciation(), Decimal::ZERO); + assert_totals_are_sums(&r); +} + +/// INVARIANT 4 with separate account pairs: each pair's GL balance counts. +#[test] +fn register_sums_the_gl_over_distinct_asset_accounts() { + let (mut l, a, _, _) = register_fixture(); + buy(&mut l, &a, a.vehicles, date(2026, 1, 1), "36000.00"); + l.register_asset(NewAsset { + description: "Van".to_string(), + purchase_date: date(2026, 1, 1), + cost: usd("36000.00"), + salvage_value: usd("6000.00"), + useful_life_months: 60, + cost_account: a.vehicles, + accum_depr_account: a.vehicles_accum, + expense_account: a.depr_expense, + }) + .unwrap(); + // (36,000 − 6,000) / 60 = 500/month; Jan–Mar posted. + for m in 1..=3 { + depreciate(&mut l, &a, a.vehicles_accum, month_end(m), "500.00"); + } + let r = fixed_asset_register(&l, date(2026, 3, 31)); + assert_eq!(r.rows[2].salvage, d("6000.00")); + assert_eq!(r.rows[2].accumulated, d("1500.00")); + assert_eq!(r.rows[2].book_value, d("34500.00")); + assert_eq!(r.total_cost, d("50400.00")); + assert_eq!(r.total_accumulated, d("4800.00")); + assert_eq!(r.total_book_value, d("45600.00")); + assert_eq!(r.ledger_cost, d("50400.00")); + assert_eq!(r.ledger_accumulated, d("4800.00")); + assert_eq!(r.unposted_depreciation(), Decimal::ZERO); + assert_eq!(r.unregistered_cost(), Decimal::ZERO); + assert_totals_are_sums(&r); +} + +/// Accounts no registered asset uses don't count in the GL figures. +#[test] +fn register_ignores_accounts_no_asset_uses() { + let (mut l, a, _, _) = register_fixture(); + // Vehicles bought but never registered under 1520: not one of the + // registered assets' accounts. + buy(&mut l, &a, a.vehicles, date(2026, 1, 1), "9000.00"); + let r = fixed_asset_register(&l, date(2026, 3, 31)); + assert_eq!(r.ledger_cost, d("14400.00")); + assert_eq!(r.unregistered_cost(), Decimal::ZERO); +} + +/// An empty ledger has an empty register that agrees with the books. +#[test] +fn register_of_no_assets_is_empty() { + let (l, _) = fixture(); + let r = fixed_asset_register(&l, date(2026, 12, 31)); + assert!(r.rows.is_empty()); + assert_eq!(r.ledger_cost, Decimal::ZERO); + assert_eq!(r.ledger_accumulated, Decimal::ZERO); + assert_eq!(r.unposted_depreciation(), Decimal::ZERO); + assert_eq!(r.unregistered_cost(), Decimal::ZERO); +} diff --git a/crates/ledger-core/tests/spec_assets_payroll.rs b/crates/ledger-core/tests/spec_assets_payroll.rs index f8cd4b0..87b18ba 100644 --- a/crates/ledger-core/tests/spec_assets_payroll.rs +++ b/crates/ledger-core/tests/spec_assets_payroll.rs @@ -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, } } diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 7034f8e..6b9e60e 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -21,6 +21,7 @@ pub struct PageState { pub transactions: pages::transactions::State, pub reports: pages::reports::State, pub accounts: pages::accounts::State, + pub assets: pages::assets::State, pub documents: pages::documents::State, pub journal: pages::journal::State, } @@ -121,6 +122,7 @@ impl App { "transactions" => Route::Transactions, "reports" => Route::Reports(ReportKind::ProfitAndLoss), "accounts" => Route::Accounts, + "assets" => Route::Assets, "documents" => Route::Documents, "company" => Route::Company, "journal" => Route::Journal, @@ -170,6 +172,7 @@ impl App { "vendor_1099" => ReportKind::Vendor1099, "ar_aging" => ReportKind::ArAging, "ap_aging" => ReportKind::ApAging, + "fixed_asset_register" => ReportKind::FixedAssetRegister, _ => return, }; if let (s, e) = (date(&start), date(&end)) @@ -182,6 +185,12 @@ impl App { Route::Reports(kind) } T::Document { attachment_id } => Route::Document(attachment_id), + T::Asset { asset_id } => { + if b.ledger.get_asset(asset_id).is_none() { + return; + } + Route::Asset(asset_id) + } T::Search { query } => { let st = &mut self.state.transactions; st.query = query; diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index 6a4f687..ca69283 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -1,7 +1,8 @@ //! What the books need: drafts left unposted, missing receipts, schedules //! and recurring entries due, open past periods, uncategorized tax lines, //! 1099 vendors without a W-9, overdue bills and invoices, money owed to -//! owners, and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that +//! owners, fixed asset depreciation not posted and assets without a tag, +//! and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that //! asks the agent to fix it (the GUI never changes the books itself). use crate::books::{Attention, Books, Severity}; @@ -31,7 +32,11 @@ use rust_decimal::Decimal; /// invoices (Problem, one each, with the total); money owed to /// owners (Info, per account with a balance); the database differing /// from the journal or unreadable, and damaged or missing stored -/// files (Problem). +/// files (Problem); depreciation due through the last finished month +/// and not posted (Books::depreciation_due amount > 0: Todo, one +/// item); the asset register and the books' asset cost accounts +/// disagreeing today (unregistered_cost != 0: Problem); an active +/// asset in service with no tag (Todo, one per asset). /// 3. Nothing else: books with none of those yield no items. /// 4. Every item's prompt starts "In wanderbooks," or names /// wanderbooks, and its amounts are the books' exact amounts. @@ -306,6 +311,79 @@ pub fn find(b: &Books) -> Vec { } } + let due = b.depreciation_due(today); + if due.amount > Decimal::ZERO { + let months = if due.periods.is_empty() { + "every month not yet posted".to_string() + } else { + due.periods.join(", ") + }; + out.push(Attention { + severity: Severity::Todo, + title: format!("Depreciation not posted through {}", fmt::date(due.through)), + detail: format!( + "{} scheduled and not yet in the books ({}).", + fmt::dollars(due.amount), + months + ), + target: Some(Route::Assets), + prompt: format!( + "In wanderbooks, run books.run_depreciation for {months} (depreciation due through {} and not posted: {}), then show me the fixed asset register.", + due.through, + fmt::dollars(due.amount) + ), + }); + } + let register = b.asset_register(today); + let unregistered = register.unregistered_cost(); + if !unregistered.is_zero() { + let (title, why) = if unregistered > Decimal::ZERO { + ( + format!( + "{} of equipment not in the asset register", + fmt::dollars(unregistered) + ), + "The asset cost accounts hold more than the registered assets cost: something bought was never registered.", + ) + } else { + ( + format!( + "{} of registered assets not in the books", + fmt::dollars(-unregistered) + ), + "Registered assets cost more than their cost accounts hold: a purchase entry may be missing.", + ) + }; + out.push(Attention { + severity: Severity::Problem, + title, + detail: why.into(), + target: Some(Route::Assets), + prompt: format!( + "In wanderbooks, the fixed asset register and the asset cost accounts differ by {} today. Find what's behind it (books.list_assets and the cost accounts' registers) and tell me how to fix it.", + fmt::dollars(unregistered) + ), + }); + } + for a in b.ledger.registered_assets().filter(|a| { + a.tag.is_none() && a.status == ledger_core::AssetStatus::Active && a.purchase_date <= today + }) { + out.push(Attention { + severity: Severity::Todo, + title: format!("No tag: {}", a.description), + detail: format!( + "Asset {}, in service {}. A tag is the label on the thing itself.", + a.id, + fmt::date(a.purchase_date) + ), + target: Some(Route::Asset(a.id)), + prompt: format!( + "In wanderbooks, label asset {} (\"{}\") with books.label_asset: give it the next generated tag unless I have my own, and its serial number if I can find it. Then print its label with books.asset_labels.", + a.id, a.description + ), + }); + } + use ledger_store::DiskCheck; let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string(); match &b.integrity.disk { diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 4afd7b7..4664d11 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -7,11 +7,14 @@ use chrono::{Datelike, NaiveDate}; use ledger_config::BooksSettings; use ledger_core::analysis::{balance_history, balance_series}; use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period}; -use ledger_core::{AccountId, AccountType, EntryId, EntryStatus, Ledger, Side}; +use ledger_core::{ + AccountId, AccountType, AssetId, AssetRegister, AssetStatus, EntryId, EntryStatus, Ledger, + Side, fixed_asset_register, +}; use ledger_store::{Attachment, JournalLine, Snapshot}; use rust_decimal::Decimal; use serde_json::Value; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; /// One entry, indexed for listing and detail. @@ -45,6 +48,63 @@ pub struct EntryInfo { pub haystack: String, } +/// One fixed asset's trail through the journal and the entries. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AssetTrail { + /// Depreciation the books carry, by month ("YYYY-MM"): the entry and + /// its amount, from `asset.depreciation_posted`. + pub depreciation: BTreeMap, + /// The disposal entry, from `asset.disposed`. + pub disposal_entry: Option, + /// The entry that put it on the books, when one can be told: posted, + /// debiting its cost account exactly its cost, dated its in-service + /// date (else the nearest such entry within 31 days), and not already + /// claimed by an earlier asset. + pub purchase_entry: Option, + /// Indexes into [`Books::journal`] of every `asset.*` event about it. + pub events: Vec, +} + +impl AssetTrail { + /// Every entry that touches the asset: purchase, depreciation (by + /// month), disposal. + pub fn entries(&self) -> Vec { + let mut out: Vec = self.purchase_entry.into_iter().collect(); + out.extend(self.depreciation.values().map(|(e, _)| *e)); + out.extend(self.disposal_entry); + out + } + + /// Depreciation the books carry for it, summed exactly. + pub fn posted(&self) -> Decimal { + self.depreciation.values().map(|(_, a)| *a).sum() + } +} + +/// Depreciation due and not yet in the books. +/// +/// ```text +/// GOAL: say when monthly depreciation hasn't been run, without crying +/// wolf over the month still under way. +/// SOURCE: docs/spec/rules.md B-15 (monthly straight-line depreciation); +/// CONVENTION (a month's depreciation is posted when the month +/// ends: ledger-store dates it the month's last day). +/// INVARIANTS: +/// 1. through is as_of when as_of is a month's last day, else the last +/// day of the month before as_of's. +/// 2. amount is fixed_asset_register(ledger, through) +/// .unposted_depreciation(), exactly. +/// 3. periods: the months ("YYYY-MM", ascending, distinct) that some +/// asset held on `through` has in its schedule through `through` +/// and no depreciation posted for. +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct DepreciationDue { + pub through: NaiveDate, + pub amount: Decimal, + pub periods: Vec, +} + /// Something the books need, with how to fix it. #[derive(Clone, Debug)] pub struct Attention { @@ -90,6 +150,10 @@ pub struct Books { pub attention: Vec, pub kpis: Kpis, pub integrity: Integrity, + /// Each registered asset's trail, by asset id. + pub asset_trails: HashMap, + /// The asset each depreciation, disposal, or purchase entry is about. + pub entry_assets: HashMap, } /// Whether what's on disk is what the journal says. @@ -319,6 +383,13 @@ impl Books { } entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.id.cmp(&a.id))); let entry_index = entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect(); + let asset_trails = asset_trails(&ledger, &journal); + let mut entry_assets = HashMap::new(); + for (&a, t) in &asset_trails { + for e in t.entries() { + entry_assets.insert(e, a); + } + } let mut books = Books { ledger, settings, @@ -340,6 +411,8 @@ impl Books { attention: Vec::new(), kpis: Kpis::default(), integrity, + asset_trails, + entry_assets, }; books.kpis = books.compute_kpis(); books.attention = crate::attention::find(&books); @@ -483,6 +556,61 @@ impl Books { } } + /// The fixed asset register on `as_of`, exactly as ledger-core + /// computes it. + pub fn asset_register(&self, as_of: NaiveDate) -> AssetRegister { + fixed_asset_register(&self.ledger, as_of) + } + + /// An asset's trail (empty for an unknown asset). + pub fn asset_trail(&self, id: AssetId) -> AssetTrail { + self.asset_trails.get(&id).cloned().unwrap_or_default() + } + + /// "WL-0001 MacBook Pro" (or just the description, untagged). + pub fn asset_label(&self, id: AssetId) -> String { + match self.ledger.get_asset(id) { + Some(a) => match &a.tag { + Some(t) => format!("{t} {}", a.description), + None => a.description.clone(), + }, + None => format!("asset {id}"), + } + } + + /// Depreciation due through the last finished month on or before + /// `as_of` and not yet posted (see [`DepreciationDue`]). + pub fn depreciation_due(&self, as_of: NaiveDate) -> DepreciationDue { + let through = if as_of.succ_opt().is_some_and(|d| d.day() == 1) { + as_of + } else { + NaiveDate::from_ymd_opt(as_of.year(), as_of.month(), 1).expect("valid") + - chrono::Days::new(1) + }; + let amount = self.asset_register(through).unposted_depreciation(); + let mut periods = std::collections::BTreeSet::new(); + for a in self + .ledger + .registered_assets() + .filter(|a| a.purchase_date <= through) + { + if matches!(a.status, AssetStatus::Disposed { date, .. } if date <= through) { + continue; + } + let posted = self.asset_trails.get(&a.id); + for l in ledger_core::depreciation_schedule(a, through) { + if !posted.is_some_and(|t| t.depreciation.contains_key(&l.period)) { + periods.insert(l.period); + } + } + } + DepreciationDue { + through, + amount, + periods: periods.into_iter().collect(), + } + } + /// Blob store path for an attachment. pub fn blob_root(&self) -> PathBuf { self.data_dir.join("blobs") @@ -520,6 +648,92 @@ fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap out } +/// Every registered asset's trail, from the journal (depreciation, +/// disposal, its own events) and the entries (the purchase). +fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap { + let mut out: HashMap = ledger + .registered_assets() + .map(|a| (a.id, AssetTrail::default())) + .collect(); + let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64()); + for (i, line) in journal.iter().enumerate() { + let p = &line.payload; + let asset = match line.line_type.as_str() { + "asset.registered" => u(p, "id"), + t if t.starts_with("asset.") => u(p, "asset_id"), + _ => None, + }; + let Some(t) = asset.and_then(|a| out.get_mut(&a)) else { + continue; + }; + t.events.push(i); + match line.line_type.as_str() { + "asset.depreciation_posted" => { + if let (Some(period), Some(entry), Some(amount)) = ( + p.get("period").and_then(|v| v.as_str()), + u(p, "entry_id"), + p.get("amount") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()), + ) { + t.depreciation.insert(period.to_string(), (entry, amount)); + } + } + "asset.disposed" => t.disposal_entry = u(p, "entry_id"), + _ => {} + } + } + // The purchase: an entry that debits the cost account exactly the cost, + // on the in-service date or as near it as can be (within 31 days). + // A linked acquisition is the purchase; otherwise match it. + for a in ledger.registered_assets() { + if let (Some(id), Some(t)) = (a.acquired_by, out.get_mut(&a.id)) { + t.purchase_entry = Some(id); + } + } + let reversed: HashSet = (1..ledger.next_entry_id()) + .filter_map(|id| ledger.get_entry(id).and_then(|e| e.reversal_of())) + .collect(); + let mut claimed: HashSet = out.values().flat_map(|t| t.entries()).collect(); + for a in ledger + .registered_assets() + .filter(|a| a.acquired_by.is_none()) + { + let mut best: Option<(i64, EntryId)> = None; + for id in 1..ledger.next_entry_id() { + let Some(e) = ledger.get_entry(id) else { + continue; + }; + if e.status() != EntryStatus::Posted + || e.reversal_of().is_some() + || reversed.contains(&id) + || claimed.contains(&id) + { + continue; + } + let days = (e.date() - a.purchase_date).num_days().abs(); + if days > 31 { + continue; + } + let buys = e.lines().iter().any(|l| { + l.account == a.cost_account + && l.side == Side::Debit + && l.amount.amount == a.cost.amount + }); + if buys && best.is_none_or(|(d, _)| days < d) { + best = Some((days, id)); + } + } + if let Some((_, id)) = best { + claimed.insert(id); + if let Some(t) = out.get_mut(&a.id) { + t.purchase_entry = Some(id); + } + } + } + out +} + /// Entry ids a journal line refers to. pub fn entry_refs(line: &JournalLine) -> Vec { let p = &line.payload; diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index c5a1f4b..c705bd7 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -376,6 +376,9 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { accum_depr_account: accum, expense_account: depreciation, })?; + // The label on the laptop, and its serial number. + s.set_asset_tag(asset, "JS-0001")?; + s.set_asset_serial(asset, Some("C02FK3XJQ6L4"))?; // Prepaid insurance, spread over a year. let ins_date = day(start + Months::new(2), 15); diff --git a/crates/ledger-gui/src/nav.rs b/crates/ledger-gui/src/nav.rs index 996567f..ffafeea 100644 --- a/crates/ledger-gui/src/nav.rs +++ b/crates/ledger-gui/src/nav.rs @@ -1,6 +1,6 @@ //! Where you are and how you got there: a browser-style history. -use ledger_core::{AccountId, EntryId}; +use ledger_core::{AccountId, AssetId, EntryId}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ReportKind { @@ -14,16 +14,18 @@ pub enum ReportKind { ArAging, ApAging, Vendor1099, + FixedAssetRegister, } impl ReportKind { - pub const ALL: [ReportKind; 10] = [ + pub const ALL: [ReportKind; 11] = [ ReportKind::ProfitAndLoss, ReportKind::BalanceSheet, ReportKind::CashFlow, ReportKind::MembersEquity, ReportKind::TrialBalance, ReportKind::GeneralLedger, + ReportKind::FixedAssetRegister, ReportKind::TaxSummary, ReportKind::Vendor1099, ReportKind::ArAging, @@ -42,6 +44,7 @@ impl ReportKind { ReportKind::ArAging => "Receivables aging", ReportKind::ApAging => "Payables aging", ReportKind::Vendor1099 => "1099 worksheet", + ReportKind::FixedAssetRegister => "Fixed asset register", } } } @@ -56,6 +59,10 @@ pub enum Route { Reports(ReportKind), Accounts, Account(AccountId), + /// The fixed asset register. + Assets, + /// One fixed asset. + Asset(AssetId), Documents, Document(u64), Company, @@ -73,6 +80,7 @@ impl Route { Route::Transactions | Route::Entry(_) => Section::Transactions, Route::Reports(_) => Section::Reports, Route::Accounts | Route::Account(_) => Section::Accounts, + Route::Assets | Route::Asset(_) => Section::Assets, Route::Documents | Route::Document(_) => Section::Documents, Route::Company => Section::Company, Route::Journal | Route::JournalEvent(_) => Section::Journal, @@ -88,19 +96,21 @@ pub enum Section { Transactions, Reports, Accounts, + Assets, Documents, Company, Journal, } impl Section { - pub const ALL: [Section; 9] = [ + pub const ALL: [Section; 10] = [ Section::Overview, Section::Cash, Section::Profit, Section::Transactions, Section::Reports, Section::Accounts, + Section::Assets, Section::Documents, Section::Company, Section::Journal, @@ -114,6 +124,7 @@ impl Section { Section::Transactions => "Transactions", Section::Reports => "Reports", Section::Accounts => "Accounts", + Section::Assets => "Assets", Section::Documents => "Documents", Section::Company => "Company", Section::Journal => "Journal", @@ -129,6 +140,7 @@ impl Section { Section::Transactions => i::LIST_BULLETS, Section::Reports => i::FILE_TEXT, Section::Accounts => i::TREE_STRUCTURE, + Section::Assets => i::PACKAGE, Section::Documents => i::FILES, Section::Company => i::BUILDINGS, Section::Journal => i::SCROLL, @@ -143,6 +155,7 @@ impl Section { Section::Transactions => Route::Transactions, Section::Reports => Route::Reports(ReportKind::ProfitAndLoss), Section::Accounts => Route::Accounts, + Section::Assets => Route::Assets, Section::Documents => Route::Documents, Section::Company => Route::Company, Section::Journal => Route::Journal, diff --git a/crates/ledger-gui/src/pages/assets.rs b/crates/ledger-gui/src/pages/assets.rs new file mode 100644 index 0000000..05dd03f --- /dev/null +++ b/crates/ledger-gui/src/pages/assets.rs @@ -0,0 +1,1059 @@ +//! Fixed assets: the register (everything the company owns as a fixed +//! asset, reconciled to the books) and one asset completely (its label, +//! its schedule month by month, the entries and documents behind it). +//! Every number comes from `ledger-core` (`fixed_asset_register`, +//! `depreciation_schedule`); what is posted comes from the journal. + +use super::page; +use crate::app::Shell; +use crate::books::Books; +use crate::fmt; +use crate::nav::{ReportKind, Route}; +use crate::theme::{self, pal}; +use crate::widgets; +use chrono::{Datelike, NaiveDate}; +use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::{AssetId, AssetRegister, AssetStatus}; +use rust_decimal::Decimal; + +/// The register's date (None: today), kept across navigation. +#[derive(Default)] +pub struct State { + pub as_of: Option, +} + +/// Something the register says about the books. +#[derive(Clone, Debug, PartialEq)] +pub struct Notice { + /// A problem (warning colors) or just so (muted). + pub problem: bool, + pub text: String, +} + +/// What the register says about the books on its date: depreciation not +/// yet posted, cost the register doesn't account for, or that they agree. +/// +/// ```text +/// GOAL: the register and the general ledger agree, and when they don't, +/// say how and who fixes it. +/// SOURCE: ledger_core::fixed_asset_register (unposted_depreciation, +/// unregistered_cost); Books::depreciation_due (a month's +/// depreciation is due when the month ends). +/// INVARIANTS: +/// 1. unposted_depreciation() > 0: a problem naming the amount due +/// through the last finished month (when any) and asking the +/// bookkeeper to run depreciation; the rest of it (the month under +/// way) a muted note that it posts when the month ends. +/// 2. unposted_depreciation() < 0, unregistered_cost() != 0: a problem +/// each, with the exact amount. +/// 3. Both zero: one note that the register agrees with the books. +/// ``` +pub fn notices(b: &Books, r: &AssetRegister) -> Vec { + let mut out = Vec::new(); + let unposted = r.unposted_depreciation(); + if unposted > Decimal::ZERO { + let due = b.depreciation_due(r.as_of); + let due_amount = due.amount.clamp(Decimal::ZERO, unposted); + if due_amount > Decimal::ZERO { + out.push(Notice { + problem: true, + text: format!( + "Depreciation not yet posted through {}: {} — ask the bookkeeper to run depreciation{}.", + fmt::date(due.through), + fmt::dollars(due_amount), + if due.periods.is_empty() { + String::new() + } else { + format!(" for {}", due.periods.join(", ")) + } + ), + }); + } + let accruing = unposted - due_amount; + if accruing > Decimal::ZERO { + out.push(Notice { + problem: false, + text: format!( + "{} more accrues in {} through {}; it's posted when the month ends.", + fmt::dollars(accruing), + r.as_of.format("%B"), + fmt::date(r.as_of) + ), + }); + } + } else if unposted < Decimal::ZERO { + out.push(Notice { + problem: true, + text: format!( + "The books carry {} more accumulated depreciation than the schedules through {} — ask the bookkeeper to check the depreciation entries.", + fmt::dollars(-unposted), + fmt::date(r.as_of) + ), + }); + } + let unregistered = r.unregistered_cost(); + if unregistered > Decimal::ZERO { + out.push(Notice { + problem: true, + text: format!( + "{} in the asset cost accounts isn't in the register — ask the bookkeeper to register it with books.register_asset.", + fmt::dollars(unregistered) + ), + }); + } else if unregistered < Decimal::ZERO { + out.push(Notice { + problem: true, + text: format!( + "{} of registered cost isn't in the books' asset cost accounts — ask the bookkeeper to check the purchase entries.", + fmt::dollars(-unregistered) + ), + }); + } + if unposted.is_zero() && unregistered.is_zero() { + out.push(Notice { + problem: false, + text: format!( + "{} The register agrees with the books: cost and accumulated depreciation match the general ledger to the cent.", + icon::CHECK + ), + }); + } + out +} + +/// The status column: "Active", or "Disposed Mar 3, 2026 for $400.00". +pub fn status_text(disposed: Option<(NaiveDate, Decimal)>) -> String { + match disposed { + None => "Active".into(), + Some((d, proceeds)) => format!("Disposed {} for {}", fmt::date(d), fmt::dollars(proceeds)), + } +} + +fn notice_box(ui: &mut Ui, n: &Notice) { + let p = pal(ui.ctx()); + let (fg, bg) = if n.problem { + (p.warning, p.warning_soft) + } else { + (p.muted, p.hover) + }; + egui::Frame::new() + .fill(bg) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal_top(|ui| { + if n.problem { + ui.label(RichText::new(icon::WARNING).color(fg)); + } + ui.add( + egui::Label::new(RichText::new(&n.text).font(theme::regular(13.0)).color(fg)) + .wrap(), + ); + }); + }); + ui.add_space(6.0); +} + +/// One painted table row: `cells` are (text, width, right-aligned, +/// color); the cell whose width is 0 takes what's left. A second line +/// (`sub`) goes under the flexible cell. +fn row( + ui: &mut Ui, + w: f32, + cells: &[(String, f32, bool, Color32)], + sub: Option<(&str, Color32)>, + strong: bool, + click: bool, +) -> egui::Response { + let p = pal(ui.ctx()); + let h = if sub.is_some() { 40.0 } else { 28.0 }; + let (r, resp) = ui.allocate_exact_size( + vec2(w, h), + if click { + Sense::click() + } else { + Sense::hover() + }, + ); + if click && resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + let fixed: f32 = cells.iter().map(|c| c.1).sum(); + let flex = (w - fixed).max(40.0); + let painter = ui.painter_at(r); + let font = if strong { + theme::semibold(13.0) + } else { + theme::regular(13.0) + }; + let y = if sub.is_some() { + r.center().y - 7.0 + } else { + r.center().y + }; + let flex_at = cells.iter().position(|c| c.1 == 0.0); + let mut x = r.left(); + for (i, (text, cw, right, color)) in cells.iter().enumerate() { + let cw = if *cw == 0.0 { flex } else { *cw }; + if *right { + painter.text( + egui::pos2(x + cw - 6.0, y), + egui::Align2::RIGHT_CENTER, + text, + font.clone(), + *color, + ); + } else { + widgets::clipped_text( + &painter, + egui::pos2(x + 6.0, y), + text, + font.clone(), + *color, + cw - 12.0, + ); + } + if flex_at == Some(i) + && let Some((s, c)) = sub + { + widgets::clipped_text( + &painter, + egui::pos2(x + 6.0, r.center().y + 9.0), + s, + theme::regular(11.5), + c, + cw - 12.0, + ); + } + x += cw; + } + resp +} + +fn date_controls(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) -> NaiveDate { + let p = pal(ui.ctx()); + let as_of = shell.state.assets.as_of.unwrap_or(b.today); + ui.horizontal_wrapped(|ui| { + ui.label( + RichText::new(format!("{} As of", icon::CALENDAR_BLANK)).font(theme::semibold(13.5)), + ); + let mut d = + jiff::civil::Date::new(as_of.year() as i16, as_of.month() as i8, as_of.day() as i8) + .expect("valid date"); + ui.add( + egui_extras::DatePickerButton::new(&mut d) + .id_salt("assets_as_of") + .calendar_week(false), + ); + let picked = NaiveDate::from_ymd_opt(i32::from(d.year()), d.month() as u32, d.day() as u32) + .expect("valid date"); + if picked != as_of { + shell.state.assets.as_of = Some(picked); + } + if shell.state.assets.as_of.is_some() && ui.button("Today").clicked() { + shell.state.assets.as_of = None; + } + if ui + .button(format!("{} As a report", icon::FILE_TEXT)) + .on_hover_text( + "The same register on the Reports page, to save as a PDF or copy for a spreadsheet", + ) + .clicked() + { + shell.state.reports.range = crate::pages::reports::Range::Custom( + b.kpis.first_entry.unwrap_or(as_of).min(as_of), + as_of, + ); + shell.go(Route::Reports(ReportKind::FixedAssetRegister)); + } + ui.label( + RichText::new(format!("{} basis", b.settings.company.basis)) + .font(theme::regular(12.5)) + .color(p.muted), + ); + }); + shell.state.assets.as_of.unwrap_or(b.today) +} + +/// The fixed asset register page. +pub fn show_register(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + page(ui, "assets", |ui| { + widgets::page_header( + ui, + "Fixed assets", + "Everything the company owns as a fixed asset: its label, what it cost, how much has been depreciated, and what it's worth on the books, reconciled to the general ledger.", + ); + if b.ledger.registered_assets().next().is_none() { + widgets::card(ui, |ui| { + widgets::empty( + ui, + icon::PACKAGE, + "No fixed assets", + "Equipment the company capitalizes appears here once the bookkeeper registers it (books.register_asset).", + ) + }); + return; + } + let as_of = date_controls(ui, b, shell); + ui.add_space(12.0); + let r = b.asset_register(as_of); + super::grid(ui, 3, 200.0, 3, |i, ui| { + let (label, value, how) = match i { + 0 => ( + "Cost", + r.total_cost, + "What the assets still held cost, together.", + ), + 1 => ( + "Accumulated depreciation", + r.total_accumulated, + "Their depreciation by schedule through the date.", + ), + _ => ( + "Book value", + r.total_book_value, + "Cost less accumulated depreciation.", + ), + }; + widgets::kpi( + ui, + widgets::Kpi { + label, + value: fmt::dollars(value), + sub: None, + spark: None, + how, + }, + ); + }); + for n in notices(b, &r) { + notice_box(ui, &n); + } + ui.add_space(8.0); + let mut go = None; + widgets::card(ui, |ui| { + if r.rows.is_empty() { + widgets::empty( + ui, + icon::PACKAGE, + "Nothing in service yet", + &format!("No asset was in service by {}.", fmt::date(as_of)), + ); + return; + } + let amt = widgets::AMOUNT_MIN + 24.0; + let fixed = 110.0 + 104.0 + amt * 3.0 + 190.0; + let w = ui.available_width().max(fixed + 200.0); + widgets::min_width(ui, "asset_register", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Tag", 110.0, false), + head("Description", 0.0, false), + head("In service", 104.0, false), + head("Cost", amt, true), + head("Accumulated", amt, true), + head("Book value", amt, true), + head("Status", 190.0, false), + ], + None, + true, + false, + ); + widgets::rule(ui); + for x in &r.rows { + let disposed = x.disposed.is_some(); + let text = if disposed { p.muted } else { p.text }; + let money = |d: Decimal| { + if disposed { + String::new() + } else { + fmt::money(d) + } + }; + let sub = x.serial.as_ref().map(|s| format!("Serial {s}")); + let resp = row( + ui, + w, + &[ + ( + x.tag.clone().unwrap_or_else(|| "no tag".into()), + 110.0, + false, + if x.tag.is_some() { text } else { p.warning }, + ), + (x.description.clone(), 0.0, false, text), + (fmt::date(x.in_service), 104.0, false, text), + (money(x.cost), amt, true, text), + (money(x.accumulated), amt, true, text), + (money(x.book_value), amt, true, text), + (status_text(x.disposed), 190.0, false, p.muted), + ], + sub.as_deref().map(|s| (s, p.faint)), + false, + true, + ); + let resp = resp.on_hover_text(if disposed { + format!( + "Open this asset (it cost {}; it left the books when disposed)", + fmt::dollars(x.cost) + ) + } else { + "Open this asset".to_string() + }); + resp.context_menu(|ui| { + if let Some(t) = &x.tag { + widgets::copy_item(ui, "Copy tag", t); + } + if let Some(s) = &x.serial { + widgets::copy_item(ui, "Copy serial number", s); + } + widgets::copy_item(ui, "Copy book value", &x.book_value.to_string()); + }); + if resp.clicked() { + go = Some(x.asset_id); + } + } + widgets::rule(ui); + row( + ui, + w, + &[ + ("Total held".into(), 110.0, false, p.text), + (String::new(), 0.0, false, p.text), + (String::new(), 104.0, false, p.text), + (fmt::money(r.total_cost), amt, true, p.text), + (fmt::money(r.total_accumulated), amt, true, p.text), + (fmt::money(r.total_book_value), amt, true, p.text), + (String::new(), 190.0, false, p.text), + ], + None, + true, + false, + ); + }); + }); + ui.add_space(14.0); + reconciliation(ui, b, &r, shell); + if let Some(id) = go { + shell.go(Route::Asset(id)); + } + }); +} + +/// Register against the general ledger, line by line. +fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let held: Vec<_> = r + .rows + .iter() + .filter_map(|x| b.ledger.get_asset(x.asset_id)) + .collect(); + let mut cost_accts: Vec = held.iter().map(|a| a.cost_account).collect(); + cost_accts.sort_unstable(); + cost_accts.dedup(); + let mut accum_accts: Vec = held.iter().map(|a| a.accum_depr_account).collect(); + accum_accts.sort_unstable(); + accum_accts.dedup(); + let mut go = None; + widgets::titled_card( + ui, + "Reconciliation to the books", + |_| {}, + |ui| { + let amt = widgets::AMOUNT_MIN + 40.0; + let w = ui.available_width().max(260.0 + amt); + widgets::min_width(ui, "asset_recon", w, |ui| { + let line = |ui: &mut Ui, label: &str, v: Decimal, strong: bool, color| { + let resp = row( + ui, + w, + &[ + (label.to_string(), 0.0, false, p.text), + (fmt::money(v), amt, true, color), + ], + None, + strong, + false, + ); + resp.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string())); + }; + let diff_color = |d: Decimal| if d.is_zero() { p.positive } else { p.warning }; + line(ui, "Cost in the register", r.total_cost, false, p.text); + line(ui, "Cost in the books", r.ledger_cost, false, p.text); + let u = r.unregistered_cost(); + line( + ui, + "Difference (not in the register)", + u, + true, + diff_color(u), + ); + ui.add_space(8.0); + line( + ui, + "Accumulated depreciation by schedule", + r.total_accumulated, + false, + p.text, + ); + line( + ui, + "Accumulated depreciation in the books", + r.ledger_accumulated, + false, + p.text, + ); + let d = r.unposted_depreciation(); + // Only the month under way: not a problem, just not yet. + let accruing = + d > Decimal::ZERO && b.depreciation_due(r.as_of).amount <= Decimal::ZERO; + let color = if accruing { p.muted } else { diff_color(d) }; + line(ui, "Difference (not yet posted)", d, true, color); + }); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + ui.label( + RichText::new("Accounts:") + .font(theme::regular(12.0)) + .color(p.muted), + ); + for id in cost_accts.iter().chain(&accum_accts) { + if widgets::link( + ui, + RichText::new(b.account_label(*id)) + .font(theme::regular(12.0)) + .color(p.accent), + ) + .clicked() + { + go = Some(*id); + } + } + }); + }, + ); + if let Some(id) = go { + shell.state.accounts.range = None; + shell.go(Route::Account(id)); + } +} + +/// How one month of an asset's schedule stands in the books. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MonthStatus { + /// Posted, by this entry. + Posted(ledger_core::EntryId), + /// The month has ended and nothing is posted for it. + Due, + /// The month under way. + ThisMonth, + /// A month still to come. + Scheduled, +} + +/// Each month of the asset's full schedule (to the end of its life, or +/// its disposal) with how it stands on `today`. +/// +/// ```text +/// INVARIANTS: +/// 1. The lines are ledger_core::depreciation_schedule(asset, the end of +/// time), unchanged and in order. +/// 2. A month with depreciation posted (asset.depreciation_posted) is +/// Posted(its entry); otherwise Due when it ended before today, +/// ThisMonth when today is in it, Scheduled after. +/// ``` +pub fn schedule(b: &Books, id: AssetId) -> Vec<(ledger_core::ScheduleLine, MonthStatus)> { + let Some(a) = b.ledger.get_asset(id) else { + return Vec::new(); + }; + let trail = b.asset_trail(id); + let this_month = b.today.format("%Y-%m").to_string(); + ledger_core::depreciation_schedule(a, NaiveDate::MAX) + .into_iter() + .map(|l| { + let st = match trail.depreciation.get(&l.period) { + Some((e, _)) => MonthStatus::Posted(*e), + None if l.period < this_month => MonthStatus::Due, + None if l.period == this_month => MonthStatus::ThisMonth, + None => MonthStatus::Scheduled, + }; + (l, st) + }) + .collect() +} + +/// One asset, completely. +pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let Some(a) = b.ledger.get_asset(id) else { + page(ui, "asset", |ui| { + widgets::empty( + ui, + icon::PACKAGE, + "No such asset", + "It isn't in these books.", + ); + }); + return; + }; + let trail = b.asset_trail(id); + let today = b + .asset_register(b.today) + .rows + .into_iter() + .find(|r| r.asset_id == id); + let mut go: Option = None; + page(ui, "asset", |ui| { + ui.horizontal(|ui| { + if widgets::link( + ui, + RichText::new(format!("{} Fixed assets", icon::ARROW_LEFT)) + .font(theme::regular(12.5)) + .color(p.muted), + ) + .clicked() + { + go = Some(Route::Assets); + } + widgets::copyable( + ui, + RichText::new(format!("· Asset {id}")) + .font(theme::regular(12.5)) + .color(p.muted), + &id.to_string(), + ); + }); + ui.add_space(4.0); + match &a.tag { + Some(tag) => { + widgets::copyable( + ui, + RichText::new(tag).font(theme::bold(30.0)).monospace(), + tag, + ); + } + None => { + widgets::pill(ui, "No tag yet", p.warning, p.warning_soft) + .on_hover_text("Ask the bookkeeper to label it (books.label_asset)."); + } + } + ui.label(RichText::new(&a.description).font(theme::bold(22.0))); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + match &a.status { + AssetStatus::Active => widgets::pill(ui, "Active", p.positive, p.positive_soft), + AssetStatus::Disposed { date, proceeds } => widgets::pill( + ui, + &status_text(Some((*date, proceeds.amount))), + p.muted, + p.hover, + ), + }; + widgets::pill(ui, "Straight line", p.muted, p.hover); + }); + ui.add_space(14.0); + + super::columns(ui, 820.0, &[1.0, 1.0], |col, ui| { + if col == 0 { + widgets::titled_card( + ui, + "Identity", + |_| {}, + |ui| { + facts(ui, |ui, k| { + k(ui, "Tag"); + match &a.tag { + Some(t) => { + widgets::copyable(ui, RichText::new(t).monospace(), t); + } + None => { + ui.label(RichText::new("none").color(p.warning)); + } + } + ui.end_row(); + k(ui, "Serial number"); + match &a.serial { + Some(s) => { + widgets::copyable(ui, RichText::new(s).monospace(), s); + } + None => { + ui.label(RichText::new("not recorded").color(p.muted)); + } + } + ui.end_row(); + k(ui, "Asset id"); + widgets::copyable(ui, RichText::new(id.to_string()), &id.to_string()); + ui.end_row(); + for (label, acct) in [ + ("Cost account", a.cost_account), + ("Accumulated depreciation", a.accum_depr_account), + ("Depreciation expense", a.expense_account), + ] { + k(ui, label); + if widgets::link_truncated( + ui, + RichText::new(b.account_label(acct)).color(p.accent), + ) + .clicked() + { + shell.state.accounts.range = None; + go = Some(Route::Account(acct)); + } + ui.end_row(); + } + }); + }, + ); + } else { + widgets::titled_card( + ui, + "Cost and depreciation", + |_| {}, + |ui| { + let end = a + .purchase_date + .checked_add_months(chrono::Months::new(a.useful_life_months)) + .and_then(|d| d.pred_opt()); + facts(ui, |ui, k| { + let money = |ui: &mut Ui, d: Decimal, strong: bool| { + let t = fmt::dollars(d); + let rt = if strong { + RichText::new(&t).font(theme::semibold(14.0)) + } else { + RichText::new(&t) + }; + widgets::copyable(ui, rt, &d.to_string()); + }; + k(ui, "In service"); + ui.label(fmt::date(a.purchase_date)); + ui.end_row(); + k(ui, "Cost"); + money(ui, a.cost.amount, false); + ui.end_row(); + k(ui, "Salvage value"); + money(ui, a.salvage_value.amount, false); + ui.end_row(); + k(ui, "Useful life"); + ui.label(match end { + Some(e) => format!( + "{} · through {}", + fmt::count(a.useful_life_months as usize, "month", "months"), + fmt::date(e) + ), + None => { + fmt::count(a.useful_life_months as usize, "month", "months") + } + }); + ui.end_row(); + if let Some(r) = &today { + k(ui, &format!("Accumulated, {}", fmt::date_short(b.today))); + money(ui, r.accumulated, false); + ui.end_row(); + k(ui, "Book value today"); + money(ui, r.book_value, true); + ui.end_row(); + } + k(ui, "Posted in the books"); + money(ui, trail.posted(), false); + ui.end_row(); + if let AssetStatus::Disposed { date, proceeds } = &a.status { + k(ui, "Disposed"); + ui.label(fmt::date(*date)); + ui.end_row(); + k(ui, "Proceeds"); + money(ui, proceeds.amount, false); + ui.end_row(); + } + }); + }, + ); + } + }); + + // The schedule. + let lines = schedule(b, id); + let due: Decimal = lines + .iter() + .filter(|(_, s)| *s == MonthStatus::Due) + .map(|(l, _)| l.amount) + .sum(); + if due > Decimal::ZERO { + notice_box( + ui, + &Notice { + problem: true, + text: format!( + "{} of depreciation for finished months isn't posted — ask the bookkeeper to run depreciation.", + fmt::dollars(due) + ), + }, + ); + } + widgets::titled_card( + ui, + "Depreciation schedule", + |ui| { + ui.label( + RichText::new(format!( + "{} · {} posted", + fmt::count(lines.len(), "month", "months"), + lines + .iter() + .filter(|(_, s)| matches!(s, MonthStatus::Posted(_))) + .count() + )) + .font(theme::regular(12.0)) + .color(p.muted), + ); + }, + |ui| { + if lines.is_empty() { + ui.label( + RichText::new("Nothing to depreciate: its salvage value is its cost.") + .color(p.muted), + ); + return; + } + let amt = widgets::AMOUNT_MIN + 16.0; + let w = ui.available_width().max(100.0 + amt * 3.0 + 200.0); + widgets::min_width(ui, "asset_schedule", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Month", 100.0, false), + head("Amount", amt, true), + head("Accumulated", amt, true), + head("Book value", amt, true), + head("", 24.0, false), + head("In the books", 0.0, false), + ], + None, + true, + false, + ); + widgets::rule(ui); + for (l, st) in &lines { + let month = + NaiveDate::parse_from_str(&format!("{}-01", l.period), "%Y-%m-%d") + .map(fmt::month) + .unwrap_or_else(|_| l.period.clone()); + let (status, color) = match st { + MonthStatus::Posted(e) => (format!("Posted · entry #{e}"), p.positive), + MonthStatus::Due => ("Not posted".to_string(), p.warning), + MonthStatus::ThisMonth => ("This month".to_string(), p.muted), + MonthStatus::Scheduled => ("Scheduled".to_string(), p.faint), + }; + let text = if matches!(st, MonthStatus::Scheduled) { + p.muted + } else { + p.text + }; + let resp = row( + ui, + w, + &[ + (month, 100.0, false, text), + (fmt::money(l.amount), amt, true, text), + (fmt::money(l.accumulated), amt, true, text), + (fmt::money(l.book_value), amt, true, text), + (String::new(), 24.0, false, text), + (status, 0.0, false, color), + ], + None, + false, + matches!(st, MonthStatus::Posted(_)), + ); + resp.context_menu(|ui| { + widgets::copy_item(ui, "Copy amount", &l.amount.to_string()); + widgets::copy_item(ui, "Copy book value", &l.book_value.to_string()); + }); + if let MonthStatus::Posted(e) = st + && resp.on_hover_text("Open the depreciation entry").clicked() + { + go = Some(Route::Entry(*e)); + } + } + }); + }, + ); + ui.add_space(14.0); + + // Entries and documents. + let entries = trail.entries(); + widgets::titled_card( + ui, + "Entries", + |_| {}, + |ui| { + if entries.is_empty() { + ui.label( + RichText::new("No entry in the books is about it yet.").color(p.muted), + ); + } + for e in &entries { + let Some(info) = b.entry(*e) else { + continue; + }; + let role = if Some(*e) == trail.purchase_entry { + "Purchase" + } else if Some(*e) == trail.disposal_entry { + "Disposal" + } else { + "Depreciation" + }; + ui.horizontal(|ui| { + ui.add_sized( + vec2(96.0, 18.0), + egui::Label::new( + RichText::new(role) + .font(theme::regular(12.5)) + .color(p.muted), + ), + ); + if widgets::link_truncated( + ui, + RichText::new(format!( + "{} · #{} {} · {}", + fmt::date(info.date), + info.id, + info.description, + fmt::dollars(info.amount) + )) + .font(theme::regular(13.0)), + ) + .clicked() + { + go = Some(Route::Entry(*e)); + } + }); + } + if trail.purchase_entry.is_some() { + ui.add_space(4.0); + ui.label( + RichText::new(if a.acquired_by.is_some() { + "The purchase is the entry linked to this asset." + } else { + "The purchase is matched by its cost account, amount, and date." + }) + .font(theme::regular(11.5)) + .color(p.faint), + ); + } + }, + ); + ui.add_space(14.0); + let docs: Vec<_> = b + .attachments + .iter() + .filter(|x| { + x.withdrawn_at.is_none() && x.entry_id.is_some_and(|e| entries.contains(&e)) + }) + .collect(); + widgets::titled_card( + ui, + "Receipts and documents", + |_| {}, + |ui| { + if docs.is_empty() { + ui.label( + RichText::new("None linked to its entries (depreciation needs none: the schedule is its evidence).") + .color(p.muted), + ); + } + for d in &docs { + ui.horizontal(|ui| { + ui.label( + RichText::new(if d.mime_type == "application/pdf" { + icon::FILE_PDF + } else { + icon::FILE_IMAGE + }) + .color(p.muted), + ); + if widgets::link_truncated( + ui, + RichText::new(format!( + "{} · {} · entry #{}", + d.file_name, + fmt::label(&d.category), + d.entry_id.unwrap_or(0) + )) + .font(theme::regular(13.0)), + ) + .clicked() + { + go = Some(Route::Document(d.id)); + } + }); + if let Some(desc) = &d.description { + ui.label( + RichText::new(desc) + .font(theme::regular(11.5)) + .color(p.faint), + ); + } + } + }, + ); + ui.add_space(14.0); + + // Its own events. + widgets::titled_card( + ui, + "History", + |_| {}, + |ui| { + for &i in &trail.events { + let line = &b.journal[i]; + let ev = super::journal::describe_any(b, line); + ui.horizontal(|ui| { + ui.label(RichText::new(ev.icon).color(p.muted)); + if widgets::link_truncated( + ui, + RichText::new(&ev.text).font(theme::regular(12.5)), + ) + .clicked() + { + go = Some(Route::JournalEvent(line.seq)); + } + ui.label( + RichText::new(format!( + "event {} · {}", + line.seq, + fmt::timestamp(&line.ts) + )) + .font(theme::regular(11.0)) + .color(p.faint), + ); + }); + } + }, + ); + }); + if let Some(r) = go { + shell.go(r); + } +} + +/// A two-column grid of facts; `add` gets the label painter. +fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) { + let p = pal(ui.ctx()); + egui::Grid::new(ui.next_auto_id()) + .num_columns(2) + .spacing(vec2(20.0, 6.0)) + .show(ui, |ui| { + let k = |ui: &mut Ui, s: &str| { + ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted)); + }; + add(ui, &k); + }); +} diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index e45f93b..87016b7 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -194,10 +194,18 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { return; } let mut go = None; - widgets::titled_card( + let (register, _) = widgets::titled_card( ui, "Fixed assets", - |_| {}, + |ui| { + widgets::link( + ui, + RichText::new(format!("Asset register {}", icon::ARROW_RIGHT)) + .font(theme::regular(12.5)) + .color(p.accent), + ) + .clicked() + }, |ui| { for a in &assets { // What has actually been posted, so this ties to the balance @@ -228,11 +236,11 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ui.horizontal_wrapped(|ui| { if widgets::link( ui, - RichText::new(&a.description).font(theme::semibold(13.5)), + RichText::new(b.asset_label(a.id)).font(theme::semibold(13.5)), ) .clicked() { - go = Some(a.cost_account); + go = Some(a.id); } ui.label( RichText::new(format!( @@ -271,7 +279,10 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { }, ); if let Some(id) = go { - shell.go(Route::Account(id)); + shell.go(Route::Asset(id)); + } + if register { + shell.go(Route::Assets); } ui.add_space(16.0); } diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index 18b04cf..7592768 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -85,6 +85,19 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { shell.go(Route::Entry(r)); } } + if let Some(&asset) = b.entry_assets.get(&e.id) { + ui.add_space(4.0); + if widgets::link( + ui, + RichText::new(format!("{} {}", icon::PACKAGE, b.asset_label(asset))) + .color(p.accent), + ) + .on_hover_text("Open this fixed asset") + .clicked() + { + shell.go(Route::Asset(asset)); + } + } ui.add_space(16.0); // Facts. diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 62da766..814ec7e 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -23,6 +23,8 @@ pub struct Event { pub icon: &'static str, pub text: String, pub route: Option, + /// A second place it's about (the asset of a depreciation entry). + pub also: Option, } fn s<'a>(p: &'a Value, k: &str) -> &'a str { @@ -58,7 +60,32 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { let id = p.get("id").and_then(|v| v.as_u64()).unwrap_or(0); let entry_id = p.get("entry_id").and_then(|v| v.as_u64()); let att = p.get("attachment_id").and_then(|v| v.as_u64()).unwrap_or(0); - let ev = |icon, text: String, route: Option| Some(Event { icon, text, route }); + let ev = |icon, text: String, route: Option| { + Some(Event { + icon, + text, + route, + also: None, + }) + }; + let asset_id = p.get("asset_id").and_then(|v| v.as_u64()); + let asset = |id: Option| { + id.map(|a| { + b.ledger + .get_asset(a) + .map(|x| x.description.clone()) + .unwrap_or_else(|| format!("asset {a}")) + }) + .unwrap_or_default() + }; + let with_asset = |icon, text: String| { + Some(Event { + icon, + text, + route: entry_id.map(Route::Entry), + also: asset_id.map(Route::Asset), + }) + }; match line.line_type.as_str() { "entry.created" | "entry.line_added" | "entry.line_class_set" => None, "store.created" => ev(icon::BOOK_OPEN, "Books created".into(), None), @@ -223,17 +250,45 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { "asset.registered" => ev( icon::PACKAGE, format!("Registered asset {}", s(p, "description")), - Some(Route::Company), + Some(Route::Asset(id)), ), - "asset.depreciation_posted" => ev( + "asset.tagged" => { + let tag = s(p, "tag"); + let text = match p.get("previous_tag").and_then(|v| v.as_str()) { + Some(old) => format!("Retagged {} from {old} to {tag}", asset(asset_id)), + None => format!("Tagged {} {tag}", asset(asset_id)), + }; + ev(icon::TAG, text, asset_id.map(Route::Asset)) + } + "asset.serial_set" => { + let text = match p.get("serial").and_then(|v| v.as_str()) { + Some(serial) => format!("Serial number of {} set to {serial}", asset(asset_id)), + None => format!("Serial number of {} cleared", asset(asset_id)), + }; + ev(icon::BARCODE, text, asset_id.map(Route::Asset)) + } + "asset.depreciation_posted" => with_asset( icon::TREND_DOWN, - "Depreciation posted".into(), - entry_id.map(Route::Entry), + format!( + "Depreciation posted: {} {} ({})", + asset(asset_id), + s(p, "period"), + amount(p) + ), ), - "asset.disposed" => ev( + "asset.disposed" => with_asset( icon::TRASH, - "Asset disposed".into(), - entry_id.map(Route::Entry), + format!( + "Disposed {} on {} for {}", + asset(asset_id), + s(p, "date"), + p.get("proceeds") + .and_then(|v| v.get("amount")) + .and_then(|v| v.as_str()) + .and_then(|a| a.parse().ok()) + .map(fmt::dollars) + .unwrap_or_default() + ), ), "schedule.registered" => ev( icon::CALENDAR, @@ -310,6 +365,7 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event { icon: icon::PENCIL_SIMPLE, text: format!("Drafted #{id} {}", s(p, "description")), route: Some(Route::Entry(id)), + also: None, }, "entry.line_added" => { let amount = p @@ -332,6 +388,7 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event { ), ), route: Some(Route::Entry(id)), + also: None, } } "entry.line_class_set" => { @@ -350,12 +407,14 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event { .map_or(0, |i| i + 1) ), route: Some(Route::Entry(id)), + also: None, } } t => Event { icon: icon::DOT, text: t.to_string(), route: None, + also: None, }, } } @@ -510,7 +569,7 @@ fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) { fact(ui, "Previous", &line.prev); let own = ledger_store::journal::sha256_hex(line.to_canonical().as_bytes()); fact(ui, "This hash", &own); - if let Some(r) = &ev.route { + for r in ev.route.iter().chain(&ev.also) { ui.add_space(6.0); if ui .button(format!( diff --git a/crates/ledger-gui/src/pages/mod.rs b/crates/ledger-gui/src/pages/mod.rs index 090a144..f1f620a 100644 --- a/crates/ledger-gui/src/pages/mod.rs +++ b/crates/ledger-gui/src/pages/mod.rs @@ -1,6 +1,7 @@ //! One module per screen. pub mod accounts; +pub mod assets; pub mod cash; pub mod company; pub mod documents; @@ -52,6 +53,8 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) { Route::Reports(kind) => reports::show(ui, b, *kind, shell), Route::Accounts => accounts::show_chart(ui, b, shell), Route::Account(id) => accounts::show_account(ui, b, *id, shell), + Route::Assets => assets::show_register(ui, b, shell), + Route::Asset(id) => assets::show_asset(ui, b, *id, shell), Route::Documents => documents::show(ui, b, None, shell), Route::Document(id) => documents::show(ui, b, Some(*id), shell), Route::Company => company::show(ui, b, shell), diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 3504b8d..bb15047 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -943,6 +943,119 @@ fn aging(rows: Vec, as_of: NaiveDate) -> Table { t } +/// The fixed asset register on `as_of`: ledger-core's +/// `fixed_asset_register`, laid out, with its reconciliation to the +/// general ledger and what that says (the Assets page's notices). +fn asset_register(b: &Books, as_of: NaiveDate) -> Table { + let r = b.asset_register(as_of); + let mut t = Table::new(vec![ + "Cost".into(), + "Accumulated".into(), + "Book value".into(), + ]); + if r.rows.is_empty() { + t.note(format!( + "No fixed asset was in service by {}.", + fmt::date(as_of) + )); + return t; + } + t.section("Assets"); + for x in &r.rows { + let label = match &x.tag { + Some(tag) => format!("{tag} · {}", x.description), + None => format!("{} (no tag)", x.description), + }; + let mut detail = Vec::new(); + if let Some(s) = &x.serial { + detail.push(format!("serial {s}")); + } + detail.push(format!("in service {}", fmt::date(x.in_service))); + detail.push(fmt::count(x.useful_life_months as usize, "month", "months")); + if !x.salvage.is_zero() { + detail.push(format!("salvage {}", fmt::dollars(x.salvage))); + } + let cells = match x.disposed { + Some(_) => { + detail.push(format!( + "{}; cost {}", + super::assets::status_text(x.disposed), + fmt::dollars(x.cost) + )); + vec![Cell::none(), Cell::none(), Cell::none()] + } + None => vec![ + Cell::of(x.cost), + Cell::of(x.accumulated), + Cell::of(x.book_value), + ], + }; + t.rows.push(Row { + kind: Kind::Line, + label, + detail: detail.join(" · "), + cells, + }); + } + t.row( + Kind::Grand, + "Total held", + vec![ + Cell::of(r.total_cost), + Cell::of(r.total_accumulated), + Cell::of(r.total_book_value), + ], + ); + t.section("Reconciliation to the books"); + let start = b.kpis.first_entry.unwrap_or(as_of).min(as_of); + let held: Vec<_> = r + .rows + .iter() + .filter_map(|x| b.ledger.get_asset(x.asset_id)) + .collect(); + // One account behind a figure drills down to it. + let only = |ids: Vec| -> Option { + let mut ids = ids; + ids.sort_unstable(); + ids.dedup(); + (ids.len() == 1).then(|| Drill { + account: ids[0], + start, + end: as_of, + }) + }; + let cost_drill = only(held.iter().map(|a| a.cost_account).collect()); + let accum_drill = only(held.iter().map(|a| a.accum_depr_account).collect()); + t.row( + Kind::Line, + "In the books", + vec![ + Cell { + value: Some(r.ledger_cost), + drill: cost_drill, + }, + Cell { + value: Some(r.ledger_accumulated), + drill: accum_drill, + }, + Cell::of(r.ledger_cost - r.ledger_accumulated), + ], + ); + t.row( + Kind::Subtotal, + "Register less books", + vec![ + Cell::of(-r.unregistered_cost()), + Cell::of(r.unposted_depreciation()), + Cell::of(-r.unregistered_cost() - r.unposted_depreciation()), + ], + ); + for n in super::assets::notices(b, &r) { + t.note(n.text); + } + t +} + // ---------------------------------------------------------------------- // The page. @@ -959,7 +1072,11 @@ const GROUPS: [(&str, &[ReportKind]); 3] = [ ), ( "Ledgers", - &[ReportKind::TrialBalance, ReportKind::GeneralLedger], + &[ + ReportKind::TrialBalance, + ReportKind::GeneralLedger, + ReportKind::FixedAssetRegister, + ], ), ( "Tax and aging", @@ -1042,7 +1159,10 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke let p = pal(ui.ctx()); let point_in_time = matches!( kind, - ReportKind::TrialBalance | ReportKind::ArAging | ReportKind::ApAging + ReportKind::TrialBalance + | ReportKind::ArAging + | ReportKind::ApAging + | ReportKind::FixedAssetRegister ) || (kind == ReportKind::BalanceSheet && shell.state.reports.columns == Columns::Total); let splittable = matches!( @@ -1231,6 +1351,7 @@ fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picke Some(t) } ReportKind::TrialBalance => Some(trial_balance(b, e)), + ReportKind::FixedAssetRegister => Some(asset_register(b, e)), ReportKind::MembersEquity => Some(owners_equity(b, s, e)), ReportKind::TaxSummary => { let mut t = tax(b, s, e); @@ -1922,6 +2043,7 @@ mod tests { tables.push(("equity", owners_equity(&b, s, e))); tables.push(("tax", tax(&b, s, e))); tables.push(("ar", aging(b.ledger.ar_aging(e), e))); + tables.push(("assets", asset_register(&b, e))); tables.push(("gl", gl_table(&general_ledger(&b.ledger, s, e).unwrap()))); let keep = std::env::var_os("PDF_DIR").map(std::path::PathBuf::from); for (name, t) in tables { diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs index cc1d22d..389a6f0 100644 --- a/crates/ledger-gui/src/palette.rs +++ b/crates/ledger-gui/src/palette.rs @@ -1,5 +1,6 @@ //! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text, -//! amount, or #id), vendors, and documents, ranked by a simple fuzzy match. +//! amount, or #id), fixed assets (by tag, serial, or description), vendors, +//! and documents, ranked by a simple fuzzy match. use crate::books::Books; use crate::fmt; @@ -16,12 +17,13 @@ pub struct Palette { focus: bool, } -struct Item { - icon: &'static str, - title: String, - detail: String, - route: Route, - score: i32, +/// One search result. +pub struct Item { + pub icon: &'static str, + pub title: String, + pub detail: String, + pub route: Route, + pub score: i32, } impl Palette { @@ -75,7 +77,7 @@ impl Palette { let screen = ctx.content_rect().size(); ui.set_width(620.0_f32.min(screen.x - 48.0).max(200.0)); let edit = egui::TextEdit::singleline(&mut self.query) - .hint_text("Search entries, accounts, vendors, documents, reports…") + .hint_text("Search entries, accounts, assets, vendors, documents, reports…") .font(theme::regular(16.0)) .frame(egui::Frame::NONE) .desired_width(f32::INFINITY); @@ -211,7 +213,19 @@ pub fn fuzzy(hay: &str, query: &str) -> Option { best.into_iter().flatten().max() } -fn search(b: &Books, query: &str) -> Vec { +/// What the palette lists for `query`, best first (at most 40). +/// +/// ```text +/// INVARIANTS (fixed assets; everything else as it was): +/// 1. An asset whose tag equals the trimmed query, ignoring ASCII case, +/// scores 2000: above every other result (an entry's exact #id is +/// 1000). +/// 2. Otherwise an asset is listed when the query fuzzily matches its +/// tag, serial, and description; a query found whole in its tag or +/// serial (ignoring case) adds 500, in its description 50. +/// 3. Every asset result opens Route::Asset(its id). +/// ``` +pub fn search(b: &Books, query: &str) -> Vec { let q = query.trim(); let mut out: Vec = Vec::new(); let mut add = |icon: &'static str, title: String, detail: String, route: Route, hay: &str| { @@ -281,6 +295,40 @@ fn search(b: &Books, query: &str) -> Vec { }); } } + let ql = q.to_lowercase(); + for a in b.ledger.registered_assets() { + let tag = a.tag.as_deref().unwrap_or(""); + let serial = a.serial.as_deref().unwrap_or(""); + let hay = format!("{tag} {serial} {}", a.description); + let score = if !tag.is_empty() && tag.eq_ignore_ascii_case(q) { + Some(2000) + } else { + fuzzy(&hay, q).map(|s| { + let whole = |t: &str| !t.is_empty() && t.to_lowercase().contains(&ql); + s + if whole(tag) || whole(serial) { + 500 + } else if whole(&a.description) { + 50 + } else { + 0 + } + }) + }; + if let Some(score) = score { + let mut detail = vec!["Fixed asset".to_string()]; + if !serial.is_empty() { + detail.push(format!("serial {serial}")); + } + detail.push(format!("in service {}", fmt::date(a.purchase_date))); + out.push(Item { + icon: icon::PACKAGE, + title: b.asset_label(a.id), + detail: detail.join(" · "), + route: Route::Asset(a.id), + score, + }); + } + } for v in b.ledger.list_vendors() { if let Some(score) = fuzzy(&v.name, q) { out.push(Item { @@ -329,6 +377,7 @@ pub fn describe(b: &Books, r: &Route) -> String { .find(|a| a.id == *id) .map(|a| a.file_name.clone()) .unwrap_or_default(), + Route::Asset(id) => b.asset_label(*id), Route::JournalEvent(seq) => format!("Event {seq}"), _ => String::new(), } diff --git a/crates/ledger-gui/tests/assets.rs b/crates/ledger-gui/tests/assets.rs new file mode 100644 index 0000000..0d4d207 --- /dev/null +++ b/crates/ledger-gui/tests/assets.rs @@ -0,0 +1,614 @@ +//! Fixed assets in the viewer: the register and each asset's schedule are +//! ledger-core's numbers exactly; what's posted comes from the journal; +//! search, books.show, the journal, and "Needs attention" know assets. + +use chrono::{Datelike, Days, Months, NaiveDate}; +use egui_kittest::Harness; +use ledger_core::{ + AccountType, AssetId, Currency, EntryId, Money, NewAsset, Side, depreciation_schedule, + fixed_asset_register, +}; +use ledger_gui::books::Books; +use ledger_gui::nav::{ReportKind, Route}; +use ledger_gui::pages::assets::{MonthStatus, notices, schedule}; +use ledger_gui::pages::journal::describe; +use ledger_gui::{App, Source, attention, data, fmt, palette}; +use ledger_store::Store; +use ledger_store::viewer::ViewerTarget; +use proptest::prelude::*; +use rust_decimal::Decimal; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static DIRS: AtomicUsize = AtomicUsize::new(0); + +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> TempDir { + let n = DIRS.fetch_add(1, Ordering::Relaxed); + let d = std::env::temp_dir().join(format!( + "ledger-gui-assets-{tag}-{}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + TempDir(d) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn usd(cents: i64) -> Money { + Money::new(Decimal::new(cents, 2), Currency::USD) +} + +fn load(dir: &Path, today: NaiveDate) -> Books { + data::load_once(&Source { + data_dir: dir.join("books-data"), + books_toml: None, + today: Some(today), + }) + .unwrap() +} + +struct Fixture { + _dir: TempDir, + books: Books, + /// The store's own ledger (the oracle's input), reopened from disk. + oracle: ledger_core::Ledger, + laptop: AssetId, + monitor: AssetId, + old: AssetId, + laptop_purchase: EntryId, +} + +const TODAY: (i32, u32, u32) = (2026, 10, 15); + +fn today() -> NaiveDate { + d(TODAY.0, TODAY.1, TODAY.2) +} + +/// A laptop (tagged, serial, depreciation through July), a monitor (no +/// tag, through August), and an old laptop retagged, its serial cleared, +/// and sold in June. +fn fixture(tag: &str) -> Fixture { + let dir = TempDir::new(tag); + let data = dir.0.join("books-data"); + let mut s = Store::create(&data, Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let equip = s + .add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let accum = s + .add_account( + "1510", + "Accumulated Depreciation", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let dep = s + .add_account("6500", "Depreciation", AccountType::Expense, None, false) + .unwrap(); + let gain = s + .add_account("7000", "Gain on sale", AccountType::Revenue, None, false) + .unwrap(); + let buy = |s: &mut Store, date, what: &str, cents| { + let e = s.create_entry(date, what).unwrap(); + s.add_line(e, equip, Side::Debit, usd(cents)).unwrap(); + s.add_line(e, cash, Side::Credit, usd(cents)).unwrap(); + s.post_entry(e).unwrap(); + e + }; + let e = s.create_entry(d(2026, 1, 1), "Owner contribution").unwrap(); + s.add_line(e, cash, Side::Debit, usd(1_000_000)).unwrap(); + s.add_line(e, capital, Side::Credit, usd(1_000_000)) + .unwrap(); + s.post_entry(e).unwrap(); + let register = |s: &mut Store, what: &str, date, cents, salvage, months| { + s.register_asset(NewAsset { + description: what.into(), + purchase_date: date, + cost: usd(cents), + salvage_value: usd(salvage), + useful_life_months: months, + cost_account: equip, + accum_depr_account: accum, + expense_account: dep, + }) + .unwrap() + }; + let laptop_purchase = buy(&mut s, d(2026, 1, 10), "Laptop", 300_000); + let laptop = register(&mut s, "Laptop", d(2026, 1, 10), 300_000, 0, 36); + buy(&mut s, d(2026, 2, 1), "Monitor", 120_000); + let monitor = register(&mut s, "Monitor", d(2026, 2, 1), 120_000, 0, 12); + buy(&mut s, d(2026, 1, 1), "Old laptop", 60_000); + let old = register(&mut s, "Old laptop", d(2026, 1, 1), 60_000, 10_000, 24); + s.set_asset_tag(laptop, "WL-0001").unwrap(); + s.set_asset_serial(laptop, Some("SN-123")).unwrap(); + s.set_asset_tag(old, "OLD-1").unwrap(); + s.set_asset_tag(old, "WL-0009").unwrap(); + s.set_asset_serial(old, Some("X1")).unwrap(); + s.set_asset_serial(old, None).unwrap(); + for m in 1..=7 { + s.run_depreciation(laptop, &format!("2026-{m:02}")).unwrap(); + } + for m in 2..=8 { + s.run_depreciation(monitor, &format!("2026-{m:02}")) + .unwrap(); + } + for m in 1..=3 { + s.run_depreciation(old, &format!("2026-{m:02}")).unwrap(); + } + s.dispose_asset(old, d(2026, 6, 30), usd(30_000), cash, gain) + .unwrap(); + drop(s); + let oracle = Store::open(&data).unwrap().ledger().clone(); + Fixture { + books: load(&dir.0, today()), + _dir: dir, + oracle, + laptop, + monitor, + old, + laptop_purchase, + } +} + +#[test] +fn the_register_is_ledger_cores_register() { + let f = fixture("register"); + let b = &f.books; + for as_of in [ + d(2025, 12, 31), + d(2026, 1, 10), + d(2026, 3, 31), + d(2026, 6, 29), + d(2026, 6, 30), + d(2026, 7, 31), + d(2026, 9, 30), + today(), + d(2030, 1, 1), + ] { + let mine = b.asset_register(as_of); + let oracle = fixed_asset_register(&f.oracle, as_of); + assert_eq!(mine, oracle, "register on {as_of}"); + } + let r = b.asset_register(today()); + assert_eq!(r.rows.len(), 3); + let old = r.rows.iter().find(|x| x.asset_id == f.old).unwrap(); + assert_eq!( + old.disposed, + Some((d(2026, 6, 30), Decimal::new(30_000, 2))) + ); + assert_eq!( + ledger_gui::pages::assets::status_text(old.disposed), + "Disposed Jun 30, 2026 for $300.00" + ); +} + +#[test] +fn depreciation_due_is_the_register_through_the_last_finished_month() { + let f = fixture("due"); + let b = &f.books; + let due = b.depreciation_due(today()); + assert_eq!(due.through, d(2026, 9, 30)); + assert_eq!( + due.amount, + fixed_asset_register(&f.oracle, d(2026, 9, 30)).unposted_depreciation() + ); + assert_eq!(due.periods, vec!["2026-08", "2026-09"]); + // Independently: the laptop's August and September, the monitor's + // September, straight from the schedules. + let sched = |id| depreciation_schedule(f.oracle.get_asset(id).unwrap(), d(2026, 9, 30)); + let month = |id, p: &str| { + sched(id) + .into_iter() + .find(|l| l.period == p) + .unwrap() + .amount + }; + assert_eq!( + due.amount, + month(f.laptop, "2026-08") + month(f.laptop, "2026-09") + month(f.monitor, "2026-09") + ); + // A month's last day counts that month as finished. + assert_eq!(b.depreciation_due(d(2026, 7, 31)).through, d(2026, 7, 31)); + assert_eq!( + b.depreciation_due(d(2026, 7, 31)).periods, + Vec::::new() + ); + + // The notices name the due amount exactly, and the month under way. + let r = b.asset_register(today()); + let n = notices(b, &r); + assert!(n[0].problem); + assert!( + n[0].text.contains(&format!( + "Depreciation not yet posted through Sep 30, 2026: {}", + fmt::dollars(due.amount) + )), + "{:?}", + n[0].text + ); + assert!(n[0].text.contains("ask the bookkeeper to run depreciation")); + let accruing = r.unposted_depreciation() - due.amount; + assert!(accruing > Decimal::ZERO); + assert!(n[1].text.contains(&fmt::dollars(accruing))); + assert!(!n[1].problem); +} + +#[test] +fn an_assets_schedule_and_trail() { + let f = fixture("schedule"); + let b = &f.books; + let lines = schedule(b, f.laptop); + let asset = f.oracle.get_asset(f.laptop).unwrap(); + let oracle = depreciation_schedule(asset, NaiveDate::MAX); + assert_eq!( + oracle.len(), + 37, + "36 months from Jan 10: 37 calendar months" + ); + assert_eq!( + lines.iter().map(|(l, _)| l.clone()).collect::>(), + oracle + ); + let trail = b.asset_trail(f.laptop); + for (l, st) in &lines { + let want = match l.period.as_str() { + p if p <= "2026-07" => MonthStatus::Posted(trail.depreciation[p].0), + "2026-08" | "2026-09" => MonthStatus::Due, + "2026-10" => MonthStatus::ThisMonth, + _ => MonthStatus::Scheduled, + }; + assert_eq!(*st, want, "{}", l.period); + } + // Posted amounts are the schedule's, and the purchase is found. + for (period, (_, amount)) in &trail.depreciation { + let l = oracle.iter().find(|l| &l.period == period).unwrap(); + assert_eq!(*amount, l.amount); + } + assert_eq!(trail.purchase_entry, Some(f.laptop_purchase)); + assert_eq!(b.entry_assets.get(&f.laptop_purchase), Some(&f.laptop)); + assert_eq!(trail.entries().len(), 1 + 7); + // The sold laptop: its disposal entry, and a final depreciation. + let old = b.asset_trail(f.old); + let disposal = old.disposal_entry.expect("disposal entry"); + assert_eq!(b.entry(disposal).unwrap().origin, "Asset disposal"); + assert!(old.depreciation.contains_key("2026-06")); + assert_eq!(b.entry_assets.get(&disposal), Some(&f.old)); + assert_eq!(b.asset_label(f.laptop), "WL-0001 Laptop"); + assert_eq!(b.asset_label(f.monitor), "Monitor"); +} + +#[test] +fn search_finds_assets_by_tag_serial_and_description() { + let f = fixture("search"); + let b = &f.books; + for q in ["WL-0001", "wl-0001", " Wl-0001 "] { + let items = palette::search(b, q); + assert_eq!(items[0].route, Route::Asset(f.laptop), "{q}"); + assert_eq!(items[0].score, 2000); + } + let first_asset = |q: &str| { + palette::search(b, q) + .into_iter() + .find(|i| matches!(i.route, Route::Asset(_))) + .map(|i| i.route) + }; + assert_eq!(first_asset("sn-123"), Some(Route::Asset(f.laptop))); + assert_eq!( + palette::search(b, "SN-123")[0].route, + Route::Asset(f.laptop) + ); + assert_eq!(first_asset("monitor"), Some(Route::Asset(f.monitor))); + assert_eq!(first_asset("wl-0009"), Some(Route::Asset(f.old))); + // Everything else still found as before. + assert!( + palette::search(b, "checking") + .iter() + .any(|i| matches!(i.route, Route::Account(_))) + ); + assert!( + palette::search(b, "") + .iter() + .any(|i| i.route == Route::Assets) + ); + assert!( + palette::search(b, "fixed asset register") + .iter() + .any(|i| i.route == Route::Reports(ReportKind::FixedAssetRegister)) + ); + assert_eq!( + palette::describe(b, &Route::Asset(f.laptop)), + "WL-0001 Laptop" + ); +} + +#[test] +fn journal_text_for_asset_events() { + let f = fixture("journal"); + let b = &f.books; + let texts: Vec<(String, Option, Option)> = b + .journal + .iter() + .filter(|l| l.line_type.starts_with("asset.")) + .filter_map(|l| describe(b, l)) + .map(|e| (e.text, e.route, e.also)) + .collect(); + let has = |t: &str, route: Option| { + assert!( + texts.iter().any(|(x, r, _)| x == t && *r == route), + "no {t:?} ({route:?}) in {texts:#?}" + ); + }; + has("Registered asset Laptop", Some(Route::Asset(f.laptop))); + has("Tagged Laptop WL-0001", Some(Route::Asset(f.laptop))); + has( + "Retagged Old laptop from OLD-1 to WL-0009", + Some(Route::Asset(f.old)), + ); + has( + "Serial number of Laptop set to SN-123", + Some(Route::Asset(f.laptop)), + ); + has( + "Serial number of Old laptop cleared", + Some(Route::Asset(f.old)), + ); + let disposal = b.asset_trail(f.old).disposal_entry; + has( + "Disposed Old laptop on 2026-06-30 for $300.00", + disposal.map(Route::Entry), + ); + let (jan, _) = b.asset_trail(f.laptop).depreciation["2026-01"]; + let dep = texts + .iter() + .find(|(x, _, _)| x.starts_with("Depreciation posted: Laptop 2026-01")) + .expect("depreciation text"); + assert_eq!(dep.1, Some(Route::Entry(jan))); + assert_eq!(dep.2, Some(Route::Asset(f.laptop))); +} + +#[test] +fn attention_asks_for_depreciation_and_tags() { + let f = fixture("attention"); + let b = &f.books; + let items = attention::find(b); + let due = b.depreciation_due(today()); + let dep = items + .iter() + .find(|a| a.title == "Depreciation not posted through Sep 30, 2026") + .expect("depreciation item"); + assert_eq!(dep.target, Some(Route::Assets)); + assert!(dep.prompt.starts_with("In wanderbooks,")); + assert!( + dep.prompt + .contains("books.run_depreciation for 2026-08, 2026-09") + ); + assert!(dep.prompt.contains(&fmt::dollars(due.amount))); + let tag = items + .iter() + .find(|a| a.title == "No tag: Monitor") + .expect("tag item"); + assert_eq!(tag.target, Some(Route::Asset(f.monitor))); + assert!(tag.prompt.contains("books.label_asset")); + // Tagged, disposed, and agreeing assets ask for nothing. + assert!( + !items + .iter() + .any(|a| a.title.contains("Laptop") && a.title.starts_with("No tag")) + ); + assert!(!items.iter().any(|a| a.title.contains("not in the"))); + for a in &items { + assert!(a.prompt.contains("wanderbooks"), "{:?}", a.prompt); + } + + // Caught up and labeled: nothing about assets. + let data = f._dir.0.join("books-data"); + let mut s = Store::open(&data).unwrap(); + s.run_depreciation(f.laptop, "2026-08").unwrap(); + s.run_depreciation(f.laptop, "2026-09").unwrap(); + s.run_depreciation(f.monitor, "2026-09").unwrap(); + s.set_asset_tag(f.monitor, "WL-0002").unwrap(); + drop(s); + let b = load(&f._dir.0, today()); + let titles: Vec = attention::find(&b).into_iter().map(|a| a.title).collect(); + assert!( + !titles + .iter() + .any(|t| t.contains("Depreciation") || t.starts_with("No tag")), + "{titles:?}" + ); + assert_eq!(b.depreciation_due(today()).amount, Decimal::ZERO); +} + +#[test] +fn unregistered_equipment_is_flagged() { + let f = fixture("unregistered"); + let data = f._dir.0.join("books-data"); + let mut s = Store::open(&data).unwrap(); + let equip = s.ledger().chart().find_by_code("1500").unwrap().id; + let cash = s.ledger().chart().find_by_code("1000").unwrap().id; + let e = s.create_entry(d(2026, 9, 2), "Printer").unwrap(); + s.add_line(e, equip, Side::Debit, usd(45_000)).unwrap(); + s.add_line(e, cash, Side::Credit, usd(45_000)).unwrap(); + s.post_entry(e).unwrap(); + drop(s); + let b = load(&f._dir.0, today()); + let r = b.asset_register(today()); + assert_eq!(r.unregistered_cost(), Decimal::new(45_000, 2)); + assert!( + notices(&b, &r) + .iter() + .any(|n| n.problem && n.text.starts_with("$450.00 in the asset cost accounts")) + ); + assert!( + attention::find(&b) + .iter() + .any(|a| a.title == "$450.00 of equipment not in the asset register") + ); +} + +#[test] +fn books_show_opens_assets() { + let f = fixture("show"); + let laptop = f.laptop; + let books = f.books; + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 920.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); + h.state_mut().show(ViewerTarget::Page { + page: "assets".into(), + }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Assets); + h.state_mut().show(ViewerTarget::Asset { asset_id: laptop }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Asset(laptop)); + h.state_mut().show(ViewerTarget::Report { + report: "fixed_asset_register".into(), + start: None, + end: Some("2026-06-30".into()), + columns: None, + compare: None, + }); + h.run_steps(2); + assert_eq!( + h.state_mut().route(), + &Route::Reports(ReportKind::FixedAssetRegister) + ); + assert!(matches!( + h.state_mut().state_mut().reports.range, + ledger_gui::pages::reports::Range::Custom(_, e) if e == d(2026, 6, 30) + )); + // An unknown asset is ignored, not a crash. + h.state_mut().show(ViewerTarget::Asset { asset_id: 999 }); + h.run_steps(1); + assert_eq!( + h.state_mut().route(), + &Route::Reports(ReportKind::FixedAssetRegister) + ); +} + +/// The asset pages render at every width without panicking, and with no +/// assets at all. +#[test] +fn asset_pages_render() { + let f = fixture("render"); + let routes = [ + Route::Assets, + Route::Asset(f.laptop), + Route::Asset(f.monitor), + Route::Asset(f.old), + Route::Asset(999), + Route::Reports(ReportKind::FixedAssetRegister), + ]; + for width in [420.0, 900.0, 1440.0] { + for route in &routes { + let books = load(&f._dir.0, today()); + let mut h = Harness::builder() + .with_size(egui::vec2(width, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, true, route.clone())); + h.run_steps(3); + } + } + let empty = TempDir::new("empty"); + drop(Store::create(&empty.0.join("books-data"), Currency::USD).unwrap()); + for route in [ + Route::Assets, + Route::Reports(ReportKind::FixedAssetRegister), + ] { + let books = load(&empty.0, today()); + let mut h = Harness::builder() + .with_size(egui::vec2(1280.0, 800.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route.clone())); + h.run_steps(3); + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(12))] + + /// On random assets with depreciation run for random months, the + /// register on any date is ledger-core's, and what's due is the + /// register through the last finished month. + #[test] + fn register_and_due_match_the_oracle( + assets in proptest::collection::vec( + (0u64..400, 1i64..2_000_000, 0i64..50, 1u32..60, 0u32..30), + 1..5, + ), + today_off in 0u64..900, + probe in 0u64..1200, + ) { + let dir = TempDir::new("prop"); + let data = dir.0.join("books-data"); + let mut s = Store::create(&data, Currency::USD).unwrap(); + let cash = s.add_account("1000", "Checking", AccountType::Asset, None, false).unwrap(); + let equip = s.add_account("1500", "Equipment", AccountType::Asset, None, false).unwrap(); + let accum = s.add_account("1510", "Accumulated", AccountType::Asset, None, false).unwrap(); + let dep = s.add_account("6500", "Depreciation", AccountType::Expense, None, false).unwrap(); + let base = d(2025, 1, 1); + for (i, (off, cents, salvage_pct, months, run)) in assets.iter().enumerate() { + let date = base + Days::new(*off); + let e = s.create_entry(date, &format!("Buy {i}")).unwrap(); + s.add_line(e, equip, Side::Debit, usd(*cents)).unwrap(); + s.add_line(e, cash, Side::Credit, usd(*cents)).unwrap(); + s.post_entry(e).unwrap(); + let id = s.register_asset(NewAsset { + description: format!("Asset {i}"), + purchase_date: date, + cost: usd(*cents), + salvage_value: usd(cents * salvage_pct / 100), + useful_life_months: *months, + cost_account: equip, + accum_depr_account: accum, + expense_account: dep, + }).unwrap(); + // Depreciation for the first `run` months of its life. + for k in 0..*run { + let m = date.with_day0(0).unwrap() + Months::new(k); + let _ = s.run_depreciation(id, &m.format("%Y-%m").to_string()); + } + } + drop(s); + let oracle = Store::open(&data).unwrap().ledger().clone(); + let today = base + Days::new(today_off); + let b = load(&dir.0, today); + let at = base + Days::new(probe); + prop_assert_eq!(b.asset_register(at), fixed_asset_register(&oracle, at)); + let due = b.depreciation_due(today); + prop_assert!(due.through <= today); + prop_assert_eq!(due.through.succ_opt().unwrap().day0(), 0); + prop_assert!(today - due.through < chrono::TimeDelta::days(32)); + prop_assert_eq!( + due.amount, + fixed_asset_register(&oracle, due.through).unposted_depreciation() + ); + // Every period listed is unposted and in some schedule. + for p in &due.periods { + let found = b.ledger.registered_assets().any(|a| { + depreciation_schedule(a, due.through).iter().any(|l| &l.period == p) + && !b.asset_trail(a.id).depreciation.contains_key(p) + }); + prop_assert!(found, "{} listed but not due", p); + } + } +} diff --git a/crates/ledger-gui/tests/read_only.rs b/crates/ledger-gui/tests/read_only.rs index 513b919..48e1282 100644 --- a/crates/ledger-gui/tests/read_only.rs +++ b/crates/ledger-gui/tests/read_only.rs @@ -128,6 +128,12 @@ fn the_viewer_never_writes_into_the_books() { let cash = books.cash_accounts[0]; let doc = books.attachments.last().map(|a| a.id).unwrap(); let event = books.seq(); + let asset = books + .ledger + .registered_assets() + .next() + .map(|a| a.id) + .unwrap(); drop(books); // Follow live for a moment. @@ -161,8 +167,13 @@ fn the_viewer_never_writes_into_the_books() { Route::Reports(ReportKind::TaxSummary), Route::Reports(ReportKind::Vendor1099), Route::Reports(ReportKind::ArAging), + Route::Reports(ReportKind::ApAging), + Route::Reports(ReportKind::MembersEquity), + Route::Reports(ReportKind::FixedAssetRegister), Route::Accounts, Route::Account(cash), + Route::Assets, + Route::Asset(asset), Route::Document(doc), Route::JournalEvent(event), Route::Documents, diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index d7bcd50..37f9785 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -45,6 +45,12 @@ fn screenshots() { let first_cash = loaded.cash_accounts.first().copied().unwrap_or(1); let last_doc = loaded.attachments.last().map(|a| a.id).unwrap_or(1); let last_event = loaded.seq(); + let first_asset = loaded + .ledger + .registered_assets() + .next() + .map(|a| a.id) + .unwrap_or(1); drop(loaded); let routes: Vec<(&str, Route)> = vec![ ("overview", Route::Overview), @@ -68,8 +74,14 @@ fn screenshots() { ("tax", Route::Reports(ReportKind::TaxSummary)), ("v1099", Route::Reports(ReportKind::Vendor1099)), ("ar", Route::Reports(ReportKind::ArAging)), + ( + "asset-register", + Route::Reports(ReportKind::FixedAssetRegister), + ), ("accounts", Route::Accounts), ("account", Route::Account(first_cash)), + ("assets", Route::Assets), + ("asset", Route::Asset(first_asset)), ("document", Route::Document(last_doc)), ("event", Route::JournalEvent(last_event)), ("documents", Route::Documents), diff --git a/crates/ledger-gui/tests/show.rs b/crates/ledger-gui/tests/show.rs index 94e0dfd..e674682 100644 --- a/crates/ledger-gui/tests/show.rs +++ b/crates/ledger-gui/tests/show.rs @@ -18,6 +18,7 @@ fn show_requests_navigate_the_viewer() { }; let books = data::load_once(&source).unwrap(); let rent = books.ledger.chart().find_by_code("6200").unwrap().id; + let laptop = books.ledger.asset_by_tag("js-0001").unwrap().id; let mut h = Harness::builder() .with_size(egui::vec2(1440.0, 920.0)) .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, true, Route::Overview)); @@ -62,6 +63,44 @@ fn show_requests_navigate_the_viewer() { assert_eq!(h.state_mut().route(), &Route::Transactions); assert_eq!(h.state_mut().state_mut().transactions.query, "adobe"); + // "Show me the asset register", "the laptop", "the register at June 30". + h.state_mut().show(ViewerTarget::Page { + page: "assets".into(), + }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Assets); + h.state_mut().show(ViewerTarget::Asset { asset_id: laptop }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Asset(laptop)); + h.state_mut().show(ViewerTarget::Report { + report: "fixed_asset_register".into(), + start: None, + end: Some("2026-06-30".into()), + columns: None, + compare: None, + }); + h.run_steps(2); + assert_eq!( + h.state_mut().route(), + &Route::Reports(ReportKind::FixedAssetRegister) + ); + assert!(matches!( + h.state_mut().state_mut().reports.range, + ledger_gui::pages::reports::Range::Custom(_, e) + if e == chrono::NaiveDate::from_ymd_opt(2026, 6, 30).unwrap() + )); + // An unknown asset is ignored. + h.state_mut().show(ViewerTarget::Asset { asset_id: 999 }); + h.run_steps(1); + assert_eq!( + h.state_mut().route(), + &Route::Reports(ReportKind::FixedAssetRegister) + ); + h.state_mut().show(ViewerTarget::Search { + query: "adobe".into(), + }); + h.run_steps(2); + // Unknown accounts are ignored, not crashes. h.state_mut().show(ViewerTarget::Account { code: "9999".into(), diff --git a/crates/ledger-import/Cargo.toml b/crates/ledger-import/Cargo.toml index e9e4fdd..af9ebcd 100644 --- a/crates/ledger-import/Cargo.toml +++ b/crates/ledger-import/Cargo.toml @@ -17,9 +17,11 @@ typst-pdf = "0.15.1" typst = "0.15.1" serde_json = "1" typst-layout = "0.15.1" +qrcode = { version = "0.14.1", default-features = false, features = ["svg"] } [lints] workspace = true [dev-dependencies] proptest = "1" +rqrr = { version = "0.11.0", default-features = false } diff --git a/crates/ledger-import/src/asset_register.rs b/crates/ledger-import/src/asset_register.rs new file mode 100644 index 0000000..c7af876 --- /dev/null +++ b/crates/ledger-import/src/asset_register.rs @@ -0,0 +1,370 @@ +//! The fixed asset register as XLSX and PDF, for the CPA. +//! +//! [`ledger_core::fixed_asset_register`] computes the register; [`layout`] +//! lays it out once — columns, one line per asset, the totals, and the +//! reconciliation to the general ledger — and both renderers draw from +//! that layout, so they can't drift apart. Amounts stay exact `Decimal`s +//! until a renderer writes them, through the same paths as the statements +//! ([`crate::report::amount_parts`] for PDF, the accounting number formats +//! of [`crate::xlsx`] for XLSX). +//! +//! ```text +//! GOAL: one list of what the company owns, its cost, depreciation, and +//! book value, that a CPA can file, and that shows at a glance +//! whether the register and the books agree. +//! SOURCE: ledger_core::fixed_asset_register (INVARIANTS 1-4); CONVENTION +//! (layout, the statements' header and footer). +//! INVARIANTS: +//! 1. One line per register row, in the register's order, disposed ones +//! included and marked "Disposed DATE, proceeds AMOUNT"; held ones +//! "Active". +//! 2. The totals are the register's own totals (held assets only), +//! never re-summed here. +//! 3. Reconciliation: cost per register, per books, and their difference +//! (unregistered_cost); accumulated depreciation per schedule, per +//! books, and their difference (unposted_depreciation), exactly. +//! 4. An empty register says "No fixed assets" and still shows totals +//! and the reconciliation (the books may carry unregistered cost). +//! 5. Header and footer as on every statement: company, "Fixed Asset +//! Register", "As of DATE"; basis, prepared on, journal seq. +//! 6. PDF: deterministic bytes; every text is data, never markup. +//! ``` + +use crate::pdf::{INTER, PdfError}; +use crate::report::{ReportMeta, amount_parts, format_amount, long_date}; +use crate::xlsx::{Formats, XlsxError, footer, header, number}; +use ledger_core::reports::{AssetRegister, AssetRegisterRow}; +use rust_decimal::Decimal; +use rust_xlsxwriter::{Format, FormatAlign, Workbook}; +use std::sync::OnceLock; +use typst::foundations::{Dict, IntoValue}; +use typst_as_lib::{TypstEngine, TypstTemplateMainFile}; +use typst_layout::PagedDocument; + +/// The report's title. +pub const TITLE: &str = "Fixed Asset Register"; + +/// Column headings, left to right. +pub const COLUMNS: [&str; 10] = [ + "Tag", + "Description", + "Serial", + "In service", + "Cost", + "Salvage", + "Life (months)", + "Accumulated depreciation", + "Book value", + "Status", +]; + +/// The line shown when there are no assets. +pub const EMPTY: &str = "No fixed assets"; + +/// One asset's line. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetLine { + /// Tag, or empty. + pub tag: String, + /// What it is. + pub description: String, + /// Serial number, or empty. + pub serial: String, + /// In-service date, ISO 8601. + pub in_service: String, + /// Capitalized cost. + pub cost: Decimal, + /// Salvage value. + pub salvage: Decimal, + /// Useful life in months. + pub life_months: u32, + /// Accumulated depreciation by schedule (zero once disposed). + pub accumulated: Decimal, + /// Book value (zero once disposed). + pub book_value: Decimal, + /// "Active", or "Disposed 2026-09-30, proceeds 450.00". + pub status: String, + /// Disposed by the date (shown, not in the totals). + pub disposed: bool, +} + +/// Which register column a reconciliation amount sits under. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReconColumn { + /// Under "Cost". + Cost, + /// Under "Accumulated depreciation". + Accumulated, +} + +/// One reconciliation line. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReconLine { + /// "Cost per register", ... + pub label: String, + /// The amount. + pub amount: Decimal, + /// The column it reconciles. + pub column: ReconColumn, + /// A difference line (ruled above, bold). + pub difference: bool, +} + +/// The register laid out for rendering. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RegisterLayout { + /// [`TITLE`]. + pub title: String, + /// "As of October 15, 2026". + pub period: String, + /// One per asset, in register order. + pub lines: Vec, + /// Total cost, accumulated depreciation, and book value of the assets + /// held. + pub totals: [Decimal; 3], + /// Cost then accumulated depreciation: per register, per books, + /// difference. + pub reconciliation: Vec, +} + +fn status(a: &AssetRegisterRow) -> String { + match a.disposed { + Some((date, proceeds)) => { + format!("Disposed {date}, proceeds {}", format_amount(proceeds)) + } + None => "Active".to_string(), + } +} + +/// Lay the register out (see the module spec). +pub fn layout(r: &AssetRegister) -> RegisterLayout { + let recon = |label: &str, amount, column, difference| ReconLine { + label: label.to_string(), + amount, + column, + difference, + }; + RegisterLayout { + title: TITLE.to_string(), + period: format!("As of {}", long_date(r.as_of)), + lines: r + .rows + .iter() + .map(|a| AssetLine { + tag: a.tag.clone().unwrap_or_default(), + description: a.description.clone(), + serial: a.serial.clone().unwrap_or_default(), + in_service: a.in_service.to_string(), + cost: a.cost, + salvage: a.salvage, + life_months: a.useful_life_months, + accumulated: a.accumulated, + book_value: a.book_value, + status: status(a), + disposed: a.disposed.is_some(), + }) + .collect(), + totals: [r.total_cost, r.total_accumulated, r.total_book_value], + reconciliation: vec![ + recon("Cost per register", r.total_cost, ReconColumn::Cost, false), + recon("Cost per books", r.ledger_cost, ReconColumn::Cost, false), + recon( + "Difference (unregistered cost)", + r.unregistered_cost(), + ReconColumn::Cost, + true, + ), + recon( + "Accumulated depreciation per schedule", + r.total_accumulated, + ReconColumn::Accumulated, + false, + ), + recon( + "Accumulated depreciation per books", + r.ledger_accumulated, + ReconColumn::Accumulated, + false, + ), + recon( + "Difference (not yet posted)", + r.unposted_depreciation(), + ReconColumn::Accumulated, + true, + ), + ], + } +} + +/// The fixed asset register as XLSX bytes. +/// +/// Layout as every statement (crate::xlsx INVARIANT 4): company, title, +/// and date on rows 1-3, headings on row 5, one row per asset from row 6, +/// amounts as numbers in the accounting format; then the totals row +/// (double rule), an empty row, "Reconciliation to the books", and its +/// lines with amounts under Cost and Accumulated depreciation. +pub fn asset_register_xlsx(r: &AssetRegister, meta: &ReportMeta) -> Result, XlsxError> { + let l = layout(r); + let mut workbook = Workbook::new(); + let sheet = workbook.add_worksheet(); + let f = Formats::new(); + sheet.set_name(TITLE)?; + let width = COLUMNS.len() as u16; + let mut row = header(sheet, &f, meta, &l.title, &l.period, width)?; + for (i, h) in COLUMNS.iter().enumerate() { + let fmt = if (4..=8).contains(&i) { + &f.header_right + } else { + &f.header + }; + sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?; + } + row += 1; + if l.lines.is_empty() { + sheet.write_string_with_format(row, 0, EMPTY, &f.sub_label)?; + row += 1; + } + let quiet = Format::new().set_italic().set_font_color("#666666"); + let quiet_plain = f.plain.clone().set_italic().set_font_color("#666666"); + for a in &l.lines { + let (text, amount) = if a.disposed { + (&quiet, &quiet_plain) + } else { + (&Format::new(), &f.plain) + }; + sheet.write_string_with_format(row, 0, &a.tag, text)?; + sheet.write_string_with_format(row, 1, &a.description, text)?; + sheet.write_string_with_format(row, 2, &a.serial, text)?; + sheet.write_string_with_format(row, 3, &a.in_service, text)?; + number(sheet, row, 4, a.cost, amount)?; + number(sheet, row, 5, a.salvage, amount)?; + sheet.write_number_with_format( + row, + 6, + f64::from(a.life_months), + &text.clone().set_align(FormatAlign::Right), + )?; + number(sheet, row, 7, a.accumulated, amount)?; + number(sheet, row, 8, a.book_value, amount)?; + sheet.write_string_with_format(row, 9, &a.status, text)?; + row += 1; + } + sheet.write_string_with_format(row, 0, "Total, assets held", &f.bold)?; + for c in (1..width).filter(|c| ![4, 7, 8].contains(c)) { + sheet.write_blank(row, c, &f.dollars_total)?; + } + for (c, d) in [4u16, 7, 8].into_iter().zip(l.totals) { + number(sheet, row, c, d, &f.dollars_total)?; + } + row += 2; + sheet.write_string_with_format(row, 0, "Reconciliation to the books", &f.bold)?; + row += 1; + for line in &l.reconciliation { + let col = match line.column { + ReconColumn::Cost => 4, + ReconColumn::Accumulated => 7, + }; + let (label_fmt, amount_fmt) = if line.difference { + (&f.bold, &f.dollars_sum) + } else { + (&f.line_label, &f.plain) + }; + sheet.write_string_with_format(row, 0, &line.label, label_fmt)?; + number(sheet, row, col, line.amount, amount_fmt)?; + row += 1; + if line.difference { + row += 1; + } + } + for (c, w) in [ + (0, 12.0), + (1, 40.0), + (2, 20.0), + (3, 12.0), + (4, 14.0), + (5, 12.0), + (6, 9.0), + (7, 16.0), + (8, 14.0), + (9, 34.0), + ] { + sheet.set_column_width(c, w)?; + } + footer(sheet, &f, meta, row.saturating_sub(1), width)?; + Ok(workbook.save_to_buffer()?) +} + +static TEMPLATE: &str = include_str!("../templates/asset_register.typ"); + +fn engine() -> &'static TypstEngine { + static ENGINE: OnceLock> = OnceLock::new(); + ENGINE.get_or_init(|| { + TypstEngine::builder() + .main_file(TEMPLATE) + .fonts(INTER) + .build() + }) +} + +fn amount(d: Decimal) -> serde_json::Value { + let p = amount_parts(d); + serde_json::json!({ "digits": p.digits, "negative": p.negative }) +} + +/// The PDF template's input: the layout, every amount split for +/// alignment. +pub fn template_data(r: &AssetRegister, meta: &ReportMeta) -> serde_json::Value { + let l = layout(r); + serde_json::json!({ + "company": meta.company, + "title": l.title, + "period": l.period, + "footer": meta.footer(), + "columns": COLUMNS, + "empty": EMPTY, + "lines": l.lines.iter().map(|a| serde_json::json!({ + "tag": a.tag, + "description": a.description, + "serial": a.serial, + "in_service": a.in_service, + "cost": amount(a.cost), + "salvage": amount(a.salvage), + "life": a.life_months.to_string(), + "accumulated": amount(a.accumulated), + "book_value": amount(a.book_value), + "status": a.status, + "disposed": a.disposed, + })).collect::>(), + "totals": l.totals.iter().map(|d| amount(*d)).collect::>(), + "reconciliation": l.reconciliation.iter().map(|x| serde_json::json!({ + "label": x.label, + "amount": amount(x.amount), + "difference": x.difference, + })).collect::>(), + }) +} + +/// Typeset the register into a document (pages, before PDF encoding), so +/// the layout can be inspected. +pub fn typeset_asset_register( + r: &AssetRegister, + meta: &ReportMeta, +) -> Result { + let mut inputs = Dict::new(); + inputs.insert( + "data".into(), + template_data(r, meta).to_string().into_value(), + ); + engine() + .compile_with_input(inputs) + .output + .map_err(|e| PdfError::Compile(format!("{e:?}"))) +} + +/// The fixed asset register as PDF bytes (US Letter, landscape). See the +/// module spec. +pub fn asset_register_pdf(r: &AssetRegister, meta: &ReportMeta) -> Result, PdfError> { + let doc = typeset_asset_register(r, meta)?; + typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default()) + .map_err(|e| PdfError::Save(format!("{e:?}"))) +} diff --git a/crates/ledger-import/src/labels.rs b/crates/ledger-import/src/labels.rs new file mode 100644 index 0000000..160d96c --- /dev/null +++ b/crates/ledger-import/src/labels.rs @@ -0,0 +1,247 @@ +//! Printable asset label sheets: one tag per label, with a QR code. +//! +//! Each label carries the company name, the asset tag (its headline), what +//! the asset is, its serial number when recorded, and a QR code holding +//! exactly the tag. Labels are typeset by `templates/labels.typ` with the +//! same bundled Inter fonts as [`crate::pdf`]; the labels reach Typst as +//! JSON data (`sys.inputs.data`), never as markup. The sheet geometry comes +//! from the label maker's own specification, vendored in +//! `docs/spec/sources/avery-5160.md`. +//! +//! ```text +//! GOAL: tag every fixed asset with a label a phone can scan back to its +//! record, printed on stock label sheets. +//! SOURCE: docs/spec/sources/avery-5160.md (Avery 5160: 1" x 2-5/8", 30 per +//! sheet, 3 x 10, top margin 0.5", side margin 0.1875", column pitch +//! 2.75", row pitch 1"); ISO/IEC 18004 QR codes via the `qrcode` +//! crate, error correction level M; CONVENTION (what a label shows). +//! INVARIANTS: +//! 1. Every label prints inside its cell: each label is a clipped box the +//! size of its cell, and its text shrinks, then ellipsizes, to fit (the +//! description in two lines, the rest in one; the tag shrinks to 6 pt +//! before it would ever be cut, and the QR code always holds it whole). +//! 2. Order: labels fill positions left to right, then top to bottom, +//! starting at position `skip` of the first sheet, then on to the +//! next sheet at position 0. +//! 3. One page per sheet: pages = ceil((skip + labels) / per_sheet). +//! 4. The QR code decodes to exactly the tag. +//! 5. Deterministic: the same labels, company, sheet, and skip render to +//! the same bytes. +//! 6. Text is data: a description holding Typst syntax ("#panic()", +//! "*bold*") prints literally and never runs. +//! 7. Refused: no labels; skip >= per_sheet. +//! SCENARIOS: +//! - 1 label, skip 0: one page, the label at the top-left cell. +//! - 30 labels, skip 0: one full sheet. 30 labels, skip 1: two pages, the +//! last label at the top-left of sheet 2. +//! - 61 labels: three pages, one label on the third. +//! - A partly used sheet with 7 labels left: skip 23. +//! ``` + +use crate::pdf::{INTER, PdfError}; +use qrcode::render::svg; +use qrcode::{EcLevel, QrCode}; +use std::sync::OnceLock; +use typst::foundations::{Dict, IntoValue}; +use typst_as_lib::{TypstEngine, TypstTemplateMainFile}; +use typst_layout::PagedDocument; + +/// One asset label. +#[derive(Clone, Debug)] +pub struct AssetLabel { + /// The asset tag, e.g. "WL-0001" (the label's headline and the QR code's content). + pub tag: String, + /// What it is, e.g. "Framework Laptop 13". + pub description: String, + /// Manufacturer serial number, if recorded. + pub serial: Option, +} + +/// A sheet layout. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LabelSheet { + /// Avery 5160 (and compatibles): 1" x 2-5/8", 30 per US Letter sheet. + Avery5160, +} + +/// Where a sheet's labels sit, in PDF points (1/72 inch), measured from the +/// page's top-left corner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SheetGeometry { + /// Page width. + pub page_width: f64, + /// Page height. + pub page_height: f64, + /// One label's width. + pub label_width: f64, + /// One label's height. + pub label_height: f64, + /// Left edge of the first column (the side margin). + pub left: f64, + /// Top edge of the first row (the top margin). + pub top: f64, + /// Distance from one column's left edge to the next. + pub pitch_x: f64, + /// Distance from one row's top edge to the next. + pub pitch_y: f64, + /// Labels across. + pub columns: usize, + /// Labels down. + pub rows: usize, + /// Space kept clear inside each label's edge (printer drift). + pub inset: f64, +} + +/// A label's cell on its page: `(x, y, width, height)` in points from the +/// page's top-left corner. +pub type Cell = (f64, f64, f64, f64); + +impl SheetGeometry { + /// Labels per sheet. + pub fn per_sheet(&self) -> usize { + self.columns * self.rows + } + + /// The cell at `position` (0-based, reading order) on a sheet. + pub fn cell(&self, position: usize) -> Cell { + let (row, col) = (position / self.columns, position % self.columns); + ( + self.left + col as f64 * self.pitch_x, + self.top + row as f64 * self.pitch_y, + self.label_width, + self.label_height, + ) + } +} + +const INCH: f64 = 72.0; + +impl LabelSheet { + /// The sheet's geometry, from the maker's specification + /// (docs/spec/sources/avery-5160.md). + pub fn geometry(self) -> SheetGeometry { + match self { + LabelSheet::Avery5160 => SheetGeometry { + page_width: 8.5 * INCH, + page_height: 11.0 * INCH, + label_width: 2.625 * INCH, + label_height: 1.0 * INCH, + left: 0.1875 * INCH, + top: 0.5 * INCH, + pitch_x: 2.75 * INCH, + pitch_y: 1.0 * INCH, + columns: 3, + rows: 10, + inset: 4.0, + }, + } + } +} + +static TEMPLATE: &str = include_str!("../templates/labels.typ"); + +fn engine() -> &'static TypstEngine { + static ENGINE: OnceLock> = OnceLock::new(); + ENGINE.get_or_init(|| { + TypstEngine::builder() + .main_file(TEMPLATE) + .fonts(INTER) + .build() + }) +} + +/// The QR code for `tag` (error correction level M) as an SVG document, +/// its 4-module quiet zone included. +pub fn qr_svg(tag: &str) -> Result { + let code = QrCode::with_error_correction_level(tag.as_bytes(), EcLevel::M) + .map_err(|e| PdfError::Qr(format!("{tag:?}: {e}")))?; + Ok(code + .render::>() + .quiet_zone(true) + .module_dimensions(1, 1) + .dark_color(svg::Color("#000000")) + .light_color(svg::Color("#ffffff")) + .build()) +} + +/// The template's input: the geometry and every sheet's positions, with +/// each label's QR code rendered. +pub fn template_data( + labels: &[AssetLabel], + company: &str, + sheet: LabelSheet, + skip: usize, +) -> Result { + let g = sheet.geometry(); + let per = g.per_sheet(); + if labels.is_empty() { + return Err(PdfError::NoLabels); + } + if skip >= per { + return Err(PdfError::SkipOutOfRange { + skip, + per_sheet: per, + }); + } + let mut positions: Vec = vec![serde_json::Value::Null; skip]; + for l in labels { + positions.push(serde_json::json!({ + "tag": l.tag, + "description": l.description, + "serial": l.serial, + "qr": qr_svg(&l.tag)?, + })); + } + let pages: Vec<_> = positions.chunks(per).map(<[_]>::to_vec).collect(); + Ok(serde_json::json!({ + "company": company, + "geometry": { + "page_width": g.page_width, + "page_height": g.page_height, + "label_width": g.label_width, + "label_height": g.label_height, + "left": g.left, + "top": g.top, + "pitch_x": g.pitch_x, + "pitch_y": g.pitch_y, + "columns": g.columns, + "rows": g.rows, + "inset": g.inset, + }, + "pages": pages, + })) +} + +/// Typeset label sheets into a document (pages, before PDF encoding), so +/// the layout can be inspected. Same arguments and refusals as +/// [`asset_labels_pdf`]. +pub fn typeset_labels( + labels: &[AssetLabel], + company: &str, + sheet: LabelSheet, + skip: usize, +) -> Result { + let data = template_data(labels, company, sheet, skip)?; + let mut inputs = Dict::new(); + inputs.insert("data".into(), data.to_string().into_value()); + engine() + .compile_with_input(inputs) + .output + .map_err(|e| PdfError::Compile(format!("{e:?}"))) +} + +/// Render labels onto sheets, starting `skip` positions into the first sheet (to reuse a partly used sheet), as PDF bytes. +/// +/// See the module documentation for the spec. Refuses an empty `labels` +/// ([`PdfError::NoLabels`]) and `skip` of a whole sheet or more +/// ([`PdfError::SkipOutOfRange`]). +pub fn asset_labels_pdf( + labels: &[AssetLabel], + company: &str, + sheet: LabelSheet, + skip: usize, +) -> Result, PdfError> { + let doc = typeset_labels(labels, company, sheet, skip)?; + typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default()) + .map_err(|e| PdfError::Save(format!("{e:?}"))) +} diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs index 323bb2b..a6fc6c0 100644 --- a/crates/ledger-import/src/lib.rs +++ b/crates/ledger-import/src/lib.rs @@ -6,20 +6,26 @@ //! - [`report`]: the statement layout every renderer draws from. //! - [`xlsx`]: generate XLSX reports. //! - [`pdf`]: generate PDF financial statements (CPA handoff). +//! - [`asset_register`]: the fixed asset register as XLSX and PDF. +//! - [`labels`]: print asset label sheets with QR codes. //! //! All money stays `Decimal`; no floats in money paths. +pub mod asset_register; pub mod bank_csv; pub mod chart_csv; pub mod iif; +pub mod labels; pub mod pdf; pub mod report; pub mod templates; pub mod xlsx; +pub use asset_register::{asset_register_pdf, asset_register_xlsx}; pub use bank_csv::{BankCsvError, BankTransaction, DateOrder, parse_bank_csv, parse_bank_csv_with}; pub use chart_csv::{ChartRow, parse_chart_csv}; pub use iif::{IifAccount, IifError, QbAccountType, iif_accounts, journal_to_iif}; +pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf}; pub use pdf::{PdfError, statement_pdf}; pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount}; pub use templates::{ChartTemplate, EntityType, chart_template}; diff --git a/crates/ledger-import/src/pdf.rs b/crates/ledger-import/src/pdf.rs index aaaeb7f..e78098e 100644 --- a/crates/ledger-import/src/pdf.rs +++ b/crates/ledger-import/src/pdf.rs @@ -25,10 +25,24 @@ pub enum PdfError { /// The PDF writer failed. #[error("pdf save failed: {0}")] Save(String), + /// No labels to print. + #[error("no labels to print")] + NoLabels, + /// The skip would start past the first sheet. + #[error("skip {skip} is out of range: a sheet has {per_sheet} labels, so skip must be 0 to {}", per_sheet - 1)] + SkipOutOfRange { + /// The requested skip. + skip: usize, + /// Labels per sheet. + per_sheet: usize, + }, + /// A tag could not be encoded as a QR code (too long). + #[error("qr code failed: {0}")] + Qr(String), } static TEMPLATE: &str = include_str!("../templates/statement.typ"); -static INTER: [&[u8]; 3] = [ +pub(crate) static INTER: [&[u8]; 3] = [ include_bytes!("../fonts/Inter-Regular.ttf"), include_bytes!("../fonts/Inter-SemiBold.ttf"), include_bytes!("../fonts/Inter-Bold.ttf"), diff --git a/crates/ledger-import/src/xlsx.rs b/crates/ledger-import/src/xlsx.rs index 3260201..d59e49a 100644 --- a/crates/ledger-import/src/xlsx.rs +++ b/crates/ledger-import/src/xlsx.rs @@ -44,25 +44,25 @@ impl From for XlsxError { /// Accounting number formats: the `_)` reserves a closing parenthesis's /// width on positives so decimal points line up with negatives. -const PLAIN: &str = "#,##0.00_);(#,##0.00)"; -const DOLLARS: &str = "$#,##0.00_);($#,##0.00)"; +pub(crate) const PLAIN: &str = "#,##0.00_);(#,##0.00)"; +pub(crate) const DOLLARS: &str = "$#,##0.00_);($#,##0.00)"; -struct Formats { - bold: Format, - company: Format, - centered: Format, - header: Format, - header_right: Format, - plain: Format, - dollars_sum: Format, - dollars_total: Format, - line_label: Format, - sub_label: Format, - note: Format, +pub(crate) struct Formats { + pub(crate) bold: Format, + pub(crate) company: Format, + pub(crate) centered: Format, + pub(crate) header: Format, + pub(crate) header_right: Format, + pub(crate) plain: Format, + pub(crate) dollars_sum: Format, + pub(crate) dollars_total: Format, + pub(crate) line_label: Format, + pub(crate) sub_label: Format, + pub(crate) note: Format, } impl Formats { - fn new() -> Self { + pub(crate) fn new() -> Self { let bold = Format::new().set_bold(); let ruled = |f: Format| f.set_border_top(FormatBorder::Thin); Self { @@ -97,7 +97,7 @@ impl Formats { /// Company, title, and period centered over `width` columns; returns the /// next free row. -fn header( +pub(crate) fn header( sheet: &mut Worksheet, f: &Formats, meta: &ReportMeta, @@ -120,7 +120,7 @@ fn header( } /// Basis, preparation date, and journal seq under the last row. -fn footer( +pub(crate) fn footer( sheet: &mut Worksheet, f: &Formats, meta: &ReportMeta, @@ -143,7 +143,7 @@ fn footer( /// with more than 13 digits before the cents can't show exactly: such an /// amount is written as its exact text instead, right-aligned, so the /// spreadsheet never shows a wrong number. -fn number( +pub(crate) fn number( sheet: &mut Worksheet, row: u32, col: u16, diff --git a/crates/ledger-import/templates/asset_register.typ b/crates/ledger-import/templates/asset_register.typ new file mode 100644 index 0000000..4ee2d1d --- /dev/null +++ b/crates/ledger-import/templates/asset_register.typ @@ -0,0 +1,149 @@ +// The fixed asset register: every asset, its cost, depreciation, and book +// value, then the totals and the reconciliation to the general ledger. +// +// Everything arrives as data in sys.inputs.data (JSON), laid out by +// ledger-import::asset_register, with amounts already split into exact +// parts. The template only typesets: no arithmetic, and no text is ever +// parsed as markup. +// +// Shape: { company, title, period, footer, columns: [10 headings], empty, +// lines: [{ tag, description, serial, in_service, cost, salvage, +// life, accumulated, book_value, status, disposed }], +// totals: [cost, accumulated, book_value], +// reconciliation: [{ label, amount, difference }] } +// where every amount is { digits: "1,234.56", negative }. + +#let d = json(bytes(sys.inputs.data)) +#let ink = luma(25) +#let quiet = luma(110) +#let hair = 0.5pt + luma(160) +#let firm = 0.6pt + ink + +#set document(title: d.company + " — " + d.title, author: d.company) +#set text(font: "Inter", size: 8pt, fill: ink, number-width: "tabular", hyphenate: false) +#set page( + paper: "us-letter", + flipped: true, + margin: (x: 0.6in, top: 0.7in, bottom: 0.7in), + header: context { + if counter(page).get().first() > 1 { + set text(7.5pt, fill: quiet) + [#d.company · #d.title #h(1fr) #d.period] + } + }, + footer: context { + set text(7.5pt, fill: quiet) + grid( + columns: (1fr, auto, 1fr), + [], + d.footer, + align(right)[Page #counter(page).display() of #counter(page).final().first()], + ) + }, +) + +#align(center)[ + #text(15pt, fill: luma(45), d.company) \ + #v(4pt) + #text(10.5pt, weight: "bold", d.title) \ + #v(2pt) + #d.period +] +#v(12pt) + +// An amount: negatives in parentheses, the closing one's width reserved on +// every row so decimal points line up; "$" on totals. +#let amount(a, currency: false) = { + let open = if a.negative { [(] } else { [] } + let close = if a.negative { [)] } else { hide[)] } + let sign = if currency [\$] else [] + [#open#sign#a.digits#close] +} + +// Columns: 4-8 are numbers (right-aligned). +#let numeric = (4, 5, 6, 7, 8) +#let ncols = d.columns.len() +#let full(body, ..args) = table.cell(colspan: ncols, ..args, body) + +#table( + // Number columns are fixed widths (headings wrap); text takes the rest. + columns: (auto, 1.4fr, auto, auto, 6.4em, 5.6em, 6em, 7.4em, 6.4em, 1fr), + stroke: none, + inset: (x: 3.5pt, y: 3.4pt), + align: (col, row) => (if col in numeric { right } else { left }) + (if row == 0 { bottom } else { horizon }), + table.header( + ..d.columns.map(c => table.cell( + stroke: (top: firm, bottom: hair), + text(6.5pt, weight: "bold", tracking: 0.04em, upper(c)), + )), + ), + ..if d.lines.len() == 0 { + (full(emph(d.empty)),) + } else { + d + .lines + .map(l => { + let t(s) = if l.disposed { text(fill: quiet, s) } else { s } + let a(x) = t(amount(x)) + ( + t(l.tag), + t(l.description), + t(l.serial), + t(l.in_service), + a(l.cost), + a(l.salvage), + t(l.life), + a(l.accumulated), + a(l.book_value), + t(l.status), + ) + }) + .flatten() + }, + // Totals of the assets held: rule above, double rule beneath. + ..{ + let s = (top: firm) + let tot(x) = table.cell(stroke: s, text(weight: "bold", amount(x, currency: true))) + let blank = table.cell(stroke: s, []) + ( + table.cell(stroke: s, colspan: 4, text(weight: "bold", "Total, assets held")), + tot(d.totals.at(0)), + blank, + blank, + tot(d.totals.at(1)), + tot(d.totals.at(2)), + blank, + ) + }, + full( + inset: (x: 0pt, y: 0pt), + stack(spacing: 1.6pt, line(length: 100%, stroke: firm), line(length: 100%, stroke: firm)), + ), +) + +#v(14pt) +#block(breakable: false, { + text(weight: "semibold", "Reconciliation to the books") + v(2pt) + table( + columns: (auto, 9em), + stroke: none, + inset: (x: 4pt, y: 3.4pt), + align: (left + horizon, right + horizon), + ..d + .reconciliation + .map(x => { + if x.difference { + let s = (top: hair) + ( + table.cell(stroke: s, text(weight: "bold", x.label)), + table.cell(stroke: s, text(weight: "bold", amount(x.amount, currency: true))), + table.cell(colspan: 2, inset: 2pt, []), + ) + } else { + (pad(left: 1.4em, x.label), amount(x.amount)) + } + }) + .flatten(), + ) +}) diff --git a/crates/ledger-import/templates/labels.typ b/crates/ledger-import/templates/labels.typ new file mode 100644 index 0000000..ac8e560 --- /dev/null +++ b/crates/ledger-import/templates/labels.typ @@ -0,0 +1,91 @@ +// Asset label sheets: one tag per label, with its QR code. +// +// Everything arrives as data in sys.inputs.data (JSON), built by +// ledger-import::labels: the sheet geometry (in points, from +// docs/spec/sources/avery-5160.md), and one entry per sheet position. The +// template only typesets: no label text is ever parsed as markup, and every +// label is drawn inside a clipped box the size of its cell, so nothing can +// print past its edge. +// +// Shape: { company, geometry: { page_width, page_height, label_width, +// label_height, left, top, pitch_x, pitch_y, columns, rows, inset }, +// pages: [[null | { tag, description, serial: null | str, qr: svg }]] } +// Each inner list is one sheet, positions in reading order (left to right, +// then top to bottom); null is a position left blank. + +#let d = json(bytes(sys.inputs.data)) +#let g = d.geometry +#let pt1 = 1pt +#let ink = luma(15) +#let quiet = luma(90) + +#set document(title: d.company + " — Asset labels", author: d.company) +#set text(font: "Inter", fill: ink, number-width: "tabular", hyphenate: false) +#set par(leading: 0.28em, spacing: 0pt) +#set page(width: g.page_width * pt1, height: g.page_height * pt1, margin: 0pt) + +// The inner box every label's content lives in. +#let inset = g.inset * pt1 +#let cell-w = g.label_width * pt1 +#let cell-h = g.label_height * pt1 +// The QR code (its 4-module quiet zone included) fills the label's height. +#let qr-size = cell-h - 2 * inset +#let gap = 4pt +#let text-w = cell-w - 2 * inset - qr-size - gap + +// The largest of `sizes` at which `s` fits in `lines` lines of `width`; +// at the smallest size, the longest prefix that fits, ending in "…". +// Text is set as a string, so it is always literal. +#let fit(s, width, sizes, lines, weight: "regular", fill: ink) = context { + let set-at(size, body) = block(width: width, text(size: size, weight: weight, fill: fill, body)) + let limit(size) = measure(set-at(size, ("X\n" * lines).trim())).height + 0.01pt + // Fits: wraps into `lines` lines, and no word is wider than the box + // (an unbreakable word would overflow sideways, not down). + let fits(size, body) = ( + measure(set-at(size, body)).height <= limit(size) + and body.split(" ").all(w => measure(text(size: size, weight: weight, w)).width <= width) + ) + let chosen = sizes.find(size => fits(size, s)) + if chosen != none { + set-at(chosen, s) + } else { + let size = sizes.last() + let cs = s.clusters() + // Binary search for the longest prefix that fits with the ellipsis. + let lo = 0 + let hi = cs.len() + while lo < hi { + let mid = calc.quo(lo + hi + 1, 2) + if fits(size, cs.slice(0, mid).join().trim() + "…") { lo = mid } else { hi = mid - 1 } + } + let prefix = if lo == 0 { "" } else { cs.slice(0, lo).join().trim() } + set-at(size, prefix + "…") + } +} + +#let label(l) = box(width: cell-w, height: cell-h, clip: true, inset: inset, { + grid( + columns: (qr-size, gap, text-w), + rows: (qr-size,), + image(bytes(l.qr), format: "svg", width: qr-size, height: qr-size), + [], + align(horizon, stack( + spacing: 2.2pt, + fit(d.company, text-w, (6pt, 5.5pt, 5pt), 1, fill: quiet), + fit(l.tag, text-w, (15pt, 13pt, 11pt, 9pt, 8pt, 7pt, 6pt), 1, weight: "bold"), + fit(l.description, text-w, (7.5pt, 7pt, 6.5pt, 6pt), 2), + if l.serial != none { fit("S/N " + l.serial, text-w, (6.5pt, 6pt, 5.5pt, 5pt), 1, fill: quiet) }, + )), + ) +}) + +#for (i, sheet) in d.pages.enumerate() { + if i > 0 { pagebreak() } + for (p, l) in sheet.enumerate() { + if l != none { + let col = calc.rem(p, g.columns) + let row = calc.quo(p, g.columns) + place(top + left, dx: (g.left + col * g.pitch_x) * pt1, dy: (g.top + row * g.pitch_y) * pt1, label(l)) + } + } +} diff --git a/crates/ledger-import/tests/asset_register.rs b/crates/ledger-import/tests/asset_register.rs new file mode 100644 index 0000000..275e42a --- /dev/null +++ b/crates/ledger-import/tests/asset_register.rs @@ -0,0 +1,221 @@ +//! Spec tests for the fixed asset register PDF +//! (`ledger_import::asset_register`); the XLSX checks live in +//! spec_import.rs beside its workbook reader. Each test names the module +//! INVARIANT it checks. + +use chrono::NaiveDate; +use ledger_core::{AssetRegister, AssetRegisterRow}; +use ledger_import::asset_register::{layout, typeset_asset_register}; +use ledger_import::report::ReportMeta; +use ledger_import::{PdfError, asset_register_pdf}; +use rust_decimal::Decimal; +use typst::layout::{Frame, FrameItem}; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +fn meta() -> ReportMeta { + ReportMeta { + company: "Wanderland Labs LLC".into(), + basis: "Accrual".into(), + prepared_on: d(2026, 10, 15), + journal_seq: 314, + } +} + +fn row(id: u64, tag: &str, desc: &str, cost: i64, acc: i64) -> AssetRegisterRow { + AssetRegisterRow { + asset_id: id, + tag: Some(tag.into()), + description: desc.into(), + serial: None, + in_service: d(2026, 1, 15), + cost: cents(cost), + salvage: Decimal::ZERO, + useful_life_months: 36, + accumulated: cents(acc), + book_value: cents(cost - acc), + disposed: None, + } +} + +fn register(rows: Vec) -> AssetRegister { + let held = || rows.iter().filter(|r| r.disposed.is_none()); + let total_cost = held().map(|r| r.cost).sum(); + let total_accumulated = held().map(|r| r.accumulated).sum(); + AssetRegister { + as_of: d(2026, 10, 15), + total_book_value: total_cost - total_accumulated, + ledger_cost: total_cost, + ledger_accumulated: total_accumulated - cents(1234), + total_cost, + total_accumulated, + rows, + } +} + +fn sample() -> AssetRegister { + let mut disposed = row(3, "WL-0003", "Old monitor", 40000, 0); + disposed.book_value = Decimal::ZERO; + disposed.disposed = Some((d(2026, 9, 30), cents(4500))); + let mut laptop = row( + 1, + "WL-0001", + "Framework Laptop 13 (Ryzen 7 7840U, 64 GB, 4 TB)", + 250000, + 52778, + ); + laptop.serial = Some("FRANBMCP0A1234".into()); + laptop.salvage = cents(10000); + register(vec![ + laptop, + row(2, "WL-0002", "MacBook Air", 129900, 32475), + disposed, + ]) +} + +fn texts(frame: &Frame, out: &mut String) { + for (_, item) in frame.items() { + match item { + FrameItem::Group(g) => texts(&g.frame, out), + FrameItem::Text(t) => { + out.push_str(&t.text); + out.push(' '); + } + _ => {} + } + } +} + +fn all_text(r: &AssetRegister, m: &ReportMeta) -> String { + let doc = typeset_asset_register(r, m).expect("typesets"); + let mut out = String::new(); + for p in doc.pages() { + texts(&p.frame, &mut out); + } + // Runs end at line breaks: collapse the seams to single spaces. + out.split_whitespace().collect::>().join(" ") +} + +/// INVARIANT 6: the same register renders to the same bytes. +#[test] +fn pdf_is_deterministic() { + let r = sample(); + let a = asset_register_pdf(&r, &meta()).unwrap(); + assert!(a.starts_with(b"%PDF")); + assert_eq!(a, asset_register_pdf(&r, &meta()).unwrap()); +} + +/// INVARIANTS 1, 2, 3, 5: header, every asset, the status of each, the +/// register's totals, and the reconciliation with both differences. +#[test] +fn pdf_shows_assets_totals_and_reconciliation() { + let t = all_text(&sample(), &meta()); + for s in [ + "Wanderland Labs LLC", + "Fixed Asset Register", + "As of October 15, 2026", + "WL-0001", + "FRANBMCP0A1234", + "MacBook Air", + "Active", + "Disposed 2026-09-30, proceeds 45.00", + "Total, assets held", + "3,799.00", // cost held: 2,500.00 + 1,299.00 + "852.53", // accumulated held + "2,946.47", // book value held + "Cost per register", + "Cost per books", + "Difference (unregistered cost)", + "Accumulated depreciation per schedule", + "Accumulated depreciation per books", + "Difference (not yet posted)", + "12.34", + "Journal seq 314", + ] { + assert!(t.contains(s), "{s:?} missing from {t:?}"); + } + // The disposed asset's cost (400.00) is shown but in no total. + assert!(t.contains("400.00")); + let l = layout(&sample()); + assert_eq!(l.totals, [cents(379900), cents(85253), cents(294647)]); +} + +/// INVARIANT 4: an empty register renders a "No fixed assets" line, its +/// totals, and the reconciliation. +#[test] +fn empty_register_renders() { + let r = register(vec![]); + let pdf = asset_register_pdf(&r, &meta()).expect("empty renders"); + assert!(pdf.starts_with(b"%PDF")); + let t = all_text(&r, &meta()); + for s in [ + "No fixed assets", + "Total, assets held", + "Difference (not yet posted)", + ] { + assert!(t.contains(s), "{s:?} missing from {t:?}"); + } +} + +/// INVARIANT 6: markup in every text field prints literally and never runs. +#[test] +fn text_is_data() { + let mut a = row(1, "#WL-*1*", "#panic() *bold* $x$ @r [x] \\", 100, 0); + a.serial = Some("_u_ // /*".into()); + let m = ReportMeta { + company: "#co *Inc*".into(), + ..meta() + }; + let t = all_text(®ister(vec![a]), &m); + for s in [ + "#WL-*1*", "#panic()", "*bold*", "$x$", "", "@r", "[x]", "_u_", "#co", "*Inc*", + ] { + assert!(t.contains(s), "{s:?} not literal in {t:?}"); + } + for s in [ + "#panic()", "*", "_", "$", "[", "]", "\\", "#", "", "@a", "//", "/*", "\"", "`", + ] { + let mut a = row(1, s, s, 100, 0); + a.serial = Some(s.into()); + let m = ReportMeta { + company: s.into(), + ..meta() + }; + let r: Result, PdfError> = asset_register_pdf(®ister(vec![a]), &m); + r.unwrap_or_else(|e| panic!("{s:?}: {e}")); + } +} + +/// A long register flows onto more landscape pages instead of being cut. +#[test] +fn long_register_paginates_landscape() { + let rows = (1..=120) + .map(|i| { + row( + i, + &format!("WL-{i:04}"), + &format!("Asset {i}"), + 100_000, + 1_000, + ) + }) + .collect(); + let doc = typeset_asset_register(®ister(rows), &meta()).unwrap(); + assert!(doc.pages().len() > 1); + let size = doc.pages()[0].frame.size(); + assert!(size.x > size.y, "landscape"); +} + +/// The sample register for eyeballing: writes `$REGISTER_SAMPLE` (a path). +#[test] +#[ignore = "writes a sample PDF for a human to look at"] +fn write_sample() { + let out = std::env::var("REGISTER_SAMPLE").expect("set REGISTER_SAMPLE to the output path"); + std::fs::write(out, asset_register_pdf(&sample(), &meta()).unwrap()).unwrap(); +} diff --git a/crates/ledger-import/tests/labels.rs b/crates/ledger-import/tests/labels.rs new file mode 100644 index 0000000..8a3e04c --- /dev/null +++ b/crates/ledger-import/tests/labels.rs @@ -0,0 +1,393 @@ +//! Spec tests for `ledger_import::labels` (asset label sheets). Each test +//! names the module INVARIANT it checks. + +use ledger_import::labels::{SheetGeometry, qr_svg, template_data, typeset_labels}; +use ledger_import::{AssetLabel, LabelSheet, PdfError, asset_labels_pdf}; +use typst::layout::{Abs, Frame, FrameItem, Point, Transform}; + +const COMPANY: &str = "Wanderland Labs LLC"; + +fn label(i: usize) -> AssetLabel { + AssetLabel { + tag: format!("WL-{i:04}"), + description: format!("Asset number {i}"), + serial: i.is_multiple_of(2).then(|| format!("SN{i:08}")), + } +} + +fn labels(n: usize) -> Vec { + (1..=n).map(label).collect() +} + +fn pages(n: usize, skip: usize) -> usize { + typeset_labels(&labels(n), COMPANY, LabelSheet::Avery5160, skip) + .expect("typesets") + .pages() + .len() +} + +/// SOURCE: docs/spec/sources/avery-5160.md, figure by figure, in inches. +#[test] +fn avery_5160_geometry_matches_the_vendored_spec() { + let g = LabelSheet::Avery5160.geometry(); + let inch = |x: f64| x * 72.0; + assert_eq!(g.page_width, inch(8.5)); + assert_eq!(g.page_height, inch(11.0)); + assert_eq!(g.label_width, inch(2.625)); + assert_eq!(g.label_height, inch(1.0)); + assert_eq!(g.top, inch(0.5)); + assert_eq!(g.left, inch(0.1875)); + assert_eq!(g.pitch_x, inch(2.75)); + assert_eq!(g.pitch_y, inch(1.0)); + assert_eq!((g.columns, g.rows, g.per_sheet()), (3, 10, 30)); + // The template's cell edges, in PDF points from the bottom-left corner. + let lefts: Vec = (0..3).map(|c| g.cell(c).0).collect(); + assert_eq!(lefts, [13.5, 211.5, 409.5]); + let bottoms: Vec = (0..10) + .map(|r| g.page_height - (g.cell(r * 3).1 + g.label_height)) + .collect(); + assert_eq!( + bottoms, + [ + 684.0, 612.0, 540.0, 468.0, 396.0, 324.0, 252.0, 180.0, 108.0, 36.0 + ] + ); + // Margins and gaps close the page exactly. + let gap = g.pitch_x - g.label_width; + assert_eq!(gap, inch(0.125)); + assert_eq!(2.0 * g.left + 3.0 * g.label_width + 2.0 * gap, g.page_width); + assert_eq!(2.0 * g.top + 10.0 * g.pitch_y, g.page_height); +} + +/// INVARIANT 3: pages = ceil((skip + labels) / 30). +#[test] +fn one_page_per_sheet() { + assert_eq!(pages(1, 0), 1); + assert_eq!(pages(30, 0), 1); + assert_eq!(pages(30, 1), 2); + assert_eq!(pages(61, 0), 3); + assert_eq!(pages(7, 23), 1); + assert_eq!(pages(8, 23), 2); +} + +/// INVARIANT 7: a skip of a whole sheet or more, and no labels, are refused. +#[test] +fn refusals() { + let s = LabelSheet::Avery5160; + for skip in [30, 31, usize::MAX] { + let e = asset_labels_pdf(&labels(1), COMPANY, s, skip).unwrap_err(); + assert!( + matches!(e, PdfError::SkipOutOfRange { skip: k, per_sheet: 30 } if k == skip), + "{e:?}" + ); + assert!(e.to_string().contains("0 to 29"), "{e}"); + } + let e = asset_labels_pdf(&[], COMPANY, s, 0).unwrap_err(); + assert!(matches!(e, PdfError::NoLabels), "{e:?}"); + assert!(asset_labels_pdf(&labels(1), COMPANY, s, 29).is_ok()); +} + +/// INVARIANT 5: the same input renders to the same bytes. +#[test] +fn deterministic_bytes() { + let ls = labels(5); + let a = asset_labels_pdf(&ls, COMPANY, LabelSheet::Avery5160, 3).unwrap(); + let b = asset_labels_pdf(&ls, COMPANY, LabelSheet::Avery5160, 3).unwrap(); + assert!(a.starts_with(b"%PDF")); + assert_eq!(a, b); +} + +/// A laid-out item with its absolute bounding box on the page, in points +/// from the top-left corner. +#[derive(Debug)] +struct Placed { + text: Option, + image: bool, + x0: f64, + y0: f64, + x1: f64, + y1: f64, +} + +fn walk(frame: &Frame, ts: Transform, out: &mut Vec) { + for (pos, item) in frame.items() { + let at = |dx: Abs, dy: Abs| Point::new(pos.x + dx, pos.y + dy).transform(ts); + match item { + FrameItem::Group(g) => { + let ts = ts + .pre_concat(Transform::translate(pos.x, pos.y)) + .pre_concat(g.transform); + walk(&g.frame, ts, out); + } + FrameItem::Text(t) => { + // The run's ink box: baseline at pos.y; Inter's ascender and + // descender are within 1em above and 0.3em below. + let a = at(Abs::zero(), -t.size); + let b = at(t.width(), t.size * 0.3); + out.push(Placed { + text: Some(t.text.to_string()), + image: false, + x0: a.x.to_pt(), + y0: a.y.to_pt(), + x1: b.x.to_pt(), + y1: b.y.to_pt(), + }); + } + FrameItem::Image(_, size, _) => { + let (a, b) = (at(Abs::zero(), Abs::zero()), at(size.x, size.y)); + out.push(Placed { + text: None, + image: true, + x0: a.x.to_pt(), + y0: a.y.to_pt(), + x1: b.x.to_pt(), + y1: b.y.to_pt(), + }); + } + FrameItem::Shape(..) | FrameItem::Link(..) | FrameItem::Tag(..) => {} + } + } +} + +fn placed(doc: &typst_layout::PagedDocument, page: usize) -> Vec { + let mut out = Vec::new(); + walk(&doc.pages()[page].frame, Transform::identity(), &mut out); + out +} + +fn inside(p: &Placed, (x, y, w, h): (f64, f64, f64, f64)) -> bool { + const EPS: f64 = 1e-6; + p.x0 >= x - EPS && p.y0 >= y - EPS && p.x1 <= x + w + EPS && p.y1 <= y + h + EPS +} + +fn cell_of(g: &SheetGeometry, p: &Placed) -> Option { + (0..g.per_sheet()).find(|&i| inside(p, g.cell(i))) +} + +/// INVARIANTS 1 and 2: every text run and image lies wholly inside one cell, +/// each label's tag and QR code sit in the cell its order and the skip put +/// it in, and the description, however long, never leaves the cell. +#[test] +fn every_label_lies_inside_its_cell_in_order() { + let g = LabelSheet::Avery5160.geometry(); + let mut ls = labels(40); + ls[3].description = "An extraordinarily long description that goes on and on, \ + far longer than any label could hold on two lines at any legible size" + .repeat(3); + ls[4].tag = "WL-0005-LONGER-TAG".to_string(); + // An absurd tag: cut with an ellipsis (the QR code holds it whole), but + // still inside its cell. + ls[6].tag = "X".repeat(60); + ls[5].serial = Some("S".repeat(80)); + let skip = 7; + let doc = typeset_labels(&ls, COMPANY, LabelSheet::Avery5160, skip).unwrap(); + assert_eq!(doc.pages().len(), 2); + for page in 0..2 { + let items = placed(&doc, page); + assert!(!items.is_empty()); + for p in &items { + assert!( + cell_of(&g, p).is_some(), + "page {page}: outside every cell: {p:?}" + ); + } + // The cells holding something are exactly this page's labels. + let mut used: Vec = items.iter().filter_map(|p| cell_of(&g, p)).collect(); + used.sort_unstable(); + used.dedup(); + let expected: Vec = if page == 0 { + (skip..30).collect() + } else { + (0..17).collect() + }; + assert_eq!(used, expected, "page {page}"); + // One QR image per label, in its cell. + let images: Vec = items + .iter() + .filter(|p| p.image) + .filter_map(|p| cell_of(&g, p)) + .collect(); + assert_eq!(images, expected, "page {page}: one QR per label, in order"); + } + // Each tag is printed whole in its own cell. + for (i, l) in ls.iter().enumerate() { + let (page, pos) = ((skip + i) / 30, (skip + i) % 30); + if i == 6 { + assert!(cell_text(&doc, page, pos).contains("XXX…")); + continue; + } + let items = placed(&doc, page); + let text: String = items + .iter() + .filter(|p| cell_of(&g, p) == Some(pos)) + .filter_map(|p| p.text.clone()) + .collect::>() + .join(" "); + assert!( + text.contains(&l.tag), + "label {i} at page {page} pos {pos}: {text:?}" + ); + } +} + +/// The text runs in one cell, joined. +fn cell_text(doc: &typst_layout::PagedDocument, page: usize, pos: usize) -> String { + let g = LabelSheet::Avery5160.geometry(); + placed(doc, page) + .iter() + .filter(|p| cell_of(&g, p) == Some(pos)) + .filter_map(|p| p.text.clone()) + .collect::>() + .join(" ") +} + +/// INVARIANT 6: markup in any field prints literally and never runs. +#[test] +fn text_is_data() { + let l = AssetLabel { + tag: "#WL-*1*".to_string(), + description: "#panic() *bold* $x$".to_string(), + serial: Some("_u_ @ref".to_string()), + }; + let doc = typeset_labels(&[l], " #co", LabelSheet::Avery5160, 0).expect("markup is inert"); + let text = cell_text(&doc, 0, 0); + for s in [ + "#WL-*1*", "#panic()", "*bold*", "$x$", "S/N", "_u_", "@ref", "", "#co", + ] { + assert!(text.contains(s), "{s:?} not printed literally in {text:?}"); + } + // Each markup string alone, in every field, still renders. + for m in [ + "#panic()", "*", "_", "$", "[", "]", "\\", "#", "", "@a", "//", "/*", "\"", "`", + ] { + let l = AssetLabel { + tag: m.to_string(), + description: m.to_string(), + serial: Some(m.to_string()), + }; + asset_labels_pdf(&[l], m, LabelSheet::Avery5160, 0) + .unwrap_or_else(|e| panic!("{m:?}: {e}")); + } +} + +/// INVARIANT 1: an overlong description is shortened with an ellipsis; +/// a short one prints whole. +#[test] +fn long_text_is_ellipsized_short_text_is_whole() { + let long = "word ".repeat(80); + let ls = [ + AssetLabel { + tag: "WL-0001".into(), + description: long.clone(), + serial: None, + }, + AssetLabel { + tag: "WL-0002".into(), + description: "MacBook Air".into(), + serial: None, + }, + ]; + let doc = typeset_labels(&ls, COMPANY, LabelSheet::Avery5160, 0).unwrap(); + let t0 = cell_text(&doc, 0, 0); + assert!(t0.contains('…'), "{t0:?}"); + assert!(t0.matches("word").count() < 80); + let t1 = cell_text(&doc, 0, 1); + assert!(t1.contains("MacBook Air") && !t1.contains('…'), "{t1:?}"); + assert!(!t1.contains("S/N"), "no serial, no S/N line: {t1:?}"); +} + +/// Rasterize the SVG the template receives (qrcode's renderer: one +/// background rect, then `M{x} {y}h{w}v{h}H{x}V{y}` per dark run). +fn rasterize(svg: &str, scale: usize) -> (usize, Vec) { + let attr = |name: &str| -> usize { + let at = svg.find(&format!(" {name}=\"")).unwrap() + name.len() + 3; + svg[at..].split('"').next().unwrap().parse().unwrap() + }; + let (w, h) = (attr("width"), attr("height")); + assert_eq!(w, h); + let d = svg + .split(" d=\"") + .nth(1) + .unwrap() + .split('"') + .next() + .unwrap(); + let mut dark = vec![false; w * w]; + for run in d.split('M').filter(|s| !s.is_empty()) { + let nums: Vec = run + .split(|c: char| c.is_ascii_alphabetic() || c == ' ') + .filter(|s| !s.is_empty()) + .map(|s| s.parse().unwrap()) + .collect(); + let [x, y, rw, rh, ..] = nums[..] else { + panic!("bad run {run:?}") + }; + for yy in y..y + rh { + for xx in x..x + rw { + dark[yy * w + xx] = true; + } + } + } + let n = w * scale; + let px = (0..n * n) + .map(|i| dark[(i / n / scale) * w + (i % n) / scale]) + .collect(); + (n, px) +} + +/// INVARIANT 4: the QR SVG each label prints decodes, with an independent +/// decoder (rqrr), to exactly its tag; and it is the SVG the template gets. +#[test] +fn qr_decodes_to_exactly_the_tag() { + let tags = [ + "WL-0001", + "WL-0002", + "WL-9999", + "A", + "wl-0001 lower case & symbols: /?#", + "WL-0005-WITH-A-RATHER-LONG-TAG", + ]; + for tag in tags { + let svg = qr_svg(tag).unwrap(); + let (n, px) = rasterize(&svg, 4); + let mut img = rqrr::PreparedImage::prepare_from_bitmap(n, n, |x, y| px[y * n + x]); + let grids = img.detect_grids(); + assert_eq!(grids.len(), 1, "{tag:?}: one code"); + let (meta, content) = grids[0].decode().unwrap(); + assert_eq!(content, tag); + assert_eq!(meta.ecc_level, 0, "{tag:?}: level M (rqrr numbers M as 0)"); + } + let data = template_data(&[label(1)], COMPANY, LabelSheet::Avery5160, 0).unwrap(); + assert_eq!(data["pages"][0][0]["qr"], qr_svg("WL-0001").unwrap()); +} + +/// The sample sheet, for eyeballing: writes `$LABELS_SAMPLE` (a path). +/// Run with `LABELS_SAMPLE=/path/out.pdf cargo test -p ledger-import --test +/// labels -- --ignored sample`. +#[test] +#[ignore = "writes a sample PDF for a human to look at"] +fn sample() { + let out = std::env::var("LABELS_SAMPLE").expect("set LABELS_SAMPLE to the output path"); + let ls = [ + AssetLabel { + tag: "WL-0001".into(), + description: "Framework Laptop 13 (Ryzen 7 7840U, 64 GB, 4 TB)".into(), + serial: Some("FRANBMCP0A1234".into()), + }, + AssetLabel { + tag: "WL-0002".into(), + description: "MacBook Air".into(), + serial: None, + }, + AssetLabel { + tag: "WL-0003".into(), + description: "Studio monitor arm with dual gas springs, cable management channel, \ + VESA 75/100 adapter plate, desk clamp and grommet mount, and a spare set of \ + every bolt the box came with" + .into(), + serial: Some("ARM-2026-0099887766".into()), + }, + ]; + let pdf = asset_labels_pdf(&ls, COMPANY, LabelSheet::Avery5160, 0).unwrap(); + std::fs::write(out, pdf).unwrap(); +} diff --git a/crates/ledger-import/tests/spec_import.rs b/crates/ledger-import/tests/spec_import.rs index faf3a7b..2729010 100644 --- a/crates/ledger-import/tests/spec_import.rs +++ b/crates/ledger-import/tests/spec_import.rs @@ -3004,3 +3004,165 @@ fn tax_summary_optional_sections() { assert!(!has(&bare, "Cost of goods sold")); assert!(!has(&bare, "Not yet categorized")); } + +// --------------------------------------------------------------------------- +// Fixed asset register (ledger_import::asset_register) +// --------------------------------------------------------------------------- + +/// A register as ledger_core::fixed_asset_register would return it: two +/// assets held, one disposed (shown, in no total), and books that differ +/// from the register on both counts. +fn asset_register_fixture() -> ledger_core::AssetRegister { + use ledger_core::AssetRegisterRow; + let row = |id, tag: Option<&str>, desc: &str, serial: Option<&str>, cost, acc, disposed| { + AssetRegisterRow { + asset_id: id, + tag: tag.map(Into::into), + description: desc.into(), + serial: serial.map(Into::into), + in_service: d(2026, 1, 15), + cost: cents(cost), + salvage: cents(10000), + useful_life_months: 36, + accumulated: cents(acc), + book_value: cents(cost - acc), + disposed, + } + }; + ledger_core::AssetRegister { + as_of: d(2026, 10, 15), + rows: vec![ + row( + 1, + Some("WL-0001"), + "Framework Laptop 13", + Some("FRAN123"), + 250000, + 52778, + None, + ), + row(2, None, "Desk", None, 120000, 25000, None), + { + let mut r = row( + 3, + Some("WL-0003"), + "Old monitor", + None, + 40000, + 0, + Some((d(2026, 9, 30), cents(4500))), + ); + r.book_value = Decimal::ZERO; + r + }, + ], + total_cost: cents(370000), + total_accumulated: cents(77778), + total_book_value: cents(292222), + ledger_cost: cents(400000), + ledger_accumulated: cents(70000), + } +} + +fn number_cell(cells: &BTreeMap<(u32, u32), Cell>, at: (u32, u32)) -> Decimal { + match cells.get(&at) { + Some(Cell::Num(v)) => parse_num(v), + other => panic!("{at:?}: expected a number, got {other:?}"), + } +} + +fn text_cell(cells: &BTreeMap<(u32, u32), Cell>, at: (u32, u32)) -> String { + match cells.get(&at) { + Some(Cell::Text(t)) => t.clone(), + other => panic!("{at:?}: expected text, got {other:?}"), + } +} + +/// asset_register INVARIANTS 1-3 and 5, read back from the workbook's XML: +/// header, headings, one row per asset in order (disposed marked), the +/// register's own totals, and the reconciliation under Cost and +/// Accumulated depreciation. +#[test] +fn asset_register_xlsx_cells() { + let r = asset_register_fixture(); + let x = ledger_import::asset_register_xlsx(&r, &meta()).unwrap(); + let c = sheet_cells(&x); + assert_eq!(text_cell(&c, (1, 0)), "Spec Test LLC"); + assert_eq!(text_cell(&c, (2, 0)), "Fixed Asset Register"); + assert_eq!(text_cell(&c, (3, 0)), "As of October 15, 2026"); + let heads: Vec = (0..10).map(|i| text_cell(&c, (5, i))).collect(); + assert_eq!( + heads, + [ + "TAG", + "DESCRIPTION", + "SERIAL", + "IN SERVICE", + "COST", + "SALVAGE", + "LIFE (MONTHS)", + "ACCUMULATED DEPRECIATION", + "BOOK VALUE", + "STATUS" + ] + ); + assert_eq!(text_cell(&c, (6, 0)), "WL-0001"); + assert_eq!(text_cell(&c, (6, 1)), "Framework Laptop 13"); + assert_eq!(text_cell(&c, (6, 2)), "FRAN123"); + assert_eq!(text_cell(&c, (6, 3)), "2026-01-15"); + assert_eq!(number_cell(&c, (6, 4)), dec("2500.00")); + assert_eq!(number_cell(&c, (6, 5)), dec("100.00")); + assert_eq!(number_cell(&c, (6, 6)), dec("36")); + assert_eq!(number_cell(&c, (6, 7)), dec("527.78")); + assert_eq!(number_cell(&c, (6, 8)), dec("1972.22")); + assert_eq!(text_cell(&c, (6, 9)), "Active"); + assert_eq!(text_cell(&c, (7, 1)), "Desk"); + assert_eq!(text_cell(&c, (8, 0)), "WL-0003"); + assert_eq!(text_cell(&c, (8, 9)), "Disposed 2026-09-30, proceeds 45.00"); + assert_eq!(number_cell(&c, (8, 8)), Decimal::ZERO); + // Totals: the register's (held assets only), not a re-sum of the rows. + assert_eq!(text_cell(&c, (9, 0)), "Total, assets held"); + assert_eq!(number_cell(&c, (9, 4)), dec("3700.00")); + assert_eq!(number_cell(&c, (9, 7)), dec("777.78")); + assert_eq!(number_cell(&c, (9, 8)), dec("2922.22")); + assert!(!c.contains_key(&(9, 5)), "no salvage total"); + // Reconciliation. + assert_eq!(text_cell(&c, (11, 0)), "Reconciliation to the books"); + let recon = [ + (12, "Cost per register", 4, "3700.00"), + (13, "Cost per books", 4, "4000.00"), + (14, "Difference (unregistered cost)", 4, "300.00"), + (16, "Accumulated depreciation per schedule", 7, "777.78"), + (17, "Accumulated depreciation per books", 7, "700.00"), + (18, "Difference (not yet posted)", 7, "77.78"), + ]; + for (row, label, col, amt) in recon { + assert_eq!(text_cell(&c, (row, 0)), label); + assert_eq!(number_cell(&c, (row, col)), dec(amt), "{label}"); + } + let footer = c + .values() + .any(|v| matches!(v, Cell::Text(t) if t.contains("Journal seq 42"))); + assert!(footer, "footer present"); +} + +/// asset_register INVARIANT 4: an empty register says so, still totals, +/// and still reconciles (the books may carry unregistered cost). +#[test] +fn asset_register_xlsx_empty() { + let r = ledger_core::AssetRegister { + as_of: d(2026, 10, 15), + rows: vec![], + total_cost: Decimal::ZERO, + total_accumulated: Decimal::ZERO, + total_book_value: Decimal::ZERO, + ledger_cost: cents(99900), + ledger_accumulated: Decimal::ZERO, + }; + let c = sheet_cells(&ledger_import::asset_register_xlsx(&r, &meta()).unwrap()); + assert_eq!(text_cell(&c, (6, 0)), "No fixed assets"); + assert_eq!(text_cell(&c, (7, 0)), "Total, assets held"); + assert_eq!(number_cell(&c, (7, 4)), Decimal::ZERO); + assert_eq!(text_cell(&c, (12, 0)), "Difference (unregistered cost)"); + assert_eq!(number_cell(&c, (12, 4)), dec("999.00")); +} diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 3279bb6..9e234bc 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -54,7 +54,7 @@ use rust_decimal::Decimal; use std::sync::{Arc, Mutex}; /// The agent operating contract, delivered via initialize. -const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions. The server reloads the file whenever it changes, with no restart: after each edit, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty. Never put a member's SSN or any TIN in it."; +const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions. The server reloads the file whenever it changes, with no restart: after each edit, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty. Never put a member's SSN or any TIN in it."; /// Max receipt/document upload: 25 MB. const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; @@ -467,6 +467,36 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { out } +/// An asset named by id or by tag (exactly one). +fn resolve_asset(store: &Store, id: Option, tag: Option<&str>) -> Result { + match (id, tag) { + (Some(id), None) => store + .ledger() + .get_asset(id) + .map(|a| a.id) + .ok_or_else(|| format!("unknown asset {id}")), + (None, Some(tag)) => store + .ledger() + .asset_by_tag(tag) + .map(|a| a.id) + .ok_or_else(|| format!("no asset is tagged {tag:?}")), + _ => Err("name the asset by asset_id or by tag (one of them)".into()), + } +} + +/// An asset named by a string that is its current tag or its id. A tag +/// wins: tags like "1001" are allowed, and the label is what the human +/// reads. +fn resolve_asset_ref(store: &Store, r: &str) -> Result { + if let Some(a) = store.ledger().asset_by_tag(r) { + return Ok(a.id); + } + match r.parse::() { + Ok(id) if store.ledger().get_asset(id).is_some() => Ok(id), + _ => Err(format!("no asset has the tag or id {r:?}")), + } +} + /// Every control role and the account filling it, for books.roles. fn roles_out(ledger: &Ledger) -> Vec { ledger_core::control_accounts() @@ -1601,7 +1631,7 @@ impl BooksServer { #[tool( name = "books.show", - description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", + description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", annotations(read_only_hint = true) )] async fn show(&self, params: Parameters) -> Result, String> { @@ -1614,6 +1644,7 @@ impl BooksServer { p.account.is_some(), p.report.is_some(), p.attachment_id.is_some(), + p.asset.is_some(), p.search.is_some(), p.page.is_some(), ] @@ -1622,13 +1653,14 @@ impl BooksServer { .count(); if given != 1 { return Err( - "give exactly one of entry_id, account, report, attachment_id, search, page".into(), + "give exactly one of entry_id, account, report, attachment_id, asset, search, page" + .into(), ); } for d in [&p.start, &p.end].into_iter().flatten() { parse_date(d)?; } - const REPORTS: [&str; 10] = [ + const REPORTS: [&str; 11] = [ "profit_and_loss", "balance_sheet", "cash_flow", @@ -1639,19 +1671,35 @@ impl BooksServer { "vendor_1099", "ar_aging", "ap_aging", + "fixed_asset_register", ]; - const PAGES: [&str; 9] = [ + const PAGES: [&str; 10] = [ "overview", "cash", "profit", "transactions", "reports", "accounts", + "assets", "documents", "company", "journal", ]; - let (target, shown) = if let Some(id) = p.entry_id { + let (target, shown) = if let Some(r) = &p.asset { + let id = resolve_asset_ref(&store, r)?; + let a = ledger.get_asset(id).expect("resolved"); + ( + ViewerTarget::Asset { asset_id: id }, + format!( + "asset {}{}", + a.tag + .as_deref() + .map(|t| format!("{t}, ")) + .unwrap_or_default(), + a.description + ), + ) + } else if let Some(id) = p.entry_id { let e = ledger .get_entry(id) .ok_or_else(|| format!("unknown entry {id}"))?; @@ -2511,6 +2559,123 @@ impl BooksServer { })) } + #[tool( + name = "books.label_asset", + description = "Link a fixed asset to the entry that acquired it (acquisition_entry), or set or change its tag (the label on the thing: letters, digits, - _ . /, at most 32; unique ignoring case and never reused by another asset, even after a retag or disposal) and its manufacturer serial number. The asset is its id or current tag. Journaled. Print labels with books.asset_labels." + )] + async fn label_asset( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = resolve_asset_ref(&store, &p.asset)?; + let clear = p.clear_serial.unwrap_or(false); + if clear && p.serial.is_some() { + return Err("pass serial or clear_serial, not both".into()); + } + if p.tag.is_none() && p.serial.is_none() && !clear && p.acquisition_entry.is_none() { + return Err( + "nothing to change: pass tag, serial, clear_serial, or acquisition_entry".into(), + ); + } + if let Some(entry) = p.acquisition_entry { + store + .ledger() + .check_asset_acquisition(id, entry) + .map_err(|e| e.to_string())?; + } + // Both checked first, so a bad serial doesn't leave a new tag behind. + if let Some(tag) = &p.tag { + store + .ledger() + .check_asset_tag_for(Some(id), tag) + .map_err(|e| e.to_string())?; + } + let serial = if clear { None } else { p.serial.as_deref() }; + if p.serial.is_some() || clear { + store + .ledger() + .check_asset_serial(id, serial) + .map_err(|e| e.to_string())?; + } + if let Some(tag) = &p.tag { + store.set_asset_tag(id, tag).map_err(|e| e.to_string())?; + } + if p.serial.is_some() || clear { + store + .set_asset_serial(id, serial) + .map_err(|e| e.to_string())?; + } + if let Some(entry) = p.acquisition_entry { + store + .set_asset_acquisition(id, entry) + .map_err(|e| e.to_string())?; + } + let a = store.ledger().get_asset(id).expect("resolved"); + Ok(Json(LabelAssetOut { + asset_id: id, + tag: a.tag.clone(), + serial: a.serial.clone(), + acquisition_entry: a.acquired_by, + })) + } + + #[tool( + name = "books.asset_labels", + description = "A printable sheet of asset labels (PDF, base64): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print at 100% scale.", + annotations(read_only_hint = true) + )] + async fn asset_labels( + &self, + params: Parameters, + ) -> Result, String> { + use base64::Engine as _; + let p = params.0; + let store = self.lock()?; + let ledger = store.ledger(); + let assets: Vec<&ledger_core::Asset> = match &p.assets { + Some(refs) => refs + .iter() + .map(|r| { + let id = resolve_asset_ref(&store, r)?; + Ok(ledger.get_asset(id).expect("resolved")) + }) + .collect::>()?, + None => ledger + .registered_assets() + .filter(|a| a.status == ledger_core::AssetStatus::Active && a.tag.is_some()) + .collect(), + }; + let labels = assets + .iter() + .map(|a| { + Ok(ledger_import::AssetLabel { + tag: a.tag.clone().ok_or_else(|| { + format!( + "asset {} has no tag: give it one with books.label_asset first", + a.id + ) + })?, + description: a.description.clone(), + serial: a.serial.clone(), + }) + }) + .collect::, String>>()?; + let pdf = ledger_import::asset_labels_pdf( + &labels, + &self.settings().company.name, + ledger_import::LabelSheet::Avery5160, + p.skip.unwrap_or(0) as usize, + ) + .map_err(|e| e.to_string())?; + Ok(Json(AssetLabelsOut { + file_name: format!("asset-labels-{}.pdf", today()), + content_base64: base64::engine::general_purpose::STANDARD.encode(pdf), + tags: labels.into_iter().map(|l| l.tag).collect(), + })) + } + #[tool( name = "books.settings", description = "The settings in force from wanderbooks.toml (company, fiscal year, tax form, members, basis, receipt rule, cash flow roles, close target), the file they come from, and problems: settings that disagree with the chart, each saying what to change. The server reloads the file whenever it changes, with no restart; after editing it, call this to confirm the edit loaded (reload_error says why one didn't, and the last good settings stay in force).", @@ -2764,7 +2929,7 @@ impl BooksServer { #[tool( name = "books.export", - description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from wanderbooks.toml), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal). Formats: pdf and xlsx (base64) for the five statements; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet and trial_balance.", + description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from wanderbooks.toml), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, and fixed_asset_register.", annotations(read_only_hint = true) )] async fn export(&self, params: Parameters) -> Result, String> { @@ -2811,9 +2976,12 @@ impl BooksServer { "profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?, "balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?, "cash_flow" => self.export_cash_flow(&format, &p)?, + "fixed_asset_register" => { + self.export_fixed_asset_register(&format, p.as_of.as_deref())? + } _ => { return Err(format!( - "bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, or tax_summary", + "bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, or fixed_asset_register", p.report )); } @@ -2893,6 +3061,14 @@ impl BooksServer { let store = self.lock()?; let meta = self.report_meta(&store); let ledger = store.ledger(); + if report == "fixed_asset_register" { + let as_of = match p.as_of.as_deref() { + Some(s) => parse_date(s)?, + None => today(), + }; + let r = ledger_core::fixed_asset_register(ledger, as_of); + return ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string()); + } let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); match report { "general_ledger" => { @@ -2918,6 +3094,14 @@ impl BooksServer { } let store = self.lock()?; let meta = self.report_meta(&store); + if report == "fixed_asset_register" { + let as_of = match p.as_of.as_deref() { + Some(s) => parse_date(s)?, + None => today(), + }; + let r = ledger_core::fixed_asset_register(store.ledger(), as_of); + return ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string()); + } let st = self.layout_statement(store.ledger(), report, p)?; ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string()) } @@ -2979,6 +3163,36 @@ impl BooksServer { let cost_account = resolve_account(&store, &p.cost_account)?; let accum_depr_account = resolve_account(&store, &p.accum_depr_account)?; let expense_account = resolve_account(&store, &p.expense_account)?; + let tag = match p.tag { + Some(t) => t, + None => store.ledger().next_asset_tag(self.settings().tag_prefix()), + }; + // Checked before registering, so a bad tag or serial registers + // nothing. + store + .ledger() + .check_asset_tag_for(None, &tag) + .map_err(|e| e.to_string())?; + if let Some(serial) = &p.serial { + ledger_core::assets::check_serial(serial) + .map_err(|why| format!("bad serial number: {why}"))?; + } + if let Some(entry) = p.acquisition_entry { + let e = store + .ledger() + .get_entry(entry) + .ok_or_else(|| format!("unknown entry {entry}"))?; + if !e + .lines() + .iter() + .any(|l| l.account == cost_account && l.side == Side::Debit) + || e.status() != ledger_core::EntryStatus::Posted + { + return Err(format!( + "entry {entry} can't be the acquisition: it must be posted and debit the cost account" + )); + } + } let asset_id = store .register_asset(NewAsset { description: p.description, @@ -2994,7 +3208,20 @@ impl BooksServer { expense_account, }) .map_err(|e| e.to_string())?; - Ok(Json(RegisterAssetOut { asset_id })) + store + .set_asset_tag(asset_id, &tag) + .map_err(|e| e.to_string())?; + if let Some(serial) = &p.serial { + store + .set_asset_serial(asset_id, Some(serial)) + .map_err(|e| e.to_string())?; + } + if let Some(entry) = p.acquisition_entry { + store + .set_asset_acquisition(asset_id, entry) + .map_err(|e| e.to_string())?; + } + Ok(Json(RegisterAssetOut { asset_id, tag })) } #[tool( @@ -3008,10 +3235,11 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let store = self.lock()?; + let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?; let asset = store .ledger() - .get_asset(p.asset_id) - .ok_or_else(|| format!("unknown asset {}", p.asset_id))? + .get_asset(asset_id) + .ok_or_else(|| format!("unknown asset {asset_id}"))? .clone(); // The schedule clamps to the depreciation end internally, so an // unbounded default just means "the whole life". @@ -3055,6 +3283,9 @@ impl BooksServer { .registered_assets() .map(|a| AssetSummary { asset_id: a.id, + tag: a.tag.clone(), + serial: a.serial.clone(), + acquisition_entry: a.acquired_by, description: a.description.clone(), purchase_date: a.purchase_date.to_string(), cost: money(a.cost.amount), @@ -3150,13 +3381,14 @@ impl BooksServer { let proceeds = Money::new(parse_amount(&p.proceeds)?, base); let proceeds_account = resolve_account(&store, &p.proceeds_account)?; let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?; + let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?; let asset = store .ledger() - .get_asset(p.asset_id) - .ok_or_else(|| format!("unknown asset {}", p.asset_id))? + .get_asset(asset_id) + .ok_or_else(|| format!("unknown asset {asset_id}"))? .clone(); let preview = store - .dispose_preview(p.asset_id, date, proceeds) + .dispose_preview(asset_id, date, proceeds) .map_err(|e| e.to_string())?; // The would-be disposal entry's lines, for the preview and the @@ -3203,7 +3435,7 @@ impl BooksServer { let out = DisposeAssetOut { status: "preview".to_string(), - asset_id: p.asset_id, + asset_id, entry_id: 0, accumulated: money(preview.accumulated), book_value: money(preview.book_value), @@ -3227,7 +3459,7 @@ impl BooksServer { self.settings().policy.describe() ), entry_id: None, - asset_id: Some(p.asset_id), + asset_id: Some(asset_id), debits: debits.to_string(), policy: self.settings().policy.describe(), remedy_tool: "books.file_document".to_string(), @@ -3243,7 +3475,7 @@ impl BooksServer { } let entry_id = store .dispose_asset( - p.asset_id, + asset_id, date, proceeds, proceeds_account, @@ -3930,7 +4162,7 @@ impl BooksServer { #[tool( name = "books.export_cpa_package", - description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", + description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", annotations(read_only_hint = true) )] async fn export_cpa_package( @@ -4011,6 +4243,18 @@ impl BooksServer { files.push((format!("{name}.pdf"), pdf(st)?)); files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?)); } + // The fixed asset register, when there are fixed assets. + if ledger.registered_assets().next().is_some() { + let r = ledger_core::fixed_asset_register(ledger, as_of); + files.push(( + "fixed-asset-register.pdf".into(), + ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string())?, + )); + files.push(( + "fixed-asset-register.xlsx".into(), + ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string())?, + )); + } // The books behind them: by account, and entry by entry. let gl = ledger_core::general_ledger(ledger, fy, as_of).map_err(|e| e.to_string())?; files.push(( @@ -4252,6 +4496,79 @@ impl BooksServer { Ok(csv) } + /// The fixed asset register (ledger_core::fixed_asset_register) as + /// JSON or CSV, reconciled to the books. + fn export_fixed_asset_register( + &self, + format: &str, + as_of: Option<&str>, + ) -> Result { + let store = self.lock()?; + let as_of = match as_of { + Some(s) => parse_date(s)?, + None => today(), + }; + let r = ledger_core::fixed_asset_register(store.ledger(), as_of); + // The same words as the XLSX and PDF register. + let status = |row: &ledger_core::AssetRegisterRow| match row.disposed { + Some((d, p)) => format!("Disposed {d}, proceeds {}", money(p)), + None => "Active".to_string(), + }; + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "as_of": as_of.to_string(), + "assets": r.rows.iter().map(|a| serde_json::json!({ + "asset_id": a.asset_id, + "tag": a.tag, + "description": a.description, + "serial": a.serial, + "in_service": a.in_service.to_string(), + "cost": money(a.cost), + "salvage": money(a.salvage), + "useful_life_months": a.useful_life_months, + "accumulated": money(a.accumulated), + "book_value": money(a.book_value), + "status": status(a), + "proceeds": a.disposed.map(|(_, p)| money(p)), + })).collect::>(), + "total_cost": money(r.total_cost), + "total_accumulated": money(r.total_accumulated), + "total_book_value": money(r.total_book_value), + "ledger_cost": money(r.ledger_cost), + "ledger_accumulated": money(r.ledger_accumulated), + "unposted_depreciation": money(r.unposted_depreciation()), + "unregistered_cost": money(r.unregistered_cost()), + })) + .map_err(|e| e.to_string()); + } + let mut csv = String::from( + "tag,description,serial,in_service,cost,salvage,useful_life_months,accumulated,book_value,status,proceeds\n", + ); + for a in &r.rows { + csv.push_str(&format!( + "{},{},{},{},{},{},{},{},{},{},{}\n", + csv_escape(a.tag.as_deref().unwrap_or("")), + csv_escape(&a.description), + csv_escape(a.serial.as_deref().unwrap_or("")), + a.in_service, + money(a.cost), + money(a.salvage), + a.useful_life_months, + money(a.accumulated), + money(a.book_value), + status(a), + a.disposed.map(|(_, p)| money(p)).unwrap_or_default(), + )); + } + csv.push_str(&format!( + "TOTAL,,,,{},,,{},{},,", + money(r.total_cost), + money(r.total_accumulated), + money(r.total_book_value) + )); + Ok(csv) + } + fn export_general_ledger(&self, format: &str, p: &ExportParams) -> Result { let store = self.lock()?; let ledger = store.ledger(); diff --git a/crates/ledger-mcp/src/settings.rs b/crates/ledger-mcp/src/settings.rs index 34f27fe..ff85339 100644 --- a/crates/ledger-mcp/src/settings.rs +++ b/crates/ledger-mcp/src/settings.rs @@ -47,6 +47,20 @@ pub struct Settings { pub company: Company, /// `[books]` cash flow roles. pub roles: CashRoles, + /// The prefix of generated asset tags (`[books] asset_tag_prefix`, or + /// the company's initials). + pub asset_tag_prefix: String, +} + +impl Settings { + /// The prefix for generated asset tags ("FA" when none is set). + pub fn tag_prefix(&self) -> &str { + if self.asset_tag_prefix.is_empty() { + "FA" + } else { + &self.asset_tag_prefix + } + } } /// Where the settings come from. @@ -81,6 +95,7 @@ impl Source { }; let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)?; Ok(Settings { + asset_tag_prefix: books.asset_tag_prefix(), policy, roles: CashRoles { cash: books.cash_accounts, diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 1430fa5..2cea06f 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -426,14 +426,16 @@ pub struct ShowParams { pub account: Option, /// A report: profit_and_loss, balance_sheet, cash_flow, /// members_equity, trial_balance, general_ledger, tax_summary, - /// vendor_1099, ar_aging, ap_aging. + /// vendor_1099, ar_aging, ap_aging, fixed_asset_register. pub report: Option, /// A stored receipt or document (attachment id). pub attachment_id: Option, + /// A fixed asset, by tag or id. + pub asset: Option, /// Search the transactions list (description, account, vendor, amount). pub search: Option, /// A page: overview, cash, profit, transactions, reports, accounts, - /// documents, company, journal. + /// assets, documents, company, journal. pub page: Option, /// For account and report: first date, YYYY-MM-DD. pub start: Option, @@ -1504,6 +1506,15 @@ pub struct RegisterAssetParams { pub accum_depr_account: String, /// Depreciation expense account, id or code. pub expense_account: String, + /// The asset tag for its label (letters, digits, - _ . /; at most 32). + /// Omit to generate the next one ("WL-0001", from `[books] + /// asset_tag_prefix` or the company's initials). + pub tag: Option, + /// The manufacturer's serial number, if known. + pub serial: Option, + /// The posted entry that put it on the books (its purchase, or a + /// member's contribution of it); it must debit the cost account. + pub acquisition_entry: Option, } /// Result of `books.register_asset`. @@ -1511,6 +1522,61 @@ pub struct RegisterAssetParams { pub struct RegisterAssetOut { /// The new asset's id. pub asset_id: u64, + /// Its tag (given or generated). + pub tag: String, +} + +/// Parameters for `books.label_asset`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct LabelAssetParams { + /// The asset: its id or its current tag. + pub asset: String, + /// A new tag (never another asset's, now or before). Omit to keep it. + pub tag: Option, + /// The manufacturer's serial number. Omit to keep it. + pub serial: Option, + /// True to clear the serial number. + pub clear_serial: Option, + /// Link the posted entry that put it on the books (purchase or + /// contribution; it must debit the asset's cost account and not be + /// reversed). Use after correcting that entry, to link the replacement. + pub acquisition_entry: Option, +} + +/// Result of `books.label_asset`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct LabelAssetOut { + /// Asset id. + pub asset_id: u64, + /// Its tag now. + pub tag: Option, + /// Its serial number now. + pub serial: Option, + /// The entry that acquired it, if linked. + pub acquisition_entry: Option, +} + +/// Parameters for `books.asset_labels`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AssetLabelsParams { + /// The assets to print (ids or tags), in order. Omit for every active + /// asset with a tag. + pub assets: Option>, + /// Label positions to skip on the first sheet, to reuse a partly used + /// one (0 to 29). Default 0. + pub skip: Option, +} + +/// Result of `books.asset_labels`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AssetLabelsOut { + /// A file name for the sheet. + pub file_name: String, + /// The label sheet as PDF, base64. Avery 5160/8160 (US letter, 30 + /// labels of 1" x 2-5/8"); print at 100% scale, not "fit to page". + pub content_base64: String, + /// The tags printed, in order. + pub tags: Vec, } /// One month of a depreciation schedule. @@ -1529,8 +1595,10 @@ pub struct ScheduleLineOut { /// Parameters for `books.asset_schedule`. #[derive(Debug, Deserialize, JsonSchema)] pub struct AssetScheduleParams { - /// Asset id. - pub asset_id: u64, + /// Asset id (or pass `tag`). + pub asset_id: Option, + /// The asset's tag, instead of its id. + pub tag: Option, /// Run the schedule through this date, YYYY-MM-DD. Defaults to the end /// of the asset's depreciable life. pub through: Option, @@ -1560,6 +1628,12 @@ pub struct AssetScheduleOut { pub struct AssetSummary { /// Asset id. pub asset_id: u64, + /// Its tag, if it has one. + pub tag: Option, + /// Its serial number, if recorded. + pub serial: Option, + /// The entry that acquired it, if linked. + pub acquisition_entry: Option, /// Asset description. pub description: String, /// Purchase date, YYYY-MM-DD. @@ -1633,8 +1707,10 @@ pub struct SkippedDepreciation { /// Parameters for `books.dispose_asset`. #[derive(Debug, Deserialize, JsonSchema)] pub struct DisposeAssetParams { - /// Asset id. - pub asset_id: u64, + /// Asset id (or pass `tag`). + pub asset_id: Option, + /// The asset's tag, instead of its id. + pub tag: Option, /// Disposal date, YYYY-MM-DD. pub date: String, /// Cash (or receivable) received, decimal string. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index a61f31b..9b22972 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 70, "expected 70 tools, got {}", tools.len()); + assert_eq!(tools.len(), 72, "expected 72 tools, got {}", tools.len()); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap(); assert_eq!(chart["annotations"]["readOnlyHint"], true); assert!( diff --git a/crates/ledger-mcp/tests/spec_assets.rs b/crates/ledger-mcp/tests/spec_assets.rs new file mode 100644 index 0000000..1f3aca8 --- /dev/null +++ b/crates/ledger-mcp/tests/spec_assets.rs @@ -0,0 +1,891 @@ +//! Spec-blind tests for the fixed-asset tag tools of `ledger-mcp`, driven +//! over stdio against the real binary. +//! +//! Expected behavior comes only from the tool descriptions and parameter / +//! result doc comments in `src/server.rs` and `src/types.rs` (the declared +//! interface, as `tools/list` serves it), `docs/mcp-interface.md` ("Tools — +//! fixed assets"), and `docs/spec/rules.md` B-24. The harness is the +//! minimal subset of `tests/spec_mcp.rs`: every success validates against +//! the tool's `outputSchema`, and every refusal leaves `journal.jsonl` +//! byte-identical (server invariant 2: a refused call changes nothing). + +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(180); + +// ---------------------------------------------------------------- harness + +#[derive(Debug, Clone)] +enum Out { + Ok(Value), + ToolErr(String), + RpcErr(Value), +} + +impl Out { + fn refused(&self) -> bool { + !matches!(self, Out::Ok(_)) + } + fn err_text(&self) -> String { + match self { + Out::Ok(v) => panic!("expected a refusal, got {v}"), + Out::ToolErr(t) => t.clone(), + Out::RpcErr(e) => e.to_string(), + } + } +} + +struct Mcp { + stdin: ChildStdin, + rx: Receiver, + child: Child, + next_id: u64, + data: PathBuf, + validators: HashMap, +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +static DIR_SEQ: AtomicU64 = AtomicU64::new(0); + +fn test_dir(name: &str) -> PathBuf { + let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ledger-mcp-spec-assets-{}-{name}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The +/// receipt threshold is high so ordinary test entries need no receipt. +fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf { + let path = dir.join("wanderbooks.toml"); + std::fs::write( + &path, + format!( + "[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}" + ), + ) + .unwrap(); + path +} + +impl Mcp { + fn start(dir: &Path, toml: &Path) -> Self { + let run = dir.join("run"); + std::fs::create_dir_all(&run).unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) + .arg("--data-dir") + .arg(dir.join("books-data")) + .arg("--books-toml") + .arg(toml) + .env("XDG_RUNTIME_DIR", &run) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ledger-mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let (tx, rx) = channel(); + std::thread::spawn(move || { + for line in stdout.lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + let mut mcp = Self { + stdin, + rx, + child, + next_id: 1, + data: dir.join("books-data"), + validators: HashMap::new(), + }; + let init = mcp.rpc( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "spec-assets", "version": "0.1.0"}, + }), + ); + assert!(init.get("result").is_some(), "initialize failed: {init}"); + mcp.send_line( + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(), + ); + let list = mcp.rpc("tools/list", json!({})); + for tool in list["result"]["tools"].as_array().expect("tools") { + let name = tool["name"].as_str().unwrap().to_string(); + let validator = jsonschema::validator_for(&tool["outputSchema"]) + .unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}")); + mcp.validators.insert(name, validator); + } + mcp + } + + fn journal(&self) -> Vec { + std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default() + } + + /// The journal's line types, in order. + fn journal_types(&self) -> Vec { + String::from_utf8(self.journal()) + .unwrap() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let v: Value = serde_json::from_str(l).unwrap(); + v["type"] + .as_str() + .or_else(|| v["line_type"].as_str()) + .unwrap_or_default() + .to_string() + }) + .collect() + } + + fn send_line(&mut self, line: &str) { + writeln!(self.stdin, "{line}").unwrap(); + self.stdin.flush().unwrap(); + } + + fn recv(&mut self) -> Option { + loop { + match self.rx.recv_timeout(TIMEOUT) { + Ok(line) => { + let v: Value = serde_json::from_str(&line) + .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); + if v.get("id").is_none() && v.get("method").is_some() { + continue; + } + return Some(v); + } + Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, + } + } + } + + fn rpc(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + self.send_line(&msg.to_string()); + let resp = self + .recv() + .unwrap_or_else(|| panic!("no response to {method} (id {id})")); + assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}"); + resp + } + + /// Call a tool; a refusal must leave the journal unchanged, a success + /// must match the outputSchema. + fn tool(&mut self, name: &str, args: Value) -> Out { + let before = self.journal(); + let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let out = if let Some(err) = resp.get("error") { + Out::RpcErr(err.clone()) + } else { + let result = &resp["result"]; + if result.get("isError") == Some(&json!(true)) { + Out::ToolErr( + result["content"] + .as_array() + .and_then(|c| c.iter().find_map(|b| b["text"].as_str())) + .unwrap_or_default() + .to_string(), + ) + } else { + let sc = result["structuredContent"].clone(); + let v = self + .validators + .get(name) + .unwrap_or_else(|| panic!("{name} is not in tools/list")); + let errors: Vec = v.iter_errors(&sc).map(|e| e.to_string()).collect(); + assert!( + errors.is_empty(), + "{name} violates its outputSchema: {errors:?}\n{sc}" + ); + Out::Ok(sc) + } + }; + if out.refused() { + assert!( + self.journal() == before, + "refused {name} {args} changed the journal ({out:?})" + ); + } + out + } + + fn ok(&mut self, name: &str, args: Value) -> Value { + let a = args.to_string(); + match self.tool(name, args) { + Out::Ok(v) => v, + other => panic!("{name} {a}: expected success, got {other:?}"), + } + } + + /// `tool`, expecting a refusal that says why. + fn refuse(&mut self, name: &str, args: Value) -> String { + let a = args.to_string(); + let out = self.tool(name, args); + assert!(out.refused(), "{name} {a} should be refused, got {out:?}"); + let why = out.err_text(); + assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why"); + why + } + + /// `ok`, and the journal must not change (read-only tools). + fn read_only(&mut self, name: &str, args: Value) -> Value { + let before = self.journal(); + let v = self.ok(name, args); + assert!( + self.journal() == before, + "read-only {name} changed the journal" + ); + v + } + + fn accounts(&mut self, accts: &[(&str, &str, &str)]) { + for (code, name, ty) in accts { + self.ok( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + ); + } + } + + /// Post an entry (high receipt threshold: none needed). + fn post(&mut self, date: &str, lines: &[(&str, &str, &str)]) { + let id = self.ok( + "books.draft_entry", + json!({"date": date, "description": "test entry"}), + )["entry_id"] + .as_u64() + .unwrap(); + for (account, side, amount) in lines { + self.ok( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + ); + } + self.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + } + + /// Register a $1,200, 12-month laptop from 2026-01-01 with `extra` args + /// (tag, serial, description). + fn register(&mut self, extra: Value) -> Out { + let mut args = json!({"description": "Laptop", "purchase_date": "2026-01-01", + "cost": "1200.00", "salvage_value": "0.00", "useful_life_months": 12, + "cost_account": "1500", "accum_depr_account": "1510", "expense_account": "6500"}); + for (k, v) in extra.as_object().unwrap() { + args[k] = v.clone(); + } + self.tool("books.register_asset", args) + } + + fn register_ok(&mut self, extra: Value) -> (u64, String) { + match self.register(extra.clone()) { + Out::Ok(v) => ( + v["asset_id"].as_u64().unwrap(), + v["tag"].as_str().unwrap().to_string(), + ), + other => panic!("register {extra}: {other:?}"), + } + } + + fn assets(&mut self) -> Vec { + self.ok("books.list_assets", json!({}))["assets"] + .as_array() + .unwrap() + .clone() + } + + fn asset(&mut self, id: u64) -> Value { + self.assets() + .into_iter() + .find(|a| a["asset_id"] == json!(id)) + .unwrap_or_else(|| panic!("asset {id} not listed")) + } + + fn file_bill_of_sale(&mut self) -> u64 { + self.ok( + "books.file_document", + json!({"category": "legal", "file_name": "bill-of-sale.txt", "mime_type": "text/plain", + "content_base64": b64(b"bill of sale"), "description": "Bill of sale"}), + )["attachment_id"] + .as_u64() + .unwrap() + } +} + +const CHART: &[(&str, &str, &str)] = &[ + ("1000", "Bank", "asset"), + ("1500", "Equipment", "asset"), + ("1510", "Accumulated Depreciation", "asset"), + ("3000", "Capital", "equity"), + ("6500", "Depreciation Expense", "expense"), + ("7100", "Gain or Loss on Disposal", "revenue"), +]; + +/// Books with the chart, generated tags prefixed "WL". +fn books(name: &str) -> Mcp { + let dir = test_dir(name); + let toml = books_toml(&dir, "Spec Co", "asset_tag_prefix = \"WL\"\n"); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m +} + +fn b64(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let n = (u32::from(chunk[0]) << 16) + | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) + | u32::from(*chunk.get(2).unwrap_or(&0)); + for i in 0..4 { + if i <= chunk.len() { + out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +fn unb64(s: &str) -> Vec { + let val = |c: u8| -> u32 { + match c { + b'A'..=b'Z' => u32::from(c - b'A'), + b'a'..=b'z' => u32::from(c - b'a') + 26, + b'0'..=b'9' => u32::from(c - b'0') + 52, + b'+' => 62, + b'/' => 63, + _ => panic!("not base64: {c}"), + } + }; + let clean: Vec = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + let mut out = Vec::new(); + for chunk in clean.chunks(4) { + let pad = chunk.iter().filter(|&&b| b == b'=').count(); + let mut n = 0u32; + for (i, &b) in chunk.iter().enumerate() { + n |= if b == b'=' { 0 } else { val(b) } << (18 - 6 * i); + } + let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8]; + out.extend_from_slice(&bytes[..3 - pad]); + } + out +} + +/// Decimal strings compared by value. +fn canon(s: &str) -> String { + let (neg, s) = match s.strip_prefix('-') { + Some(r) => (true, r), + None => (false, s), + }; + let (int, frac) = s.split_once('.').unwrap_or((s, "")); + let int = int.trim_start_matches('0'); + let frac = frac.trim_end_matches('0'); + let int = if int.is_empty() { "0" } else { int }; + let body = if frac.is_empty() { + int.to_string() + } else { + format!("{int}.{frac}") + }; + if neg && body != "0" { + format!("-{body}") + } else { + body + } +} + +/// Every value under `key` anywhere in `v`. +fn find_key<'a>(v: &'a Value, key: &str, out: &mut Vec<&'a Value>) { + match v { + Value::Object(m) => { + for (k, child) in m { + if k == key { + out.push(child); + } + find_key(child, key, out); + } + } + Value::Array(a) => a.iter().for_each(|c| find_key(c, key, out)), + _ => {} + } +} + +fn the_value(v: &Value, key: &str) -> String { + let mut found = Vec::new(); + find_key(v, key, &mut found); + assert_eq!(found.len(), 1, "{key} in {v}"); + match found[0] { + Value::String(s) => canon(s), + other => panic!("{key} is not a decimal string: {other}"), + } +} + +// ------------------------------------------------- books.register_asset + +/// "Without `tag`, the next free `PREFIX-0001` ([books] asset_tag_prefix)"; +/// result `{ asset_id, tag }` (the tag given or generated). +#[test] +fn register_generates_the_next_tag_from_the_prefix() { + let mut m = books("gen"); + let (a, ta) = m.register_ok(json!({})); + assert_eq!(ta, "WL-0001"); + let (_, tb) = m.register_ok(json!({"description": "Monitor"})); + assert_eq!(tb, "WL-0002"); + let (c, tc) = m.register_ok(json!({"tag": "DESK-9", "serial": "SN 9"})); + assert_eq!(tc, "DESK-9"); + let (_, td) = m.register_ok(json!({})); + assert_eq!(td, "WL-0003"); + // list_assets carries tag and serial. + let la = m.asset(a); + assert_eq!(la["tag"], "WL-0001"); + assert_eq!(la["serial"], Value::Null); + let lc = m.asset(c); + assert_eq!(lc["tag"], "DESK-9"); + assert_eq!(lc["serial"], "SN 9"); +} + +/// "else the company name's initials without LLC/Inc". +#[test] +fn register_default_prefix_is_the_company_initials() { + let dir = test_dir("initials"); + let toml = books_toml(&dir, "Wanderland Labs LLC", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + let (_, t) = m.register_ok(json!({})); + assert_eq!(t, "WL-0001"); +} + +/// "\"FA\" when none." +#[test] +fn register_default_prefix_falls_back_to_fa() { + let dir = test_dir("fa"); + let toml = books_toml(&dir, "LLC", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + let (_, t) = m.register_ok(json!({})); + assert_eq!(t, "FA-0001"); +} + +/// A bad tag or serial registers nothing (the harness checks the journal +/// is unchanged on every refusal), and the next generated tag is unmoved. +#[test] +fn register_with_a_bad_tag_or_serial_registers_nothing() { + let mut m = books("bad-register"); + let (_, t) = m.register_ok(json!({})); + assert_eq!(t, "WL-0001"); + let n = m.assets().len(); + for extra in [ + json!({"tag": "has space"}), + json!({"tag": ""}), + json!({"tag": "Ü-1"}), + json!({"tag": "A".repeat(33)}), + json!({"tag": "---"}), + json!({"tag": "wl-0001"}), // taken, ignoring case + json!({"serial": " lead"}), + json!({"serial": "trail "}), + json!({"serial": ""}), + json!({"serial": "ctl\u{1}"}), + json!({"serial": "S".repeat(65)}), + ] { + assert!( + m.register(extra.clone()).refused(), + "{extra} should be refused" + ); + } + assert_eq!(m.assets().len(), n, "nothing registered"); + let (_, t) = m.register_ok(json!({})); + assert_eq!(t, "WL-0002"); +} + +/// A generated tag is never one any asset held (B-24). +#[test] +fn register_never_generates_a_released_tag() { + let mut m = books("released"); + let (a, _) = m.register_ok(json!({})); + m.ok( + "books.label_asset", + json!({"asset": a.to_string(), "tag": "LAPTOP-A"}), + ); + let (_, t) = m.register_ok(json!({})); + assert_eq!(t, "WL-0002"); + // And a released tag can't be given to a new asset explicitly. + assert!(m.register(json!({"tag": "WL-0001"})).refused()); +} + +// ------------------------------------------------- books.label_asset + +/// "The asset is its id or current tag"; result `{ asset_id, tag, serial }`; +/// journaled as `asset.tagged`, `asset.serial_set`. +#[test] +fn label_asset_sets_tag_and_serial_by_id_or_tag() { + let mut m = books("label"); + let (a, _) = m.register_ok(json!({})); + let before = m.journal_types().len(); + let r = m.ok( + "books.label_asset", + json!({"asset": a.to_string(), "serial": "C02XG0FDJGH5"}), + ); + assert_eq!(r["asset_id"], json!(a)); + assert_eq!(r["tag"], "WL-0001", "omitted tag is kept"); + assert_eq!(r["serial"], "C02XG0FDJGH5"); + let r = m.ok( + "books.label_asset", + json!({"asset": "wl-0001", "tag": "LAPTOP-1"}), + ); + assert_eq!(r["asset_id"], json!(a)); + assert_eq!(r["tag"], "LAPTOP-1"); + assert_eq!(r["serial"], "C02XG0FDJGH5", "omitted serial is kept"); + let types = m.journal_types(); + let new: Vec<&str> = types[before..].iter().map(String::as_str).collect(); + assert!(new.contains(&"asset.serial_set"), "{new:?}"); + assert!(new.contains(&"asset.tagged"), "{new:?}"); + // clear_serial. + let r = m.ok( + "books.label_asset", + json!({"asset": "LAPTOP-1", "clear_serial": true}), + ); + assert_eq!(r["serial"], Value::Null); + assert_eq!(r["tag"], "LAPTOP-1"); + let l = m.asset(a); + assert_eq!(l["tag"], "LAPTOP-1"); + assert_eq!(l["serial"], Value::Null); + // The old tag is no longer current: it names nothing. + m.refuse( + "books.label_asset", + json!({"asset": "WL-0001", "serial": "x"}), + ); +} + +/// Tags are unique ignoring case and never reused by another asset, even +/// after a retag; bad forms and unknown assets are refused. +#[test] +fn label_asset_refusals() { + let mut m = books("label-refuse"); + let (a, _) = m.register_ok(json!({})); + let (b, _) = m.register_ok(json!({})); + m.ok( + "books.label_asset", + json!({"asset": a.to_string(), "tag": "LAPTOP-A"}), + ); + // b may not take a's current tag (any case) or its released WL-0001. + for t in ["laptop-a", "LAPTOP-A", "WL-0001", "wl-0001"] { + m.refuse( + "books.label_asset", + json!({"asset": b.to_string(), "tag": t}), + ); + } + for t in ["bad tag", "", "--", "É"] { + m.refuse( + "books.label_asset", + json!({"asset": b.to_string(), "tag": t}), + ); + } + for s in ["", " x", "x ", "a\nb"] { + m.refuse( + "books.label_asset", + json!({"asset": b.to_string(), "serial": s}), + ); + } + m.refuse("books.label_asset", json!({"asset": "999", "tag": "NEW-1"})); + m.refuse( + "books.label_asset", + json!({"asset": "NOPE-1", "tag": "NEW-1"}), + ); + // a may take back its own old tag. + let r = m.ok( + "books.label_asset", + json!({"asset": "LAPTOP-A", "tag": "WL-0001"}), + ); + assert_eq!(r["asset_id"], json!(a)); + assert_eq!(m.asset(b)["tag"], "WL-0002"); +} + +/// resolve_asset_ref: "A tag wins: tags like \"1001\" are allowed". +#[test] +fn an_asset_ref_that_is_a_tag_means_the_tagged_asset() { + let mut m = books("tag-wins"); + let (a, _) = m.register_ok(json!({})); + let (b, _) = m.register_ok(json!({"tag": a.to_string()})); + assert_ne!(a, b); + let r = m.ok( + "books.label_asset", + json!({"asset": a.to_string(), "serial": "which"}), + ); + assert_eq!(r["asset_id"], json!(b), "the tag {a} wins over the id {a}"); + assert_eq!(m.asset(b)["serial"], "which"); + assert_eq!(m.asset(a)["serial"], Value::Null); +} + +// ------------------------------------------------- tag in place of id + +/// `books.asset_schedule { asset_id | tag }`: the same schedule either way. +#[test] +fn asset_schedule_takes_the_tag_in_place_of_the_id() { + let mut m = books("schedule"); + let (a, t) = m.register_ok(json!({})); + let by_id = m.read_only("books.asset_schedule", json!({"asset_id": a})); + let by_tag = m.read_only("books.asset_schedule", json!({"tag": t})); + let by_lower = m.read_only( + "books.asset_schedule", + json!({"tag": t.to_ascii_lowercase()}), + ); + assert_eq!(by_id, by_tag); + assert_eq!(by_id, by_lower); + assert_eq!(by_tag["asset_id"], json!(a)); + // Exactly one of asset_id and tag. + m.refuse("books.asset_schedule", json!({})); + m.refuse("books.asset_schedule", json!({"asset_id": a, "tag": t})); + m.refuse("books.asset_schedule", json!({"tag": "NOPE-1"})); +} + +/// `books.dispose_asset { asset_id | tag, ... }`: the same preview either +/// way; confirmed by tag disposes it; its tag stays reserved. +#[test] +fn dispose_asset_takes_the_tag_and_the_tag_stays_reserved() { + let mut m = books("dispose"); + m.post( + "2026-01-01", + &[("1000", "debit", "5000.00"), ("3000", "credit", "5000.00")], + ); + m.post( + "2026-01-01", + &[("1500", "debit", "1200.00"), ("1000", "credit", "1200.00")], + ); + let (a, t) = m.register_ok(json!({"serial": "SN-A"})); + let (b, tb) = m.register_ok(json!({})); + let base = json!({"date": "2026-06-30", "proceeds": "500.00", + "proceeds_account": "1000", "gain_loss_account": "7100"}); + let mut by_id = base.clone(); + by_id["asset_id"] = json!(a); + let mut by_tag = base.clone(); + by_tag["tag"] = json!(t.to_ascii_lowercase()); + let p1 = m.ok("books.dispose_asset", by_id.clone()); + let p2 = m.ok("books.dispose_asset", by_tag.clone()); + assert_eq!(p1, p2, "same preview by id and by tag"); + let mut both = by_id.clone(); + both["tag"] = json!(t); + m.refuse("books.dispose_asset", both); + m.refuse("books.dispose_asset", base.clone()); + let doc = m.file_bill_of_sale(); + let mut confirm = by_tag; + confirm["confirmed"] = json!(true); + confirm["receipt_attachment_ids"] = json!([doc]); + m.ok("books.dispose_asset", confirm); + assert_eq!(m.asset(a)["status"], "disposed"); + // B-24: a disposed asset's tag is never reused. + m.refuse( + "books.label_asset", + json!({"asset": b.to_string(), "tag": t}), + ); + assert!(m.register(json!({"tag": t})).refused()); + // Default labels: active tagged assets only. + let labels = m.read_only("books.asset_labels", json!({})); + assert_eq!(labels["tags"], json!([tb])); +} + +// ------------------------------------------------- books.asset_labels + +#[test] +fn asset_labels_prints_a_pdf_of_the_tags_in_order() { + let mut m = books("labels"); + let (_, t1) = m.register_ok(json!({"serial": "SN-1"})); + let (_, t2) = m.register_ok(json!({})); + let (a3, t3) = m.register_ok(json!({"tag": "DESK/7"})); + let r = m.read_only("books.asset_labels", json!({"assets": [t3, t1]})); + assert_eq!(r["tags"], json!([t3, t1]), "the tags printed, in order"); + let pdf = unb64(r["content_base64"].as_str().unwrap()); + assert!(pdf.starts_with(b"%PDF"), "a PDF"); + assert!(!r["file_name"].as_str().unwrap().is_empty()); + // By id too. + let r = m.read_only("books.asset_labels", json!({"assets": [a3.to_string()]})); + assert_eq!(r["tags"], json!(["DESK/7"])); + // Default: every active asset with a tag, all three here. + let r = m.read_only("books.asset_labels", json!({})); + let mut tags: Vec = r["tags"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + tags.sort(); + let mut want = vec![t1.clone(), t2.clone(), t3.clone()]; + want.sort(); + assert_eq!(tags, want); + // skip: 0 to 29. + let r = m.read_only("books.asset_labels", json!({"assets": [t1], "skip": 29})); + assert!(unb64(r["content_base64"].as_str().unwrap()).starts_with(b"%PDF")); + m.refuse("books.asset_labels", json!({"assets": [t2], "skip": 30})); + // Unknown asset. + m.refuse("books.asset_labels", json!({"assets": ["NOPE-1"]})); +} + +/// "asset {} has no tag: give it one with books.label_asset first": an +/// asset with no tag can't be printed. Assets registered before tags +/// existed have none; here one is made by the store directly. +#[test] +fn asset_labels_refuses_an_asset_without_a_tag() { + let dir = test_dir("untagged"); + let toml = books_toml(&dir, "Spec Co", "asset_tag_prefix = \"WL\"\n"); + // Books written by the store, outside the server: an untagged asset. + { + use ledger_core::{AccountType, Currency, Money, NewAsset}; + let data = dir.join("books-data"); + std::fs::create_dir_all(&data).unwrap(); + let mut s = ledger_store::Store::create(&data, Currency::USD).unwrap(); + let mut add = |c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap(); + let cost = add("1500", "Equipment", AccountType::Asset); + let accum = add("1510", "Accumulated Depreciation", AccountType::Asset); + let exp = add("6500", "Depreciation Expense", AccountType::Expense); + let usd = |a: &str| Money::new(a.parse().unwrap(), Currency::USD); + s.register_asset(NewAsset { + description: "Old laptop".into(), + purchase_date: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), + cost: usd("1200.00"), + salvage_value: usd("0.00"), + useful_life_months: 12, + cost_account: cost, + accum_depr_account: accum, + expense_account: exp, + }) + .unwrap(); + } + let mut m = Mcp::start(&dir, &toml); + let assets = m.assets(); + assert_eq!(assets.len(), 1); + assert_eq!(assets[0]["tag"], Value::Null); + let id = assets[0]["asset_id"].as_u64().unwrap(); + m.refuse("books.asset_labels", json!({"assets": [id.to_string()]})); + // The default (every active tagged asset) has nothing to print, or + // prints nothing; either way it never prints the untagged one. + if let Out::Ok(r) = m.tool("books.asset_labels", json!({})) { + assert_eq!(r["tags"], json!([])); + } + // Tag it and it prints. + m.ok( + "books.label_asset", + json!({"asset": id.to_string(), "tag": "OLD-1"}), + ); + let r = m.read_only("books.asset_labels", json!({"assets": ["OLD-1"]})); + assert_eq!(r["tags"], json!(["OLD-1"])); +} + +// ------------------------------------------------- books.show { asset } + +#[test] +fn show_opens_an_asset_by_tag_or_id() { + let mut m = books("show"); + let (a, t) = m.register_ok(json!({})); + m.read_only("books.show", json!({"asset": t})); + m.read_only("books.show", json!({"asset": a.to_string()})); + m.read_only("books.show", json!({"report": "fixed_asset_register"})); + m.read_only("books.show", json!({"page": "assets"})); + // Exactly one target. + m.refuse("books.show", json!({"asset": t, "page": "assets"})); +} + +// ------------------------------------------------- export fixed_asset_register + +/// `books.export { report: "fixed_asset_register", format: json | csv, +/// as_of? }`: tag, serial, ..., `unposted_depreciation` and +/// `unregistered_cost` (zero when register and books agree). +#[test] +fn export_fixed_asset_register_reconciles_to_the_books() { + let mut m = books("export"); + m.post( + "2026-01-01", + &[("1000", "debit", "5000.00"), ("3000", "credit", "5000.00")], + ); + m.post( + "2026-01-01", + &[("1500", "debit", "1200.00"), ("1000", "credit", "1200.00")], + ); + // $1,200 over 12 months from Jan 1: $100 a month. + m.register_ok(json!({"tag": "LAPTOP-1", "serial": "C02XG0FDJGH5"})); + m.ok("books.run_depreciation", json!({"period": "2026-01"})); + m.ok("books.run_depreciation", json!({"period": "2026-02"})); + + let export = |m: &mut Mcp, format: &str, as_of: &str| -> String { + let r = m.read_only( + "books.export", + json!({"report": "fixed_asset_register", "format": format, "as_of": as_of}), + ); + assert_eq!(r["report"], "fixed_asset_register"); + assert_eq!(r["format"], format); + r["content"].as_str().unwrap().to_string() + }; + + // Agreeing: depreciation posted through February. + let j: Value = serde_json::from_str(&export(&mut m, "json", "2026-02-28")).unwrap(); + assert_eq!(the_value(&j, "unposted_depreciation"), "0"); + assert_eq!(the_value(&j, "unregistered_cost"), "0"); + let text = j.to_string(); + assert!( + text.contains("LAPTOP-1") && text.contains("C02XG0FDJGH5"), + "{text}" + ); + + // March due by schedule, not posted. + let j: Value = serde_json::from_str(&export(&mut m, "json", "2026-03-31")).unwrap(); + assert_eq!(the_value(&j, "unposted_depreciation"), "100"); + assert_eq!(the_value(&j, "unregistered_cost"), "0"); + + // Equipment in the books that no asset accounts for. + m.post( + "2026-03-15", + &[("1500", "debit", "250.00"), ("1000", "credit", "250.00")], + ); + let j: Value = serde_json::from_str(&export(&mut m, "json", "2026-03-31")).unwrap(); + assert_eq!(the_value(&j, "unregistered_cost"), "250"); + + let csv = export(&mut m, "csv", "2026-02-28"); + assert!(csv.contains("LAPTOP-1"), "{csv}"); + assert!(csv.contains("C02XG0FDJGH5"), "{csv}"); + let header = csv.lines().next().unwrap().to_ascii_lowercase(); + assert!(header.contains("tag"), "{header}"); + assert!(header.contains("serial"), "{header}"); +} + +/// The register lists assets registered by the date only. +#[test] +fn export_fixed_asset_register_as_of_leaves_out_later_assets() { + let mut m = books("export-later"); + m.register_ok(json!({"tag": "EARLY-1"})); + m.register_ok(json!({"tag": "LATE-1", "purchase_date": "2026-06-01"})); + let r = m.read_only( + "books.export", + json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}), + ); + let text = r["content"].as_str().unwrap(); + assert!(text.contains("EARLY-1")); + assert!(!text.contains("LATE-1"), "{text}"); +} diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 46fb6c0..22ab536 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -778,15 +778,29 @@ fn happy_path(name: &str) -> Mcp { m.ok("books.ap_aging", json!({"as_of": "2026-06-30"})); // Fixed assets. - let asset = m.ok( + let registered = m.ok( "books.register_asset", json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "3000.00", "salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500", "accum_depr_account": "1510", "expense_account": "6500"}), - )["asset_id"] - .as_u64() - .unwrap(); + ); + assert_eq!(registered["tag"], "S-0001", "\"Spec Co\" without \"Co\": S"); + let asset = registered["asset_id"].as_u64().unwrap(); m.ok("books.asset_schedule", json!({"asset_id": asset})); + m.ok("books.asset_schedule", json!({"tag": "s-0001"})); + m.ok( + "books.label_asset", + json!({"asset": "S-0001", "tag": "LAPTOP-1", "serial": "SN 123"}), + ); + m.ok("books.asset_labels", json!({})); + m.ok( + "books.asset_labels", + json!({"assets": ["LAPTOP-1"], "skip": 3}), + ); + m.ok( + "books.export", + json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}), + ); m.ok("books.list_assets", json!({})); m.ok("books.run_depreciation", json!({"period": "2026-01"})); let dispose = json!({"asset_id": asset, "date": "2026-06-15", "proceeds": "1000.00", diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index d5abefd..5c8e5f2 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 8; +pub const SCHEMA_VERSION: u32 = 9; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -207,5 +207,18 @@ ALTER TABLE attachments ADD COLUMN mime_type TEXT; UPDATE attachments SET file_name = (SELECT original_name FROM blobs WHERE sha256 = attachments.blob_sha), mime_type = (SELECT mime_type FROM blobs WHERE sha256 = attachments.blob_sha); +"#, + r#" +-- v9: asset tags, serial numbers, and each asset's acquisition entry. assets.tag is the current tag; +-- asset_tags_log keeps every tagging in journal order, because a tag is +-- never reused by another asset even after a retag. +ALTER TABLE assets ADD COLUMN tag TEXT; +ALTER TABLE assets ADD COLUMN serial TEXT; +ALTER TABLE assets ADD COLUMN acquisition_entry INTEGER; +CREATE TABLE IF NOT EXISTS asset_tags_log( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL, + tag TEXT NOT NULL +); "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index cdbe550..515227c 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -711,6 +711,51 @@ impl Store { Ok(live) } + /// Give an asset a tag (see `Ledger::set_asset_tag`). Journaled as + /// `asset.tagged`, with the tag it had. + pub fn set_asset_tag(&mut self, id: AssetId, tag: &str) -> Result<(), StoreError> { + self.ledger.check_asset_tag_for(Some(id), tag)?; + let previous = self.ledger.get_asset(id).and_then(|a| a.tag.clone()); + let line = self.append_journal( + "asset.tagged", + json!({"asset_id": id, "tag": tag, "previous_tag": previous}), + )?; + self.ledger.set_asset_tag(id, tag)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Link an asset to the entry that put it on the books (see + /// `Ledger::set_asset_acquisition`). Journaled as + /// `asset.acquisition_linked`. + pub fn set_asset_acquisition(&mut self, id: AssetId, entry: EntryId) -> Result<(), StoreError> { + self.ledger.check_asset_acquisition(id, entry)?; + let line = self.append_journal( + "asset.acquisition_linked", + json!({"asset_id": id, "entry_id": entry}), + )?; + self.ledger.set_asset_acquisition(id, entry)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Record or clear an asset's serial number (see + /// `Ledger::set_asset_serial`). Journaled as `asset.serial_set`. + pub fn set_asset_serial( + &mut self, + id: AssetId, + serial: Option<&str>, + ) -> Result<(), StoreError> { + self.ledger.check_asset_serial(id, serial)?; + let line = self.append_journal( + "asset.serial_set", + json!({"asset_id": id, "serial": serial}), + )?; + self.ledger.set_asset_serial(id, serial)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + /// Was depreciation already posted for this asset and period? fn depreciation_posted_entry( &self, @@ -2812,6 +2857,32 @@ impl Store { )?; } } + // Tags in the order they were given (a retag keeps the old tag + // reserved), then serial numbers. + let mut tstmt = self + .conn + .prepare("SELECT asset_id, tag FROM asset_tags_log ORDER BY seq")?; + let tags = tstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; + for t in tags { + let (id, tag) = t?; + ledger.set_asset_tag(id as u64, &tag)?; + } + let mut sstmt = self + .conn + .prepare("SELECT id, serial FROM assets WHERE serial IS NOT NULL ORDER BY id")?; + let serials = sstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; + for row in serials { + let (id, serial) = row?; + ledger.set_asset_serial(id as u64, Some(&serial))?; + } + let mut qstmt = self.conn.prepare( + "SELECT id, acquisition_entry FROM assets WHERE acquisition_entry IS NOT NULL ORDER BY id", + )?; + let links = qstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; + for row in links { + let (id, entry) = row?; + ledger.set_asset_acquisition(id as u64, entry as u64)?; + } // Payroll runs: id order; lines in insertion order. Amounts are // TEXT in SQLite, so they parse here with exact Decimal math. let parse_payroll_amount = |s: String| { @@ -3356,6 +3427,22 @@ pub(crate) fn replay_line_into_ledger( ))); } } + "asset.tagged" => { + ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?; + } + "asset.acquisition_linked" => { + ledger.set_asset_acquisition(get_u64(p, "asset_id")?, get_u64(p, "entry_id")?)?; + } + "asset.serial_set" => { + let serial = + match p.get("serial") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().ok_or_else(|| { + StoreError::JournalCorrupt("asset.serial_set serial".into()) + })?), + }; + ledger.set_asset_serial(get_u64(p, "asset_id")?, serial)?; + } "asset.depreciation_posted" => { // The entry itself was journaled as entry.created/entry.posted; // this line is idempotency bookkeeping for the projection. @@ -3809,6 +3896,32 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr // Roles live in the ledger (rebuilt by replay); the events // mirror below keeps the history. } + "asset.tagged" => { + let (id, tag) = (get_u64(p, "asset_id")? as i64, get_str(p, "tag")?); + conn.execute("UPDATE assets SET tag = ? WHERE id = ?", params![tag, id])?; + conn.execute( + "INSERT INTO asset_tags_log(asset_id, tag) VALUES (?, ?)", + params![id, tag], + )?; + } + "asset.acquisition_linked" => { + conn.execute( + "UPDATE assets SET acquisition_entry = ? WHERE id = ?", + params![ + get_u64(p, "entry_id")? as i64, + get_u64(p, "asset_id")? as i64 + ], + )?; + } + "asset.serial_set" => { + conn.execute( + "UPDATE assets SET serial = ? WHERE id = ?", + params![ + p.get("serial").and_then(|v| v.as_str()), + get_u64(p, "asset_id")? as i64 + ], + )?; + } "receipt.attached" | "document.filed" => { conn.execute( "INSERT OR IGNORE INTO blobs(sha256, size_bytes, mime_type, original_name, created_at) VALUES (?,?,?,?,?)", diff --git a/crates/ledger-store/src/viewer.rs b/crates/ledger-store/src/viewer.rs index 8f8d57c..42a7a9d 100644 --- a/crates/ledger-store/src/viewer.rs +++ b/crates/ledger-store/src/viewer.rs @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf}; #[serde(tag = "show", rename_all = "snake_case")] pub enum ViewerTarget { /// A top-level page: overview, cash, profit, transactions, reports, - /// accounts, documents, company, journal. + /// accounts, assets, documents, company, journal. Page { /// Page name. page: String, @@ -39,7 +39,7 @@ pub enum ViewerTarget { Report { /// profit_and_loss, balance_sheet, cash_flow, members_equity, /// trial_balance, general_ledger, tax_summary, vendor_1099, - /// ar_aging, ap_aging. + /// ar_aging, ap_aging, fixed_asset_register. report: String, /// First date, YYYY-MM-DD. start: Option, @@ -57,6 +57,11 @@ pub enum ViewerTarget { /// Attachment id. attachment_id: u64, }, + /// One fixed asset: its identity, schedule, entries, and documents. + Asset { + /// Asset id. + asset_id: u64, + }, /// The transactions list, searched. Search { /// Search text: description, account, vendor, amount. diff --git a/crates/ledger-store/tests/asset_tags.rs b/crates/ledger-store/tests/asset_tags.rs new file mode 100644 index 0000000..b5fd533 --- /dev/null +++ b/crates/ledger-store/tests/asset_tags.rs @@ -0,0 +1,501 @@ +//! Spec-derived store tests for asset tags and serial numbers (rules.md +//! B-24): journaling (`asset.tagged` {asset_id, tag, previous_tag}, +//! `asset.serial_set` {asset_id, serial | null}), replay on reopen, the v9 +//! SQLite projection agreeing with the journal (`Store::verify`), books +//! written before tags existed, refusals journaling nothing, and corrupt +//! `asset.serial_set` lines refused on open. +//! +//! Expectations come from the doc comments on `Store::set_asset_tag`, +//! `Store::set_asset_serial`, `Store::open`, `Store::verify`, +//! `Ledger::set_asset_tag` and the schema v9 migration comment. + +use chrono::NaiveDate; +use ledger_core::{AccountType, AssetId, Currency, LedgerError, Money, NewAsset}; +use ledger_store::journal::read_and_verify_journal; +use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store, StoreError}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; + +fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wb-asset-tags-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn usd(amount: &str) -> Money { + Money::new(amount.parse().unwrap(), Currency::USD) +} + +fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// The journal's (type, payload) pairs, verified. +fn journal(dir: &Path) -> Vec<(String, Value)> { + read_and_verify_journal(&dir.join(JOURNAL_FILE)) + .unwrap() + .into_iter() + .map(|l| (l.line_type, l.payload)) + .collect() +} + +struct Accts { + cost: u64, + accum: u64, + expense: u64, + bank: u64, + gain: u64, +} + +fn accounts(store: &mut Store) -> Accts { + let mut add = + |code: &str, name: &str, ty| store.add_account(code, name, ty, None, false).unwrap(); + Accts { + cost: add("1500", "Equipment", AccountType::Asset), + accum: add("1501", "Accumulated Depreciation", AccountType::Asset), + expense: add("6200", "Depreciation Expense", AccountType::Expense), + bank: add("1000", "Bank", AccountType::Asset), + gain: add("4900", "Gain on Disposal", AccountType::Revenue), + } +} + +fn laptop(store: &mut Store, a: &Accts, name: &str) -> AssetId { + store + .register_asset(NewAsset { + description: name.to_string(), + purchase_date: date(2026, 1, 1), + cost: usd("1200.00"), + salvage_value: usd("0.00"), + useful_life_months: 12, + cost_account: a.cost, + accum_depr_account: a.accum, + expense_account: a.expense, + }) + .unwrap() +} + +/// A store with two untagged laptops. +fn fixture(name: &str) -> (PathBuf, Store, Accts, AssetId, AssetId) { + let dir = test_dir(name); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let a = accounts(&mut s); + let x = laptop(&mut s, &a, "Laptop A"); + let y = laptop(&mut s, &a, "Laptop B"); + (dir, s, a, x, y) +} + +fn tag_of(s: &Store, id: AssetId) -> Option { + s.ledger().get_asset(id).unwrap().tag.clone() +} + +fn serial_of(s: &Store, id: AssetId) -> Option { + s.ledger().get_asset(id).unwrap().serial.clone() +} + +// ------------------------------------------------- journaling + +/// Store::set_asset_tag: journaled as `asset.tagged`, with the tag it had. +#[test] +fn tagging_journals_asset_tagged_with_the_previous_tag() { + let (dir, mut s, _, x, _) = fixture("journal-tag"); + s.set_asset_tag(x, "WL-0001").unwrap(); + s.set_asset_tag(x, "WL-0007").unwrap(); + let j = journal(&dir); + let tagged: Vec<&Value> = j + .iter() + .filter(|(t, _)| t == "asset.tagged") + .map(|(_, p)| p) + .collect(); + assert_eq!(tagged.len(), 2); + assert_eq!( + tagged[0], + &json!({"asset_id": x, "tag": "WL-0001", "previous_tag": null}) + ); + assert_eq!( + tagged[1], + &json!({"asset_id": x, "tag": "WL-0007", "previous_tag": "WL-0001"}) + ); + assert_eq!(tag_of(&s, x).as_deref(), Some("WL-0007")); + s.verify().unwrap(); +} + +/// Store::set_asset_serial: journaled as `asset.serial_set`, serial or null. +#[test] +fn serials_journal_asset_serial_set() { + let (dir, mut s, _, x, _) = fixture("journal-serial"); + s.set_asset_serial(x, Some("C02XG0FDJGH5")).unwrap(); + s.set_asset_serial(x, None).unwrap(); + let j = journal(&dir); + let set: Vec<&Value> = j + .iter() + .filter(|(t, _)| t == "asset.serial_set") + .map(|(_, p)| p) + .collect(); + assert_eq!( + set, + vec![ + &json!({"asset_id": x, "serial": "C02XG0FDJGH5"}), + &json!({"asset_id": x, "serial": null}), + ] + ); + assert_eq!(serial_of(&s, x), None); + s.verify().unwrap(); +} + +// ------------------------------------------------- refusals journal nothing + +/// Ledger::set_asset_tag INVARIANT 3 through the store: a refusal changes +/// nothing, so nothing is journaled. +#[test] +fn a_refused_tag_journals_nothing() { + let (dir, mut s, _, x, y) = fixture("refused-tag"); + s.set_asset_tag(x, "WL-0001").unwrap(); + s.set_asset_tag(x, "WL-0002").unwrap(); + let lines = journal(&dir).len(); + let before = s.ledger().clone(); + // Taken (current and released), wrong form, unknown asset. + assert!(matches!( + s.set_asset_tag(y, "wl-0002"), + Err(StoreError::Ledger(LedgerError::AssetTagTaken { .. })) + )); + assert!(matches!( + s.set_asset_tag(y, "WL-0001"), + Err(StoreError::Ledger(LedgerError::AssetTagTaken { .. })) + )); + assert!(matches!( + s.set_asset_tag(y, "no spaces"), + Err(StoreError::Ledger(LedgerError::InvalidAssetTag { .. })) + )); + assert!(s.set_asset_tag(y + 99, "WL-0050").is_err()); + assert_eq!(journal(&dir).len(), lines, "nothing journaled"); + assert_eq!(s.ledger(), &before); + s.verify().unwrap(); + // And nothing reappears on reopen. + drop(s); + let s = Store::open(&dir).unwrap(); + assert_eq!(s.ledger(), &before); + s.verify().unwrap(); +} + +#[test] +fn a_refused_serial_journals_nothing() { + let (dir, mut s, _, x, _) = fixture("refused-serial"); + s.set_asset_serial(x, Some("SN-1")).unwrap(); + let lines = journal(&dir).len(); + let before = s.ledger().clone(); + for bad in ["", " SN", "SN\u{1}"] { + assert!(matches!( + s.set_asset_serial(x, Some(bad)), + Err(StoreError::Ledger(LedgerError::InvalidSerial(_))) + )); + } + assert!(s.set_asset_serial(x + 99, Some("SN-2")).is_err()); + assert_eq!(journal(&dir).len(), lines); + assert_eq!(s.ledger(), &before); + s.verify().unwrap(); +} + +// ------------------------------------------------- replay + +/// Tags (with their history) and serials survive a reopen, both with the +/// database present and rebuilt from the journal alone (Store::open +/// SCENARIO 5), and verify passes each time. +#[test] +fn tags_and_serials_replay_on_reopen() { + let (dir, mut s, a, x, y) = fixture("replay"); + s.set_asset_tag(x, "WL-0001").unwrap(); + s.set_asset_tag(y, "WL-0002").unwrap(); + s.set_asset_tag(x, "Desk/7").unwrap(); // releases WL-0001 (still reserved) + s.set_asset_tag(x, "WL-0001").unwrap(); // takes it back + s.set_asset_tag(x, "WL-0003").unwrap(); + s.set_asset_serial(x, Some("SN 123")).unwrap(); + s.set_asset_serial(y, Some("first")).unwrap(); + s.set_asset_serial(y, Some("second")).unwrap(); + let z = laptop(&mut s, &a, "Laptop C"); + s.set_asset_serial(z, Some("gone")).unwrap(); + s.set_asset_serial(z, None).unwrap(); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + + for rebuild in [false, true] { + if rebuild { + std::fs::remove_file(dir.join(DB_FILE)).unwrap(); + } + let s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!(s.ledger(), &live, "rebuild={rebuild}"); + assert_eq!(tag_of(&s, x).as_deref(), Some("WL-0003")); + assert_eq!(tag_of(&s, y).as_deref(), Some("WL-0002")); + assert_eq!(tag_of(&s, z), None); + assert_eq!(serial_of(&s, x).as_deref(), Some("SN 123")); + assert_eq!(serial_of(&s, y).as_deref(), Some("second")); + assert_eq!(serial_of(&s, z), None); + assert_eq!(s.ledger().asset_by_tag("wl-0003").map(|a| a.id), Some(x)); + assert!(s.ledger().asset_by_tag("Desk/7").is_none()); + // Every tag x ever held stays reserved after replay. + for t in ["WL-0001", "desk/7", "WL-0003"] { + assert!( + matches!( + s.ledger().check_asset_tag_for(Some(z), t), + Err(LedgerError::AssetTagTaken { asset, .. }) if asset == x + ), + "{t} rebuild={rebuild}" + ); + } + assert_eq!(s.ledger().next_asset_tag("WL"), "WL-0004"); + // x may still take back what it held. + s.ledger().check_asset_tag_for(Some(x), "Desk/7").unwrap(); + drop(s); + } +} + +/// Tags on a disposed asset stay reserved through the store and a reopen. +#[test] +fn a_disposed_assets_tag_stays_reserved_after_reopen() { + let (dir, mut s, a, x, y) = fixture("disposed"); + s.set_asset_tag(x, "WL-0001").unwrap(); + s.set_asset_serial(x, Some("SN-X")).unwrap(); + s.dispose_asset(x, date(2026, 3, 31), usd("500.00"), a.bank, a.gain) + .unwrap(); + s.verify().unwrap(); + drop(s); + let mut s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!(tag_of(&s, x).as_deref(), Some("WL-0001")); + assert_eq!(serial_of(&s, x).as_deref(), Some("SN-X")); + assert!(matches!( + s.set_asset_tag(y, "WL-0001"), + Err(StoreError::Ledger(LedgerError::AssetTagTaken { .. })) + )); + s.set_asset_tag(y, "WL-0002").unwrap(); + s.verify().unwrap(); +} + +/// The store's projection keeps up after mutations without a reopen, and +/// stays right across many retags (verify compares every projection table +/// to a fresh projection of the journal). +#[test] +fn verify_passes_after_many_retags_and_serial_changes() { + let (dir, mut s, _, x, y) = fixture("many"); + for n in 1..=20u32 { + let id = if n % 2 == 0 { x } else { y }; + s.set_asset_tag(id, &format!("WL-{n:04}")).unwrap(); + s.set_asset_serial(id, Some(&format!("SN {n}"))).unwrap(); + if n % 5 == 0 { + s.set_asset_serial(id, None).unwrap(); + } + } + s.verify().unwrap(); + assert_eq!(s.ledger().next_asset_tag("WL"), "WL-0021"); + drop(s); + let s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!(s.ledger().next_asset_tag("WL"), "WL-0021"); +} + +// ------------------------------------------------- older books + +/// Books written before tags existed (no asset.tagged lines) open, verify, +/// and then take tags. +#[test] +fn books_written_before_tags_open_and_verify() { + let (dir, s, _, x, y) = fixture("older"); + drop(s); + let _ = std::fs::remove_file(dir.join(DB_FILE)); + let mut s = Store::open(&dir).expect("older books still open"); + s.verify().unwrap(); + assert_eq!(tag_of(&s, x), None); + assert_eq!(serial_of(&s, y), None); + s.set_asset_tag(x, "WL-0001").unwrap(); + s.verify().unwrap(); +} + +/// What an older wanderbooks wrote, by hand (as tests/store.rs +/// `books_written_under_older_rules_still_open` does): an asset.registered +/// line from before tags, appended with JournalWriter, then the database +/// rebuilt from the journal. It opens, verifies, and takes a tag. +#[test] +fn an_older_asset_registered_line_still_replays() { + let (dir, s, a, x, y) = fixture("older-line"); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append( + "asset.registered", + json!({"id": y + 1, "description": "Old printer", "purchase_date": "2026-02-01", + "cost": {"amount": "600.00", "currency": "USD"}, + "salvage_value": {"amount": "0.00", "currency": "USD"}, + "useful_life_months": 6, "method": "straight_line", + "cost_account": a.cost, "accum_depr_account": a.accum, + "expense_account": a.expense}), + ) + .unwrap(); + drop(w); + let _ = std::fs::remove_file(dir.join(DB_FILE)); + let mut s = Store::open(&dir).expect("older books still open"); + s.verify().unwrap(); + let old = y + 1; + assert_eq!(tag_of(&s, old), None); + assert_eq!(serial_of(&s, old), None); + assert_eq!(tag_of(&s, x), None); + s.set_asset_tag(old, "WL-0001").unwrap(); + s.set_asset_serial(old, Some("HP-1")).unwrap(); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!(tag_of(&s, old).as_deref(), Some("WL-0001")); +} + +/// Downgrade the database to schema v8 (no assets.tag / assets.serial, no +/// asset_tags_log), as an older wanderbooks left it. +fn downgrade_db_to_v8(dir: &Path) { + let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap(); + conn.execute_batch( + "ALTER TABLE assets DROP COLUMN tag; + ALTER TABLE assets DROP COLUMN serial; + ALTER TABLE assets DROP COLUMN acquisition_entry; + DROP TABLE asset_tags_log; + UPDATE meta SET value = '8' WHERE key = 'schema_version';", + ) + .unwrap(); +} + +/// An existing v8 database migrates to v9 on open and verifies. +#[test] +fn a_v8_database_migrates_and_verifies() { + let (dir, s, _, x, y) = fixture("v8"); + drop(s); + downgrade_db_to_v8(&dir); + let mut s = Store::open(&dir).expect("a v8 database opens"); + s.verify().unwrap(); + s.set_asset_tag(x, "WL-0001").unwrap(); + s.set_asset_serial(y, Some("SN-Y")).unwrap(); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!(tag_of(&s, x).as_deref(), Some("WL-0001")); + assert_eq!(serial_of(&s, y).as_deref(), Some("SN-Y")); +} + +/// A v8 database behind a journal that has tag lines (Store::open SCENARIO +/// 7: apply the rest) migrates, applies them, and verifies. +#[test] +fn a_v8_database_behind_a_journal_with_tags_catches_up() { + let (dir, s, _, x, _) = fixture("v8-behind"); + drop(s); + downgrade_db_to_v8(&dir); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append( + "asset.tagged", + json!({"asset_id": x, "tag": "WL-0001", "previous_tag": null}), + ) + .unwrap(); + w.append("asset.serial_set", json!({"asset_id": x, "serial": "SN-1"})) + .unwrap(); + drop(w); + let s = Store::open(&dir).expect("opens and catches up"); + s.verify().unwrap(); + assert_eq!(tag_of(&s, x).as_deref(), Some("WL-0001")); + assert_eq!(serial_of(&s, x).as_deref(), Some("SN-1")); +} + +// ------------------------------------------------- corruption + +/// A journal is the system of record; an `asset.serial_set` line whose +/// serial is neither a string nor null is corruption, refused on open. The +/// line is appended with `JournalWriter`, so the chain is valid and only +/// the payload is wrong. +#[test] +fn an_asset_serial_set_line_with_a_non_string_serial_is_corrupt() { + for bad in [json!(12), json!(true), json!(["SN"]), json!({"s": "SN"})] { + let (dir, s, _, x, _) = fixture("corrupt-serial"); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append("asset.serial_set", json!({"asset_id": x, "serial": bad})) + .unwrap(); + drop(w); + match Store::open(&dir) { + Err(StoreError::JournalCorrupt(_)) => {} + Err(other) => panic!("serial {bad}: expected JournalCorrupt, got {other:?}"), + Ok(_) => panic!("serial {bad}: a corrupt asset.serial_set line opened"), + } + // Rebuilding from the journal alone refuses it too. + let _ = std::fs::remove_file(dir.join(DB_FILE)); + assert!(Store::open(&dir).is_err(), "serial {bad}: rebuild opened"); + } +} + +/// A journaled tag the rules refuse (taken by another asset) can't replay: +/// opening refuses rather than give one tag to two assets. +#[test] +fn a_journaled_tag_taken_by_another_asset_is_refused_on_open() { + let (dir, mut s, _, x, y) = fixture("corrupt-taken"); + s.set_asset_tag(x, "WL-0001").unwrap(); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append( + "asset.tagged", + json!({"asset_id": y, "tag": "wl-0001", "previous_tag": null}), + ) + .unwrap(); + drop(w); + assert!(Store::open(&dir).is_err()); +} + +/// The acquisition link is journaled, replays on reopen, survives a +/// rebuild of the database from the journal, and verifies; a refused link +/// journals nothing. +#[test] +fn the_acquisition_link_replays_and_verifies() { + let (dir, mut s, a, x, _) = fixture("acquired"); + let e = s.create_entry(date(2026, 1, 1), "bought laptop").unwrap(); + s.add_line(e, a.cost, ledger_core::Side::Debit, usd("1200.00")) + .unwrap(); + s.add_line(e, a.bank, ledger_core::Side::Credit, usd("1200.00")) + .unwrap(); + s.post_entry(e).unwrap(); + s.verify().expect("before linking"); + let before = journal(&dir).len(); + assert!(s.set_asset_acquisition(x, 999).is_err()); + assert_eq!(journal(&dir).len(), before, "a refusal journals nothing"); + s.set_asset_acquisition(x, e).unwrap(); + let (kind, p) = journal(&dir).pop().unwrap(); + assert_eq!(kind, "asset.acquisition_linked"); + assert_eq!(p, json!({"asset_id": x, "entry_id": e})); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + assert_eq!(s.ledger().get_asset(x).unwrap().acquired_by, Some(e)); + s.verify().unwrap(); + drop(s); + std::fs::remove_file(dir.join(DB_FILE)).unwrap(); + let s = Store::open(&dir).unwrap(); + assert_eq!(s.ledger().get_asset(x).unwrap().acquired_by, Some(e)); + s.verify().unwrap(); +} + +/// Regression: verify compared the snapshot's amounts as strings, and a +/// zero total summed in hash-map order could be "0" or "0.00", so books +/// that agreed failed verify at random (found 2026-10-15). Fresh stores +/// (fresh hash seeds) with an entry that nets the assets to zero must all +/// verify. +#[test] +fn verify_compares_amounts_by_value() { + for n in 0..40 { + let dir = test_dir(&format!("by-value-{n}")); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let a = accounts(&mut s); + let e = s.create_entry(date(2026, 3, 5), "bought").unwrap(); + s.add_line(e, a.cost, ledger_core::Side::Debit, usd("1200.00")) + .unwrap(); + s.add_line(e, a.bank, ledger_core::Side::Credit, usd("1200.00")) + .unwrap(); + s.post_entry(e).unwrap(); + s.verify().unwrap(); + drop(s); + Store::open(&dir).unwrap().verify().unwrap(); + } +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 40eb66f..f995f15 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -156,20 +156,40 @@ CPA. Partial first/final months prorated by days; the final month trues up so the lifetime total is exactly (cost − salvage). - `books.register_asset { description, purchase_date, cost, salvage_value?, - useful_life_months, cost_account, accum_depr_account, expense_account }` - → `{ asset_id }`. Accounts resolved by id or code. -- `books.asset_schedule { asset_id }` + useful_life_months, cost_account, accum_depr_account, expense_account, + tag?, serial? }` → `{ asset_id, tag }`. Accounts resolved by id or code. + Without `tag`, the next free `PREFIX-0001` (`[books] asset_tag_prefix`, + else the company name's initials without LLC/Inc; "FA" when none). +- `books.label_asset { asset, tag?, serial?, clear_serial? }` → `{ asset_id, + tag, serial }`: set or change the tag (unique ignoring case, never reused + by another asset even after a retag or disposal) or the manufacturer's + serial. `asset` is the id or current tag. Journaled as `asset.tagged`, + `asset.serial_set`. +- `books.asset_labels { assets?, skip? }` → `{ file_name, content_base64, + tags }`: an Avery 5160/8160 label sheet (PDF): company, tag, QR code of + the tag, description, serial. Default: every active tagged asset; `skip` + reuses a partly used sheet. +- `books.asset_schedule { asset_id | tag }` → `{ status, schedule: [{ period, amount, accumulated, book_value }] }`. - `books.list_assets {}` → assets with status and current book values. - `books.run_depreciation { period: "YYYY-MM", asset_id? }` → per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for assets outside their useful life. Idempotent per asset and period. -- `books.dispose_asset { asset_id, date, proceeds, proceeds_account, +- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, gain_loss_account, confirmed?, receipt_attachment_ids? }` → without confirmation: `{ status: "preview", ... }` and posts nothing; with `confirmed: true`: auto-posts missing final-period depreciation, then posts the balanced disposal entry. Subject to receipt policy (bill of sale); monthly depreciation is exempt as an allocation. +- `books.export { report: "fixed_asset_register", format: json | csv, + as_of? }`: every asset registered by the date (tag, serial, cost, + depreciation by schedule, book value, disposal), totals, and the + general ledger's balances of the assets' cost and accumulated + depreciation accounts, with `unposted_depreciation` and + `unregistered_cost` (zero when register and books agree; + `ledger_core::fixed_asset_register`). +- `books.show { asset }` opens an asset in the viewer; `page: "assets"` + and `report: "fixed_asset_register"` open the register. ## Tools — schedules and recurring entries diff --git a/docs/spec/README.md b/docs/spec/README.md index a2e1947..d2e8c5e 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -14,7 +14,7 @@ layers differ in what they cover and in whether we may copy them: |---|---|---|---| | **Pacioli (1494), Geijsbeek translation (1914)** | The mechanics of double entry: debits equal credits, the trial balance, one money, writing only forward, correcting by opposite entry, closing into capital. No modern standard restates these; they predate every regulator. | Public domain | Vendored verbatim: `sources/pacioli-1494.md` | | **IRS: Internal Revenue Code, Treasury Regulations, publications, form instructions** | Records and supporting documents, retention, accounting periods and methods, information returns (1099), capitalization (de minimis, 12-month rule), partners' capital, payroll taxes, rounding on returns, return line items for tax categories (`sources/irs-tax-lines.md`) | Public domain (17 U.S.C. §105; IRS.gov's content policy) | Vendored verbatim: `sources/irs-*.md`, `sources/us-public-domain-status.md` | -| **File formats: Intuit (QuickBooks Online bank CSV, QuickBooks Desktop IIF), ECMA-376 Office Open XML, Microsoft Excel limits** | What `ledger-import` reads and writes: bank/card CSV layouts and signs, IIF journal entries, how XLSX stores numbers and Excel's 15-digit precision | © Intuit Inc.; © Ecma International; © Microsoft | Cited with short quotes, URLs, and hashes: `sources/intuit-bank-csv.md`, `sources/intuit-iif.md`, `sources/xlsx-numbers.md` | +| **File formats: Intuit (QuickBooks Online bank CSV, QuickBooks Desktop IIF), ECMA-376 Office Open XML, Microsoft Excel limits, Avery label sheets** | What `ledger-import` reads and writes: bank/card CSV layouts and signs, IIF journal entries, how XLSX stores numbers and Excel's 15-digit precision, asset-label sheet geometry (Avery 5160) | © Intuit Inc.; © Ecma International; © Microsoft; © Avery Products Corporation | Cited with short quotes, URLs, and hashes: `sources/intuit-bank-csv.md`, `sources/intuit-iif.md`, `sources/xlsx-numbers.md`, `sources/avery-5160.md` | | **FASB Accounting Standards Codification (ASC)** | Authoritative US GAAP: cash flows (ASC 230), revenue and principal vs agent (ASC 606), foreign currency (ASC 830), credit losses and aging (ASC 326), property and depreciation (ASC 360) | © Financial Accounting Foundation | Cited by paragraph with short quotes only (in `rules.md`) | | **FASB Concepts Statement No. 8, Chapter 4 (2021)** | Definitions of the five elements (it replaced Concepts Statement 6) | © Financial Accounting Foundation; non-authoritative | Cited with short quotes only | | **Textbooks (OpenStax *Principles of Accounting, Vol. 1*)** | Conventions no standard states: normal balances, closing-entry mechanics, straight-line depreciation arithmetic | CC BY-NC-SA 4.0 (incompatible with this repo's license) | Cited with short quotes and attribution only | diff --git a/docs/spec/rules.md b/docs/spec/rules.md index bee289e..84bdc29 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -446,3 +446,21 @@ postings stay where they are. `check_assign_role`, `Ledger::check_renumber_account` (an assigned role frees its code). **Checked:** `ledger-core` `tests/spec_roles.rs`; `ledger-store` replay of `role.assigned`. + +## B-24 Asset tags: one label per asset, never reused + +**Status:** DECISION (Stephen, 2026-10-15) + +Each fixed asset may carry a tag, the label stuck on the thing itself +(generated `PREFIX-0001` or the human's own: 1–32 ASCII letters, digits, +`- _ . /`), and the manufacturer's serial number. Tags are unique ignoring +case and are never reused by another asset, even after a retag or a +disposal, so an old label can never point at the wrong thing. Both are +journaled. The fixed asset register lists every asset as of a date and +reconciles to the general ledger's cost and accumulated depreciation +accounts. + +**Enforced:** `Ledger::set_asset_tag` / `check_asset_tag_for`, +`assets::check_asset_tag`, `assets::check_serial`. **Checked:** +`ledger-core` `tests/spec_asset_tags.rs`; `ledger-store` +`tests/asset_tags.rs`; `ledger-mcp` `tests/spec_assets.rs`. diff --git a/docs/spec/sources/avery-5160.md b/docs/spec/sources/avery-5160.md new file mode 100644 index 0000000..31f1371 --- /dev/null +++ b/docs/spec/sources/avery-5160.md @@ -0,0 +1,49 @@ +# Avery 5160 label sheet geometry +Governs `ledger_import::labels` (`LabelSheet::Avery5160`, printable asset-tag sheets). Avery's pages and templates are © Avery Products Corporation, not public domain, so per the vendoring rules (docs/spec/README.md) they are cited, with short quotes only; everything not inside a `>` blockquote is our description, not Avery's words. Avery's pages are dynamic (Next.js), so the hashes are of the files as fetched at retrieval and a later fetch will differ. + +## Avery Template 5160 (product template page) +Source: +Retrieved: 2026-10-15 +SHA-256 of the retrieved file (`templates_5160.html`, 90,873 bytes): `a274842ad34b3cb83e3129306df8b4f4ff51eb520c96cc263d96eff7b15c0a95` + +> 1" x 2-5/8" + +> 30 per sheet + +The page links Avery's blank downloadable template for 5160 as `U-0087-01` (PDF, Word, Illustrator, InDesign). + +## Blank labels, 1" x 2-5/8" (Presta 94200; lists 5160 as compatible) +Source: +Retrieved: 2026-10-15 +SHA-256 of the retrieved file (`blank_labels_94200.html`, 274,099 bytes): `c4e7c7eb3428654b1244ed04da5f52ef7668c14d6703f971ea7d0be7daa95b17` + +Avery's specification table on this page (key and value, transcribed as given): + +| Key | Value | +|---|---| +| Labels per Sheet | 30 labels per sheet | +| Size | 1" x 2-5/8" | +| Print to the edge | No | +| Sheet Size | 8-1/2" x 11" | +| Margin (Top and Bottom) | 0.5" | +| Margin (Left and Right) | 0.1875" | +| Corner Shape | Rounded | + +The page's `compatibleSkus` field lists `5160` first. + +## Avery 5160 blank PDF template (`U-0087-01.pdf`) +Source: the "PDF" download on (a signed S3 link: `https://s3.amazonaws.com/avery.dpp.projects.s3uspdownloadables/US_en/Downloadables/pdf/U-0087-01.pdf`, served as `Avery5160AddressLabels.pdf`) +Retrieved: 2026-10-15 +SHA-256 of the retrieved file (`U-0087-01.pdf`, 675,971 bytes): `486b464c0380d41225f752dc834c5d7e6362364c1454d7ed92a77fc4164d0466` + +One US Letter page (612 x 792 pt). We read the cell outlines from its content stream (PDF points, origin bottom-left): 30 rounded rectangles, left edges at x = 13.5, 211.5, 409.5 pt; bottom edges at y = 36, 108, ..., 684 pt (step 72); each 72 pt tall with 7.2 pt (0.1") corner radii. The outline is drawn 189.36 pt wide (2.63"); the stated label width is 2-5/8" (189 pt), and we use the stated size. + +## What wanderbooks uses +Derived from the figures above (1 in = 72 pt): + +- Page: 8.5" x 11" (US Letter), portrait. +- Label: 2.625" wide x 1" tall; 3 columns x 10 rows = 30 per sheet. +- Top margin 0.5" (the template's top row spans y = 756..684 pt, i.e. 36 pt below the top edge); bottom margin 0.5" (11 - 0.5 - 10 x 1 = 0.5). +- Side margin 0.1875" (13.5 pt). +- Column pitch 2.75" (211.5 - 13.5 = 198 pt), so the horizontal gap is 0.125" (8.5 - 2 x 0.1875 - 3 x 2.625 = 0.25, over two gaps). +- Row pitch 1" (72 pt): no vertical gap.