P3: depreciation straight-line by days through the final month, schedules by day (defects 22, 9, D-37)

The final month takes what the months before leave, spread over its days;
a disposed asset stops on its disposal day. A skipped month is caught up
in the month the run is made; a reversed run un-posts its month. New
schedules spread by day; equal-months schedules must start on the 1st.
Amounts round to the currency's own minor unit. Kani and Lean proofs; an
exhaustive search over runs, reversals and disposals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:11 -06:00
1 parent 1304b9c0ad
commit b0a9fd8ad4
69 files changed
+10529 -841

No files matched your search

+116 -11
View File
@@ -93,10 +93,28 @@ pub struct EntryParty {
#[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`.
/// its amount, from `asset.depreciation_posted`, each entry in effect
/// (a reversed run is left out: its month is due again, as the store
/// reads it).
pub depreciation: BTreeMap<String, (EntryId, Decimal)>,
/// Depreciation runs reversed since (their months un-posted).
pub reversed_runs: Vec<EntryId>,
/// Every run journaled, reversed or not, with its amount.
pub runs: Vec<(EntryId, Decimal)>,
/// Each of [`Self::runs`]' month ("YYYY-MM"), in the same order.
pub periods: Vec<String>,
/// What the books carry for it, entry by entry, each in its own month,
/// as the catch-up rule reads it: every run, and every reversal of one
/// (ledger_core::depreciation_effects of [`Self::runs`], computed once
/// at load).
pub effects: Vec<ledger_core::PostedRun>,
/// The disposal entry, from `asset.disposed`.
pub disposal_entry: Option<EntryId>,
/// Depreciation the disposal entry itself charged (positive: the
/// store's catch_up_depreciation) or credited back (negative: its
/// excess_depreciation), so a disposed asset's depreciation is what
/// the disposal took.
pub disposal_true_up: Decimal,
/// 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
@@ -108,20 +126,31 @@ pub struct AssetTrail {
impl AssetTrail {
/// Every entry that touches the asset: purchase, depreciation (by
/// month), disposal.
/// month, then any run reversed), disposal.
pub fn entries(&self) -> Vec<EntryId> {
let mut out: Vec<EntryId> = self.purchase_entry.into_iter().collect();
out.extend(self.depreciation.values().map(|(e, _)| *e));
out.extend(&self.reversed_runs);
out.extend(self.disposal_entry);
out
}
/// Depreciation the books carry for it, summed exactly.
/// Depreciation the books carry for it, summed exactly: its runs in
/// effect, and what its disposal trued up (so a disposed asset's is
/// what the disposal took, ledger_store's dispose_preview).
pub fn posted(&self) -> Decimal {
self.depreciation.values().map(|(_, a)| *a).sum()
self.depreciation.values().map(|(_, a)| *a).sum::<Decimal>() + self.disposal_true_up
}
}
/// A "YYYY-MM" period as (year, month), read leniently (an earlier version
/// posted "10000-01").
pub fn period_month(period: &str) -> Option<(i32, u32)> {
let (y, m) = period.split_once('-')?;
let (y, m) = (y.parse().ok()?, m.parse().ok()?);
(1..=12).contains(&m).then_some((y, m))
}
/// One party's dealings with the company: its entries, documents, and
/// this year's totals, computed once per refresh.
///
@@ -201,8 +230,11 @@ pub struct PartyInfo {
/// 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.
/// asset held on `through` has in its schedule through `through`,
/// no run in effect for, and something due for (ledger_core::
/// run_amount): a month a later run caught up, or that rounds to
/// nothing, is never due (P3: a month skipped is caught up in the
/// month the run is made).
/// ```
#[derive(Clone, Debug, PartialEq)]
pub struct DepreciationDue {
@@ -1210,9 +1242,22 @@ impl Books {
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)) {
let trail = self.asset_trails.get(&a.id).cloned().unwrap_or_default();
// Due when a run for the month would post something: not when a
// later run caught it up, or it rounds to nothing (one pass).
let due: HashMap<(i32, u32), Decimal> =
ledger_core::run_amounts(a, (through.year(), through.month()), &trail.effects)?
.into_iter()
.map(|(y, m, r)| ((y, m), r.amount))
.collect();
for l in ledger_core::depreciation_schedule(a, through)? {
if trail.depreciation.contains_key(&l.period) {
continue;
}
if period_month(&l.period)
.and_then(|ym| due.get(&ym))
.is_some_and(|a| !a.is_zero())
{
periods.insert(l.period);
}
}
@@ -2030,6 +2075,7 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, As
.map(|a| (a.id, AssetTrail::default()))
.collect();
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
let mut gain_loss_accounts: HashMap<AssetId, Option<u64>> = HashMap::new();
for (i, line) in journal.iter().enumerate() {
let p = &line.payload;
let asset = match line.line_type.as_str() {
@@ -2050,13 +2096,46 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, As
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<Decimal>().ok()),
) {
t.depreciation.insert(period.to_string(), (entry, amount));
t.runs.push((entry, amount));
t.periods.push(period.to_string());
}
}
"asset.disposed" => {
t.disposal_entry = u(p, "entry_id");
if let Some(a) = asset {
gain_loss_accounts.insert(a, u(p, "gain_loss_account"));
}
}
"asset.disposed" => t.disposal_entry = u(p, "entry_id"),
_ => {}
}
}
// The runs in effect, by month; a run reversed (and not re-instated)
// no longer counts. Every run is read, not a month's latest: one
// re-instated after its month was run again and that run reversed
// counts (P3 review round 2, as the store reads it).
for t in out.values_mut() {
for (&(e, amount), period) in t.runs.iter().zip(&t.periods) {
if ledger.in_effect(e) {
t.depreciation.insert(period.clone(), (e, amount));
} else {
t.reversed_runs.push(e);
}
}
}
for t in out.values_mut() {
t.effects = ledger_core::depreciation_effects(ledger, &t.runs);
}
// What each disposal trued up, from its entry.
for (id, t) in out.iter_mut() {
if let (Some(a), Some(e)) = (
ledger.get_asset(*id),
t.disposal_entry.and_then(|e| ledger.get_entry(e)),
) {
let runs: Decimal = t.depreciation.values().map(|(_, a)| *a).sum();
let gain_loss = gain_loss_accounts.get(id).copied().flatten();
t.disposal_true_up = disposal_true_up(a, e, gain_loss, runs);
}
}
// 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.
@@ -2108,6 +2187,32 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, As
out
}
/// Depreciation a disposal entry itself charged (positive) or credited
/// back (negative): its lines on the asset's depreciation expense account
/// (ledger_store's disposal_lines: catch_up_depreciation debited,
/// excess_depreciation credited). When the disposal's gain or loss went to
/// that same account the lines can't be told apart, and the disposal took
/// the schedule through its day: that, less the runs in effect.
fn disposal_true_up(
asset: &ledger_core::Asset,
entry: &ledger_core::JournalEntry,
gain_loss_account: Option<u64>,
runs: Decimal,
) -> Decimal {
if gain_loss_account.is_some_and(|g| g != asset.expense_account) {
return entry
.lines()
.iter()
.filter(|l| l.account == asset.expense_account)
.map(|l| match l.side {
Side::Debit => l.amount.amount,
Side::Credit => -l.amount.amount,
})
.sum();
}
ledger_core::accumulated_depreciation(asset, NaiveDate::MAX).map_or(Decimal::ZERO, |t| t - runs)
}
/// Entry ids a journal line refers to.
pub fn entry_refs(line: &JournalLine) -> Vec<EntryId> {
let p = &line.payload;
+3
View File
@@ -539,6 +539,9 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
months: 12,
balance_account: prepaid,
pl_account: insurance,
// A new schedule spreads by the day (D-11, D-37): a small first
// and last month around eleven full ones.
convention: ledger_core::ScheduleConvention::Daily,
})?;
let mut open_invoices: Vec<(u64, NaiveDate, Decimal)> = Vec::new();
+107 -22
View File
@@ -15,7 +15,7 @@ use crate::fmt;
use crate::nav::{ReportKind, Route};
use crate::theme::{self, pal};
use crate::widgets;
use chrono::NaiveDate;
use chrono::{Datelike, NaiveDate};
use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::{Asset, AssetId, AssetRegister, AssetRegisterRow, AssetStatus, TaxBasisRule};
@@ -774,8 +774,14 @@ fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<'
pub enum MonthStatus {
/// Posted, by this entry.
Posted(ledger_core::EntryId),
/// The month has ended and nothing is posted for it.
/// The month has ended, nothing is posted for it, and a run for it
/// would post something.
Due,
/// The month has ended with no run of its own, and nothing is due for
/// it: a later run caught it up, its run was reversed after it ended
/// (the reversal's month catches it up), it rounds to nothing, or the
/// asset's disposal took it.
Covered,
/// The month under way.
ThisMonth,
/// A month still to come.
@@ -788,29 +794,91 @@ pub enum MonthStatus {
/// ```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.
/// time), unchanged and in order: for a disposed asset, through its
/// disposal day.
/// 2. A month with a run in effect (asset.depreciation_posted, not
/// reversed) is Posted(its entry); otherwise, once it has ended, Due
/// when a run for it would post something (ledger_core::run_amount,
/// over what the books carry, every run and reversal in its own
/// month) and Covered when not (a later run caught it up, its run was
/// reversed after it ended, or it rounds to nothing); ThisMonth when
/// today is in it, Scheduled after. A disposed asset's months are
/// never Due: its disposal took the schedule through its day.
/// 3. A schedule the books can't compute (an asset an earlier version
/// took) is refused, never a panic: the page says why.
/// ```
pub fn schedule(b: &Books, id: AssetId) -> Vec<(ledger_core::ScheduleLine, MonthStatus)> {
pub fn schedule(
b: &Books,
id: AssetId,
) -> Result<Vec<(ledger_core::ScheduleLine, MonthStatus)>, ledger_core::LedgerError> {
let Some(a) = b.ledger.get_asset(id) else {
return Vec::new();
return Ok(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)
// What a run would post for each month ended before this one, in one
// pass (a long life has many).
let ended = b
.today
.with_day(1)
.and_then(|d| d.pred_opt())
.map_or((i32::MIN, 1), |d| (d.year(), d.month()));
let disposed = matches!(a.status, AssetStatus::Disposed { .. });
let due: std::collections::HashMap<(i32, u32), Decimal> = if disposed {
Default::default()
} else {
ledger_core::run_amounts(a, ended, &trail.effects)?
.into_iter()
.map(|(y, m, r)| ((y, m), r.amount))
.collect()
};
Ok(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 disposed => MonthStatus::Covered,
None if l.period < this_month => {
let owed = crate::books::period_month(&l.period)
.and_then(|ym| due.get(&ym).copied())
.unwrap_or(l.amount);
if owed.is_zero() {
MonthStatus::Covered
} else {
MonthStatus::Due
}
}
None if l.period == this_month => MonthStatus::ThisMonth,
None => MonthStatus::Scheduled,
};
(l, st)
})
.collect()
.collect())
}
/// Depreciation for finished months that the books don't carry yet: what
/// a run of the last month before today's would post
/// (ledger_core::run_amounts), every skipped or reversed month caught up.
/// Zero for a disposed asset (its disposal took the schedule through its
/// day) or one with nothing due.
pub fn unposted(b: &Books, id: AssetId) -> Result<Decimal, ledger_core::LedgerError> {
let Some(a) = b
.ledger
.get_asset(id)
.filter(|a| a.status == AssetStatus::Active)
else {
return Ok(Decimal::ZERO);
};
let ended = b
.today
.with_day(1)
.and_then(|d| d.pred_opt())
.map_or((i32::MIN, 1), |d| (d.year(), d.month()));
Ok(
ledger_core::run_amounts(a, ended, &b.asset_trail(id).effects)?
.last()
.map_or(Decimal::ZERO, |(_, _, r)| r.amount),
)
}
/// One asset, completely.
@@ -1014,12 +1082,20 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
ui.add_space(14.0);
// The schedule.
let lines = schedule(b, id);
let due: Decimal = lines
.iter()
.filter(|(_, s)| *s == MonthStatus::Due)
.map(|(l, _)| l.amount)
.sum();
let (lines, schedule_error) = match schedule(b, id) {
Ok(lines) => (lines, None),
Err(e) => (Vec::new(), Some(e)),
};
if let Some(e) = &schedule_error {
notice_box(
ui,
&Notice {
problem: true,
text: format!("Its depreciation schedule can't be computed: {e}."),
},
);
}
let due = unposted(b, id).unwrap_or(Decimal::ZERO);
if due > Decimal::ZERO {
notice_box(
ui,
@@ -1051,10 +1127,12 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
},
|ui| {
if lines.is_empty() {
ui.label(
RichText::new("Nothing to depreciate: its salvage value is its cost.")
.color(p.muted),
);
let why = if schedule_error.is_some() {
"The schedule can't be computed (above)."
} else {
"Nothing to depreciate: its salvage value is its cost."
};
ui.label(RichText::new(why).color(p.muted));
return;
}
let amt = widgets::AMOUNT_MIN + 16.0;
@@ -1085,6 +1163,13 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
let (status, color) = match st {
MonthStatus::Posted(e) => (format!("Posted · entry #{e}"), p.positive),
MonthStatus::Due => ("Not posted".to_string(), p.warning),
MonthStatus::Covered if l.amount.is_zero() => {
("Rounds to nothing".to_string(), p.muted)
}
MonthStatus::Covered if a.status != AssetStatus::Active => {
("Taken by the disposal".to_string(), p.muted)
}
MonthStatus::Covered => ("Nothing due".to_string(), p.muted),
MonthStatus::ThisMonth => ("This month".to_string(), p.muted),
MonthStatus::Scheduled => ("Scheduled".to_string(), p.faint),
};
@@ -1422,7 +1507,7 @@ mod tests {
for i in 0..2 {
let (_dir, books, ids) = two_assets(&format!("asset-page-{i}"));
let a = books.ledger.get_asset(ids[i]).unwrap().clone();
let want = ledger_core::book_value(&a, today());
let want = ledger_core::book_value(&a, today()).unwrap();
let (texts, _) = render(books, Route::Asset(ids[i]), |_| {});
assert_eq!(
beside(&texts, "Book value today").text,
+31 -28
View File
@@ -11,6 +11,7 @@ use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::AssetStatus;
use ledger_core::PeriodStatus;
use ledger_core::assets::{accumulated_depreciation, book_value};
use rust_decimal::Decimal;
@@ -355,22 +356,9 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
},
|ui| {
for a in &assets {
// What has actually been posted, so this ties to the balance
// sheet; the schedule may run ahead of the books.
let acc: Decimal = b
.journal
.iter()
.filter(|l| {
l.line_type == "asset.depreciation_posted"
&& l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id)
})
.filter_map(|l| {
l.payload
.get("amount")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<Decimal>().ok())
})
.sum();
// What the books carry (runs in effect), so this ties to the
// balance sheet; the schedule may run ahead of the books.
let acc: Decimal = b.asset_trail(a.id).posted();
let bv = a.cost.amount - acc;
let scheduled = accumulated_depreciation(a, b.today);
let _ = book_value;
@@ -394,7 +382,11 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
"bought {} · {} months · {}",
fmt::date(a.purchase_date),
a.useful_life_months,
fmt::words(&format!("{:?}", a.status)).to_lowercase()
match &a.status {
AssetStatus::Active => "active".to_string(),
AssetStatus::Disposed { date, .. } =>
format!("disposed {}", fmt::date(*date)),
}
))
.font(theme::regular(12.0))
.color(p.muted),
@@ -406,11 +398,13 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
);
});
});
let behind = scheduled - acc;
let note = if behind > Decimal::ZERO {
format!(" · {} more scheduled through today", fmt::dollars(behind))
} else {
String::new()
let note = match scheduled {
Ok(s) if s > acc => {
format!(" · {} more scheduled through today", fmt::dollars(s - acc))
}
Ok(_) => String::new(),
// An earlier version's asset: said, never guessed.
Err(_) => " · its schedule can't be computed".to_string(),
};
progress(
ui,
@@ -489,12 +483,20 @@ fn schedules(ui: &mut Ui, b: &Books) {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
"{} · {} months from {}",
fmt::words(&format!("{:?}", s.kind)),
s.months,
fmt::month(s.start)
))
RichText::new(match s.convention {
ledger_core::ScheduleConvention::Daily => format!(
"{} · {} months from {}, by the day",
fmt::words(&format!("{:?}", s.kind)),
s.months,
fmt::date(s.start)
),
ledger_core::ScheduleConvention::MonthlyEven => format!(
"{} · {} months from {}",
fmt::words(&format!("{:?}", s.kind)),
s.months,
fmt::month(s.start)
),
})
.font(theme::regular(12.0))
.color(p.muted),
);
@@ -729,6 +731,7 @@ mod tests {
months: 12,
balance_account: prepaid,
pl_account: insurance,
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.unwrap();
b.s.run_schedules(d(2026, 3, 31)).unwrap();
+9 -1
View File
@@ -856,7 +856,15 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
),
"schedule.registered" => ev(
icon::CALENDAR,
format!("Schedule: {}", s(p, "description")),
format!(
"Schedule: {} ({})",
s(p, "description"),
// Journals before P3 carry no convention: equal months.
match s(p, "convention") {
"daily" => "by the day",
_ => "equal months",
}
),
Some(Route::Company),
),
"schedule.posted" => ev(