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
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:
1 parent
6e6f9d65b2
commit
27e78a0ed5
11 files changed
+289
-4
No files matched your search
@@ -87,6 +87,19 @@ pub struct Asset {
|
||||
pub tax: Option<TaxBasis>,
|
||||
}
|
||||
|
||||
/// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct AssetCorrection {
|
||||
/// Capitalized cost.
|
||||
pub cost: Money,
|
||||
/// Residual value.
|
||||
pub salvage_value: Money,
|
||||
/// Useful life in months.
|
||||
pub useful_life_months: u32,
|
||||
/// In-service date (depreciation starts here).
|
||||
pub purchase_date: NaiveDate,
|
||||
}
|
||||
|
||||
/// Which rule sets an asset's federal tax basis.
|
||||
///
|
||||
/// ```text
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -311,6 +311,28 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
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!(
|
||||
|
||||
@@ -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(
|
||||
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."
|
||||
|
||||
@@ -1523,6 +1523,43 @@ pub struct RegisterAssetParams {
|
||||
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`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SetAssetTaxBasisParams {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<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" => {
|
||||
conn.execute(
|
||||
"UPDATE assets SET tax_rule = ?, contributor_basis = ? WHERE id = ?",
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
Reference in new issue
Block a user