diff --git a/crates/ledger-core/src/assets.rs b/crates/ledger-core/src/assets.rs index 91d2c81..f70f6b0 100644 --- a/crates/ledger-core/src/assets.rs +++ b/crates/ledger-core/src/assets.rs @@ -87,6 +87,19 @@ pub struct Asset { pub tax: Option, } +/// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AssetCorrection { + /// Capitalized cost. + pub cost: Money, + /// Residual value. + pub salvage_value: Money, + /// Useful life in months. + pub useful_life_months: u32, + /// In-service date (depreciation starts here). + pub purchase_date: NaiveDate, +} + /// Which rule sets an asset's federal tax basis. /// /// ```text diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 4d9ec70..f9d7ab6 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -1698,6 +1698,58 @@ impl Ledger { tax.check().map_err(LedgerError::InvalidTaxBasis) } + /// Correct a registered asset's economics (cost, salvage, useful life, + /// in-service date) when it was registered wrong. Its accounts, tag, + /// serial, links, and tax basis record stay. + /// + /// ```text + /// GOAL: fix a registration mistake without re-registering (which would + /// burn its tag, since tags are never reused). + /// SOURCE: DECISION (Stephen, 2026-10-15). + /// INVARIANTS: + /// 1. The corrected figures pass every check a new registration does + /// (validate_new_asset). + /// 2. Only an active asset is corrected; a store also refuses once any + /// depreciation for it is posted (the posted entries would disagree + /// with the schedule; correct with entries instead). + /// 3. A refusal MUST leave the books unchanged. + /// ``` + pub fn correct_asset( + &mut self, + id: AssetId, + fix: assets::AssetCorrection, + ) -> Result<(), LedgerError> { + self.check_correct_asset(id, &fix)?; + let a = self.assets.get_mut(&id).expect("checked"); + a.cost = fix.cost; + a.salvage_value = fix.salvage_value; + a.useful_life_months = fix.useful_life_months; + a.purchase_date = fix.purchase_date; + Ok(()) + } + + /// The validation behind [`Self::correct_asset`]. + pub fn check_correct_asset( + &self, + id: AssetId, + fix: &assets::AssetCorrection, + ) -> Result<(), LedgerError> { + let a = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; + if a.status != AssetStatus::Active { + return Err(LedgerError::AssetAlreadyDisposed(id)); + } + self.validate_new_asset(&NewAsset { + description: a.description.clone(), + purchase_date: fix.purchase_date, + cost: fix.cost, + salvage_value: fix.salvage_value, + useful_life_months: fix.useful_life_months, + cost_account: a.cost_account, + accum_depr_account: a.accum_depr_account, + expense_account: a.expense_account, + }) + } + /// 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))?; diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 7860b41..3146f82 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -43,9 +43,9 @@ pub use arap::{ Vendor, VendorId, }; pub use assets::{ - Asset, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, ScheduleLine, - TaxBasis, TaxBasisRule, accumulated_depreciation, book_value, depreciation_schedule, - disposal_amounts, + Asset, AssetCorrection, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, + ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, book_value, + depreciation_schedule, disposal_amounts, }; pub use classes::{Class, ClassId}; pub use error::LedgerError; diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index eb6ad6e..19b1e97 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -311,6 +311,28 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { tax_basis_text(b, asset_id, p), asset_id.map(Route::Asset), ), + "asset.corrected" => ev( + icon::PENCIL_SIMPLE, + format!( + "Corrected {}: cost {} (was {}), {} months from {}. {}", + asset(asset_id), + p.get("cost") + .and_then(|c| c.get("amount")) + .and_then(|v| v.as_str()) + .unwrap_or("?"), + p.get("previous") + .and_then(|c| c.get("cost")) + .and_then(|c| c.get("amount")) + .and_then(|v| v.as_str()) + .unwrap_or("?"), + p.get("useful_life_months") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + s(p, "purchase_date"), + s(p, "reason") + ), + asset_id.map(Route::Asset), + ), "asset.described" => ev( icon::PENCIL_SIMPLE, format!( diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 93d004e..9752b28 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -2661,6 +2661,49 @@ impl BooksServer { })) } + #[tool( + name = "books.correct_asset", + description = "Correct a registered fixed asset's cost, salvage value, useful life, or in-service date when it was registered wrong, with the reason; omitted fields keep their values. Refused once any depreciation for it is posted (correct with entries then). Its tag, serial, links, and tax basis record stay; correct the entry that put it on the books too, so the books and the register agree (books.export fixed_asset_register shows any difference). Journaled with the old figures." + )] + async fn correct_asset( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = resolve_asset_ref(&store, &p.asset)?; + let a = store.ledger().get_asset(id).expect("resolved").clone(); + let base = store.ledger().base_currency(); + let fix = ledger_core::AssetCorrection { + cost: match &p.cost { + Some(c) => Money::new(parse_amount(c)?, base), + None => a.cost, + }, + salvage_value: match &p.salvage_value { + Some(c) => Money::new(parse_amount(c)?, base), + None => a.salvage_value, + }, + useful_life_months: p.useful_life_months.unwrap_or(a.useful_life_months), + purchase_date: match &p.in_service { + Some(d) => parse_date(d)?, + None => a.purchase_date, + }, + }; + store + .correct_asset(id, fix, &p.reason) + .map_err(|e| e.to_string())?; + let a = store.ledger().get_asset(id).expect("resolved"); + Ok(Json(AssetSummaryOne { + asset_id: id, + tag: a.tag.clone(), + cost: money(a.cost.amount), + salvage_value: money(a.salvage_value.amount), + useful_life_months: a.useful_life_months, + in_service: a.purchase_date.to_string(), + tax: tax_out(a), + })) + } + #[tool( name = "books.set_asset_tax_basis", description = "Record a fixed asset's federal tax basis beside its book value: rule cost (the company bought it: basis is its cost, IRC §1012), carryover (a member contributed business property: the member's adjusted basis, IRC §723), or personal_use_conversion (a member contributed personal-use property: the lesser of fair market value, which is the book cost, and the member's adjusted basis, Treas. Reg. §1.168(i)-4(b)). Contributions take the member's adjusted basis (what they paid, less any depreciation they took). Reports the tax basis and the built-in gain or loss (IRC §704(c)). Journaled; book figures don't change. Every asset should have one: the register and the CPA package show it." diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 2581e93..891e982 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -1523,6 +1523,43 @@ pub struct RegisterAssetParams { pub contributor_basis: Option, } +/// Parameters for `books.correct_asset`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct CorrectAssetParams { + /// The asset: its id or tag. + pub asset: String, + /// Corrected cost, decimal string. Omit to keep it. + pub cost: Option, + /// Corrected salvage value, decimal string. Omit to keep it. + pub salvage_value: Option, + /// Corrected useful life in months. Omit to keep it. + pub useful_life_months: Option, + /// Corrected in-service date, YYYY-MM-DD. Omit to keep it. + pub in_service: Option, + /// Why (required): what was wrong. + pub reason: String, +} + +/// Result of `books.correct_asset`: the asset's figures now. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AssetSummaryOne { + /// Asset id. + pub asset_id: u64, + /// Its tag. + pub tag: Option, + /// Cost, decimal string. + pub cost: String, + /// Salvage value, decimal string. + pub salvage_value: String, + /// Useful life in months. + pub useful_life_months: u32, + /// In-service date, YYYY-MM-DD. + pub in_service: String, + /// Its federal tax basis (follows the corrected cost under + /// personal_use_conversion and cost). + pub tax: Option, +} + /// Parameters for `books.set_asset_tax_basis`. #[derive(Debug, Deserialize, JsonSchema)] pub struct SetAssetTaxBasisParams { diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 9ee3e3e..2618276 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(), 73, "expected 73 tools, got {}", tools.len()); + assert_eq!(tools.len(), 74, "expected 74 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_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 07d9f1a..32d8ec0 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -796,6 +796,10 @@ fn happy_path(name: &str) -> Mcp { "books.set_asset_tax_basis", json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00"}), ); + m.ok( + "books.correct_asset", + json!({"asset": "LAPTOP-1", "useful_life_months": 48, "reason": "registered with the wrong life"}), + ); m.ok("books.asset_labels", json!({})); m.ok( "books.asset_labels", diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index 05cf044..7226022 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -5,6 +5,11 @@ use thiserror::Error; /// Every way a store operation can fail. #[derive(Debug, Error)] pub enum StoreError { + /// An asset can't be corrected once depreciation for it is posted: the + /// posted entries would disagree with the corrected schedule. + #[error("asset {0} already has depreciation posted; correct it with entries instead")] + AssetDepreciationPosted(u64), + /// Filesystem error. #[error("io error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 6821032..5e123a4 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -725,6 +725,48 @@ impl Store { Ok(()) } + /// Correct a registered asset's cost, salvage, life, or in-service date + /// (see `Ledger::correct_asset`), with the reason. Refused once any + /// depreciation for it is posted. Journaled as `asset.corrected`, with + /// the figures it had. + pub fn correct_asset( + &mut self, + id: AssetId, + fix: ledger_core::AssetCorrection, + reason: &str, + ) -> Result<(), StoreError> { + if reason.trim().is_empty() { + return Err(StoreError::ReasonRequired); + } + self.ledger.check_correct_asset(id, &fix)?; + if !self.asset_depreciation_total(id)?.is_zero() { + return Err(StoreError::AssetDepreciationPosted(id)); + } + let a = self.ledger.get_asset(id).expect("checked").clone(); + let money = + |m: &Money| json!({"amount": m.amount.to_string(), "currency": m.currency.code()}); + let line = self.append_journal( + "asset.corrected", + json!({ + "asset_id": id, + "cost": money(&fix.cost), + "salvage_value": money(&fix.salvage_value), + "useful_life_months": fix.useful_life_months, + "purchase_date": fix.purchase_date.to_string(), + "reason": reason, + "previous": { + "cost": money(&a.cost), + "salvage_value": money(&a.salvage_value), + "useful_life_months": a.useful_life_months, + "purchase_date": a.purchase_date.to_string(), + }, + }), + )?; + self.ledger.correct_asset(id, fix)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + /// Record an asset's federal tax basis (see /// `Ledger::set_asset_tax_basis`). Journaled as `asset.tax_basis_set`. pub fn set_asset_tax_basis( @@ -3488,6 +3530,17 @@ pub(crate) fn replay_line_into_ledger( "asset.tagged" => { ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?; } + "asset.corrected" => { + ledger.correct_asset( + get_u64(p, "asset_id")?, + ledger_core::AssetCorrection { + cost: parse_money(p, "cost")?, + salvage_value: parse_money(p, "salvage_value")?, + useful_life_months: get_u64(p, "useful_life_months")? as u32, + purchase_date: parse_date(get_str(p, "purchase_date")?)?, + }, + )?; + } "asset.tax_basis_set" => { let tax = tax_basis_from_journal(p)?; ledger.set_asset_tax_basis(get_u64(p, "asset_id")?, tax)?; @@ -3989,6 +4042,21 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr params![id, tag], )?; } + "asset.corrected" => { + let amt = |k: &str| -> Result { + Ok(get_str(&p[k], "amount")?.to_string()) + }; + conn.execute( + "UPDATE assets SET cost_amount = ?, salvage_amount = ?, useful_life_months = ?, purchase_date = ? WHERE id = ?", + params![ + amt("cost")?, + amt("salvage_value")?, + get_u64(p, "useful_life_months")? as i64, + get_str(p, "purchase_date")?, + get_u64(p, "asset_id")? as i64 + ], + )?; + } "asset.tax_basis_set" => { conn.execute( "UPDATE assets SET tax_rule = ?, contributor_basis = ? WHERE id = ?", diff --git a/crates/ledger-store/tests/asset_tags.rs b/crates/ledger-store/tests/asset_tags.rs index 71e4923..24e9937 100644 --- a/crates/ledger-store/tests/asset_tags.rs +++ b/crates/ledger-store/tests/asset_tags.rs @@ -757,3 +757,44 @@ fn an_asset_tax_basis_set_line_with_a_bad_payload_is_corrupt() { } } } + +/// A registration mistake is corrected (journaled with the old figures, +/// replayed, verified); a correction needs a reason and valid figures, and +/// is refused once depreciation for the asset is posted. +#[test] +fn an_asset_is_corrected_until_depreciation_is_posted() { + let (dir, mut s, _, x, _) = fixture("corrected"); + let fix = |cost: &str| ledger_core::AssetCorrection { + cost: usd(cost), + salvage_value: usd("0.00"), + useful_life_months: 12, + purchase_date: date(2026, 1, 1), + }; + let before = journal(&dir).len(); + assert!(matches!( + s.correct_asset(x, fix("1000.00"), " "), + Err(StoreError::ReasonRequired) + )); + assert!(s.correct_asset(x, fix("0.00"), "zero cost").is_err()); + assert_eq!(journal(&dir).len(), before, "refusals journal nothing"); + s.correct_asset(x, fix("1000.00"), "AppleCare+ split out") + .unwrap(); + let (kind, p) = journal(&dir).pop().unwrap(); + assert_eq!(kind, "asset.corrected"); + assert_eq!(p["cost"]["amount"], "1000.00"); + assert_eq!(p["previous"]["cost"]["amount"], "1200.00"); + s.verify().unwrap(); + drop(s); + std::fs::remove_file(dir.join(DB_FILE)).unwrap(); + let mut s = Store::open(&dir).unwrap(); + assert_eq!( + s.ledger().get_asset(x).unwrap().cost.amount.to_string(), + "1000.00" + ); + s.verify().unwrap(); + s.run_depreciation(x, "2026-01").unwrap(); + assert!(matches!( + s.correct_asset(x, fix("900.00"), "too late"), + Err(StoreError::AssetDepreciationPosted(_)) + )); +}