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

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 ba7373bdb8
commit 1500d18cea
11 files changed
+289 -4

No files matched your search

+5
View File
@@ -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),
+68
View File
@@ -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 = ?",
+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(_))
));
}