Assets can be corrected before depreciation: books.correct_asset (cost, salvage, life, in-service date, with a reason), journaled as asset.corrected with the old figures; refused once depreciation is posted
check / check (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 13:41:15 -06:00
1 parent 6e6f9d65b2
commit 27e78a0ed5
11 files changed
+289 -4

No files matched your search

+13
View File
@@ -87,6 +87,19 @@ pub struct Asset {
pub tax: Option<TaxBasis>, pub tax: Option<TaxBasis>,
} }
/// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AssetCorrection {
/// Capitalized cost.
pub cost: Money,
/// Residual value.
pub salvage_value: Money,
/// Useful life in months.
pub useful_life_months: u32,
/// In-service date (depreciation starts here).
pub purchase_date: NaiveDate,
}
/// Which rule sets an asset's federal tax basis. /// Which rule sets an asset's federal tax basis.
/// ///
/// ```text /// ```text
+52
View File
@@ -1698,6 +1698,58 @@ impl Ledger {
tax.check().map_err(LedgerError::InvalidTaxBasis) 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. /// The asset whose current tag is `tag`, ignoring case.
pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> { pub fn asset_by_tag(&self, tag: &str) -> Option<&Asset> {
let id = self.asset_tags.get(&assets::tag_key(tag))?; let id = self.asset_tags.get(&assets::tag_key(tag))?;
+3 -3
View File
@@ -43,9 +43,9 @@ pub use arap::{
Vendor, VendorId, Vendor, VendorId,
}; };
pub use assets::{ pub use assets::{
Asset, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, ScheduleLine, Asset, AssetCorrection, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset,
TaxBasis, TaxBasisRule, accumulated_depreciation, book_value, depreciation_schedule, ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, book_value,
disposal_amounts, depreciation_schedule, disposal_amounts,
}; };
pub use classes::{Class, ClassId}; pub use classes::{Class, ClassId};
pub use error::LedgerError; pub use error::LedgerError;
+22
View File
@@ -311,6 +311,28 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
tax_basis_text(b, asset_id, p), tax_basis_text(b, asset_id, p),
asset_id.map(Route::Asset), 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( "asset.described" => ev(
icon::PENCIL_SIMPLE, icon::PENCIL_SIMPLE,
format!( format!(
+43
View File
@@ -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<CorrectAssetParams>,
) -> Result<Json<AssetSummaryOne>, 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( #[tool(
name = "books.set_asset_tax_basis", 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." 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."
+37
View File
@@ -1523,6 +1523,43 @@ pub struct RegisterAssetParams {
pub contributor_basis: Option<String>, pub contributor_basis: Option<String>,
} }
/// 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<String>,
/// Corrected salvage value, decimal string. Omit to keep it.
pub salvage_value: Option<String>,
/// Corrected useful life in months. Omit to keep it.
pub useful_life_months: Option<u32>,
/// Corrected in-service date, YYYY-MM-DD. Omit to keep it.
pub in_service: Option<String>,
/// 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<String>,
/// 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<TaxBasisOut>,
}
/// Parameters for `books.set_asset_tax_basis`. /// Parameters for `books.set_asset_tax_basis`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct SetAssetTaxBasisParams { pub struct SetAssetTaxBasisParams {
+1 -1
View File
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: the narrow tools; chart is read-only. // Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); 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(); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true); assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!( assert!(
+4
View File
@@ -796,6 +796,10 @@ fn happy_path(name: &str) -> Mcp {
"books.set_asset_tax_basis", "books.set_asset_tax_basis",
json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00"}), 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", json!({}));
m.ok( m.ok(
"books.asset_labels", "books.asset_labels",
+5
View File
@@ -5,6 +5,11 @@ use thiserror::Error;
/// Every way a store operation can fail. /// Every way a store operation can fail.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum StoreError { 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. /// Filesystem error.
#[error("io error: {0}")] #[error("io error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
+68
View File
@@ -725,6 +725,48 @@ impl Store {
Ok(()) 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 /// Record an asset's federal tax basis (see
/// `Ledger::set_asset_tax_basis`). Journaled as `asset.tax_basis_set`. /// `Ledger::set_asset_tax_basis`). Journaled as `asset.tax_basis_set`.
pub fn set_asset_tax_basis( pub fn set_asset_tax_basis(
@@ -3488,6 +3530,17 @@ pub(crate) fn replay_line_into_ledger(
"asset.tagged" => { "asset.tagged" => {
ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?; 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" => { "asset.tax_basis_set" => {
let tax = tax_basis_from_journal(p)?; let tax = tax_basis_from_journal(p)?;
ledger.set_asset_tax_basis(get_u64(p, "asset_id")?, tax)?; 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], params![id, tag],
)?; )?;
} }
"asset.corrected" => {
let amt = |k: &str| -> Result<String, StoreError> {
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" => { "asset.tax_basis_set" => {
conn.execute( conn.execute(
"UPDATE assets SET tax_rule = ?, contributor_basis = ? WHERE id = ?", "UPDATE assets SET tax_rule = ?, contributor_basis = ? WHERE id = ?",
+41
View File
@@ -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(_))
));
}