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:
69 files changed
+10529
-841
No files matched your search
+509
-133
@@ -5,13 +5,17 @@
|
||||
//! separate set of books with its own rules and stays with the CPA. This
|
||||
//! module never pretends to do tax.
|
||||
//!
|
||||
//! The register is master data owned by [`Ledger`](crate::Ledger): assets are journaled
|
||||
//! The register is master data owned by [`Ledger`]: assets are journaled
|
||||
//! like accounts (see `ledger-store`), and ids are allocator-deterministic
|
||||
//! for replay. Depreciation itself is pure math — [`depreciation_schedule`]
|
||||
//! — and postings go through the normal entry machinery, so every
|
||||
//! accounting invariant holds.
|
||||
|
||||
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money};
|
||||
use crate::money::Steps;
|
||||
use crate::{
|
||||
AccountId, AccountType, ChartOfAccounts, EntryId, EntryStatus, Ledger, LedgerError, Money,
|
||||
kernel,
|
||||
};
|
||||
use chrono::{Datelike, Months, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -24,8 +28,11 @@ pub type AssetId = u64;
|
||||
/// room for declining-balance later.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DepreciationMethod {
|
||||
/// Even allocation over the useful life: (cost − salvage) / months,
|
||||
/// prorated by days for partial first and last months.
|
||||
/// Even allocation over the useful life by the day: (cost − salvage) /
|
||||
/// months a month, prorated by days for a partial first month and any
|
||||
/// month a schedule stops in; the life's final month takes what the
|
||||
/// others leave, evenly over its days, so the life ends at exactly
|
||||
/// cost − salvage ([`depreciation_schedule`]).
|
||||
StraightLine,
|
||||
}
|
||||
|
||||
@@ -322,150 +329,501 @@ pub struct ScheduleLine {
|
||||
pub book_value: Decimal,
|
||||
}
|
||||
|
||||
/// The days in a month of a year chrono holds, without stepping to the
|
||||
/// next month, so chrono's last month (December 262142, whose next month
|
||||
/// chrono can't hold) has its 31 days too (rules.md B-28: a schedule an
|
||||
/// earlier version accepted is computed, never a panic).
|
||||
fn days_in_month(year: i32, month: u32) -> u32 {
|
||||
match month {
|
||||
2 if NaiveDate::from_ymd_opt(year, 2, 29).is_some() => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first day of the month after `month_start`'s, or `None` past
|
||||
/// chrono's last day (the month containing NaiveDate::MAX).
|
||||
fn next_month_start(month_start: NaiveDate) -> Option<NaiveDate> {
|
||||
month_start.checked_add_months(Months::new(1))
|
||||
}
|
||||
|
||||
/// The last day depreciation accrues: purchase + life, or disposal, whichever
|
||||
/// is earlier. The accrual window is [purchase_date, depreciation_end).
|
||||
fn depreciation_end(asset: &Asset) -> NaiveDate {
|
||||
let natural = asset
|
||||
/// The end of an asset's life, exclusive: its in-service date plus its
|
||||
/// life in months (chrono clamps a day past a month's end, so Jan 31 plus
|
||||
/// one month is Feb 28). The life is `[purchase_date, life_end)`.
|
||||
/// Refused (DateOutOfRange), never a panic, past chrono's last day: replay
|
||||
/// validation keeps every journaled asset's end a date, so this is for
|
||||
/// figures asked of a value no command took.
|
||||
pub fn life_end(asset: &Asset) -> Result<NaiveDate, LedgerError> {
|
||||
asset
|
||||
.purchase_date
|
||||
.checked_add_months(Months::new(asset.useful_life_months))
|
||||
.expect("useful life does not overflow dates");
|
||||
match &asset.status {
|
||||
AssetStatus::Active => natural,
|
||||
AssetStatus::Disposed { date, .. } => (*date).min(natural),
|
||||
}
|
||||
.ok_or(LedgerError::DateOutOfRange(asset.purchase_date))
|
||||
}
|
||||
|
||||
/// One month of a schedule, with its month as numbers (the line names it
|
||||
/// "YYYY-MM").
|
||||
struct MonthLine {
|
||||
year: i32,
|
||||
month: u32,
|
||||
line: ScheduleLine,
|
||||
}
|
||||
|
||||
/// Straight-line depreciation schedule from the purchase month through
|
||||
/// `through` (clamped to the depreciation end): one line per month, the
|
||||
/// last prorated by days when `through` falls mid-month.
|
||||
/// `through`, inclusive, and through the disposal day of a disposed asset:
|
||||
/// one line per month, the last prorated by days when the schedule stops
|
||||
/// mid-month.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: how much of an asset's cost has been used up, month by month.
|
||||
/// SOURCE: docs/spec/rules.md B-15 (straight line; salvage never
|
||||
/// depreciated; partial months by days); DECISION (owner,
|
||||
/// 2026-10-13): a schedule through a mid-month date prorates that
|
||||
/// month by days, as partial first and last months do.
|
||||
/// depreciated; partial months by days; ASC 360-10-35-4: cost less
|
||||
/// salvage over the useful life "in a systematic and rational
|
||||
/// manner", as equitably as possible to the periods served);
|
||||
/// DECISION (owner, 2026-10-13): a schedule through a mid-month
|
||||
/// date prorates that month by days, as partial first and last
|
||||
/// months do.
|
||||
/// INVARIANTS:
|
||||
/// 1. A full schedule MUST sum to exactly cost - salvage, to the cent.
|
||||
/// 2. Accumulated depreciation NEVER falls; book value NEVER rises and
|
||||
/// NEVER drops below salvage.
|
||||
/// 1. The life is [purchase, purchase + life months), cut into calendar
|
||||
/// months (calendar::month_parts). Through day k of a month but the
|
||||
/// last, accumulated is (cost − salvage) / life × (the months held
|
||||
/// before it, each as days held over days in the month, + k / days
|
||||
/// in this month): the monthly rate by days.
|
||||
/// 2. The last month of the life is charged what the months before it
|
||||
/// leave, evenly over its days (kernel::prorated_through): through
|
||||
/// its last day accumulated is exactly cost − salvage, and through an
|
||||
/// earlier day it is prorated by days, never plugged to the whole.
|
||||
/// 3. Each accumulated figure is rounded half away from zero to the
|
||||
/// currency's minor unit (cents; whole yen; a thousandth of a dinar);
|
||||
/// a month's amount is the difference of consecutive accumulated
|
||||
/// figures.
|
||||
/// 4. It stops at through, or at the disposal day of a disposed asset,
|
||||
/// whichever is earlier, both inclusive (the disposal day is held:
|
||||
/// disposal_amounts), and never runs past the life.
|
||||
/// 5. Accumulated depreciation NEVER falls and NEVER exceeds cost −
|
||||
/// salvage; book value NEVER rises and NEVER drops below salvage.
|
||||
/// 6. Through every month end it equals the per-month running-total
|
||||
/// method books posted before P3, except that a half-cent tie is
|
||||
/// rounded exactly here (Decimal's 28-digit quotients could land on
|
||||
/// either side of one).
|
||||
/// 7. NEVER panics: a figure that can't be computed (a life past
|
||||
/// chrono's last day, amounts past exact arithmetic) is refused
|
||||
/// (rules.md B-28).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF $12,000 over 12 months from Jan 1, through Jun 15:
|
||||
/// - SHOULD end with June at 500.00 (15 of 30 days), accumulated
|
||||
/// 5,500.00
|
||||
/// BECAUSE depreciation accrues by the day within a month.
|
||||
/// 2. IF the same asset through Dec 15, its final month:
|
||||
/// - SHOULD end with December at 1,000 × 15/31 = 483.87, accumulated
|
||||
/// 11,483.87
|
||||
/// - NEVER plug December to 1,000.00 before its last day
|
||||
/// BECAUSE the final month is held by the day like any other.
|
||||
/// 3. IF $930 depreciable over 3 months from Jan 15 (the life ends
|
||||
/// Apr 15; Jan 17/31, Feb, Mar leave 140.00 for Apr 1-14), through
|
||||
/// Apr 13:
|
||||
/// - SHOULD be April 130.00 (13 of its 14 days), accumulated 920.00
|
||||
/// BECAUSE the month-length drift is spread over the final days.
|
||||
/// 4. IF the first asset is disposed of on Jun 15:
|
||||
/// - SHOULD stop there: June 500.00, accumulated 5,500.00, what the
|
||||
/// disposal posted
|
||||
/// - NEVER show full depreciation for a disposed asset.
|
||||
/// ```
|
||||
///
|
||||
/// Partial first and last months are prorated by days held. Each month is
|
||||
/// rounded to cents via the running-total method, and the final month
|
||||
/// trues up: partial months in different-length calendar months don't
|
||||
/// complement exactly (17/31 + 14/30 ≠ 1), so the last month absorbs the
|
||||
/// drift and the lifetime total is exactly (cost − salvage) — never a
|
||||
/// penny off.
|
||||
pub fn depreciation_schedule(asset: &Asset, through: NaiveDate) -> Vec<ScheduleLine> {
|
||||
if through < asset.purchase_date {
|
||||
return Vec::new();
|
||||
}
|
||||
let depreciable = asset.cost.amount - asset.salvage_value.amount;
|
||||
if depreciable <= Decimal::ZERO {
|
||||
return Vec::new();
|
||||
}
|
||||
let end = depreciation_end(asset);
|
||||
// Accrual is inclusive of `through`: work with an exclusive end.
|
||||
let accrual_end = through.succ_opt().unwrap_or(through).min(end);
|
||||
if accrual_end <= asset.purchase_date {
|
||||
return Vec::new();
|
||||
}
|
||||
let monthly_rate = depreciable / Decimal::from(asset.useful_life_months);
|
||||
pub fn depreciation_schedule(
|
||||
asset: &Asset,
|
||||
through: NaiveDate,
|
||||
) -> Result<Vec<ScheduleLine>, LedgerError> {
|
||||
Ok(schedule_months(asset, through)?
|
||||
.into_iter()
|
||||
.map(|m| m.line)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// [`depreciation_schedule`], each line with its year and month.
|
||||
fn schedule_months(asset: &Asset, through: NaiveDate) -> Result<Vec<MonthLine>, LedgerError> {
|
||||
let stop = match &asset.status {
|
||||
AssetStatus::Active => through,
|
||||
AssetStatus::Disposed { date, .. } => through.min(*date),
|
||||
};
|
||||
if stop < asset.purchase_date {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let too_large =
|
||||
|| LedgerError::TooLargeToCompute(format!("asset {}'s depreciation schedule", asset.id));
|
||||
let depreciable = asset
|
||||
.cost
|
||||
.amount
|
||||
.checked_sub(asset.salvage_value.amount)
|
||||
.ok_or_else(too_large)?;
|
||||
if depreciable <= Decimal::ZERO {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let end = life_end(asset)?;
|
||||
let steps = Steps::of(depreciable, asset.cost.currency).ok_or_else(too_large)?;
|
||||
let mut out = Vec::new();
|
||||
let mut cumulative_exact = Decimal::ZERO;
|
||||
let mut accumulated_rounded = Decimal::ZERO;
|
||||
// Walk calendar months from the purchase month.
|
||||
let (mut year, mut month) = (asset.purchase_date.year(), asset.purchase_date.month());
|
||||
// Past chrono's last month there is no month to walk, and nothing left
|
||||
// to accrue: accrual_end is at most NaiveDate::MAX.
|
||||
while let Some(month_start) = NaiveDate::from_ymd_opt(year, month, 1) {
|
||||
if month_start >= accrual_end {
|
||||
let (mut before, mut charged) = (0i128, 0i128);
|
||||
for p in crate::calendar::month_parts(asset.purchase_date, end) {
|
||||
if p.first > stop {
|
||||
break;
|
||||
}
|
||||
let dim = days_in_month(year, month);
|
||||
// Days held this month: overlap of [purchase_date, accrual_end) with
|
||||
// the month. In chrono's last month there is no next month to end
|
||||
// at, and accrual_end (at most NaiveDate::MAX) is the bound.
|
||||
let held_start = asset.purchase_date.max(month_start);
|
||||
let held_end = next_month_start(month_start).map_or(accrual_end, |n| accrual_end.min(n));
|
||||
let days_held = (held_end - held_start).num_days().max(0) as u32;
|
||||
if days_held > 0 {
|
||||
let exact = monthly_rate * Decimal::from(days_held) / Decimal::from(dim);
|
||||
cumulative_exact += exact;
|
||||
let rounded_cumulative = cumulative_exact
|
||||
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||||
let amount = rounded_cumulative - accumulated_rounded;
|
||||
accumulated_rounded = rounded_cumulative;
|
||||
out.push(ScheduleLine {
|
||||
period: format!("{year:04}-{month:02}"),
|
||||
amount,
|
||||
accumulated: accumulated_rounded,
|
||||
book_value: asset.cost.amount - accumulated_rounded,
|
||||
// Days of this month held through the stop day, inclusive.
|
||||
let held = (stop - p.first).num_days().saturating_add(1);
|
||||
let k = u32::try_from(held).map_or(p.part.days, |h| h.min(p.part.days));
|
||||
let through_k = kernel::prorated_through(
|
||||
steps.units,
|
||||
asset.useful_life_months,
|
||||
before,
|
||||
p.part,
|
||||
k,
|
||||
p.last,
|
||||
steps.unit,
|
||||
)
|
||||
.ok_or_else(too_large)?;
|
||||
let accumulated = steps.decimal(through_k).ok_or_else(too_large)?;
|
||||
out.push(MonthLine {
|
||||
year: p.year,
|
||||
month: p.month,
|
||||
line: ScheduleLine {
|
||||
period: format!("{:04}-{:02}", p.year, p.month),
|
||||
amount: steps.decimal(through_k - charged).ok_or_else(too_large)?,
|
||||
accumulated,
|
||||
book_value: asset
|
||||
.cost
|
||||
.amount
|
||||
.checked_sub(accumulated)
|
||||
.ok_or_else(too_large)?,
|
||||
},
|
||||
});
|
||||
charged = through_k;
|
||||
if k < p.part.days {
|
||||
break;
|
||||
}
|
||||
before = before
|
||||
.checked_add(kernel::part_weight(p.part).ok_or_else(too_large)?)
|
||||
.ok_or_else(too_large)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Accumulated depreciation through `as_of` (inclusive), exact to the
|
||||
/// minor unit ([`depreciation_schedule`]).
|
||||
pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Result<Decimal, LedgerError> {
|
||||
Ok(depreciation_schedule(asset, as_of)?
|
||||
.last()
|
||||
.map(|l| l.accumulated)
|
||||
.unwrap_or(Decimal::ZERO))
|
||||
}
|
||||
|
||||
/// Book value (cost − accumulated depreciation) as of a date.
|
||||
pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Result<Decimal, LedgerError> {
|
||||
asset
|
||||
.cost
|
||||
.amount
|
||||
.checked_sub(accumulated_depreciation(asset, as_of)?)
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("asset {}'s book value", asset.id)))
|
||||
}
|
||||
|
||||
/// A change to what the books carry for an asset's depreciation, in the
|
||||
/// month of the entry that made it, as the catch-up rule reads it
|
||||
/// ([`run_amount`], [`depreciation_effects`]): a run adds its amount in its
|
||||
/// month; a reversal of the run takes it away (a negative amount) in the
|
||||
/// reversal's month, and a reversal of that reversal adds it back in its
|
||||
/// own. Summed through a month's end, they are what the books carry for
|
||||
/// the asset's runs then.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PostedRun {
|
||||
/// The entry's year.
|
||||
pub year: i32,
|
||||
/// The entry's month, 1 to 12.
|
||||
pub month: u32,
|
||||
/// What it adds to what the books carry (negative for a reversal).
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
/// What an asset's depreciation runs did to what the books carry, entry by
|
||||
/// entry, each in its own month: for every run (`runs`: each run's entry
|
||||
/// and amount, reversed or not, its disposal's final depreciation
|
||||
/// included), the run itself and every entry of its reversal chain
|
||||
/// ([`Ledger::reversal_chain`]), alternately adding and taking away its
|
||||
/// amount.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the catch-up rule reads the books as they stood at each month
|
||||
/// end: a run reversed after its month ended still carried that
|
||||
/// month, so running the month again would carry it twice.
|
||||
/// SOURCE: docs/spec/rules.md B-9 (a mistake is corrected by an opposite
|
||||
/// entry, dated when it is made), B-10 (reversing the reversal
|
||||
/// re-instates), B-15 (the catch-up rule).
|
||||
/// INVARIANTS:
|
||||
/// 1. Through any month's end, the effects sum to what the runs' entries
|
||||
/// and their reversals dated on or before it carry in the asset's
|
||||
/// accumulated depreciation.
|
||||
/// 2. A run in effect sums to its amount; one reversed, to zero.
|
||||
/// ```
|
||||
pub fn depreciation_effects(ledger: &Ledger, runs: &[(EntryId, Decimal)]) -> Vec<PostedRun> {
|
||||
let mut out = Vec::new();
|
||||
for &(run, amount) in runs {
|
||||
let chain = ledger.reversal_chain(run);
|
||||
let from = chain.iter().position(|&e| e == run).unwrap_or(0);
|
||||
for (i, e) in chain[from..].iter().enumerate() {
|
||||
let Some(entry) = ledger
|
||||
.get_entry(*e)
|
||||
.filter(|x| x.status() == EntryStatus::Posted)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let d = entry.date();
|
||||
out.push(PostedRun {
|
||||
year: d.year(),
|
||||
month: d.month(),
|
||||
amount: if i % 2 == 0 { amount } else { -amount },
|
||||
});
|
||||
}
|
||||
if month == 12 {
|
||||
year += 1;
|
||||
month = 1;
|
||||
} else {
|
||||
month += 1;
|
||||
}
|
||||
}
|
||||
// True-up: if the schedule reaches the final month of the asset's life,
|
||||
// absorb any month-length drift so the lifetime total is exactly
|
||||
// (cost − salvage). The drift is bounded by a fraction of one month's
|
||||
// charge, so the final month stays positive.
|
||||
// The final month named as the lines name theirs: chrono's "%Y" signs
|
||||
// a year past 9999 ("+10004"), and the lines don't, so books an
|
||||
// earlier version wrote with such a life true up too (rules.md B-28).
|
||||
if let Some(last) = out.last_mut()
|
||||
&& let Some(day_before_end) = end.pred_opt()
|
||||
&& last.period == format!("{:04}-{:02}", day_before_end.year(), day_before_end.month())
|
||||
{
|
||||
let plug = depreciable - accumulated_rounded;
|
||||
last.amount += plug;
|
||||
last.accumulated = depreciable;
|
||||
last.book_value = asset.cost.amount - depreciable;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Accumulated depreciation through `as_of` (inclusive), exact cents.
|
||||
pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Decimal {
|
||||
depreciation_schedule(asset, as_of)
|
||||
.last()
|
||||
.map(|l| l.accumulated)
|
||||
.unwrap_or(Decimal::ZERO)
|
||||
/// What a depreciation run for a month posts ([`run_amount`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RunAmount {
|
||||
/// What the run posts: zero when nothing is due.
|
||||
pub amount: Decimal,
|
||||
/// The month's own scheduled amount ([`depreciation_schedule`]; zero
|
||||
/// for a month after the life).
|
||||
pub scheduled: Decimal,
|
||||
/// What the books carry beyond the schedule through the month's end:
|
||||
/// zero unless something carried them past it (a run an earlier
|
||||
/// version posted on a half-cent tie, a correction by hand), which no
|
||||
/// run takes back: a human corrects it.
|
||||
pub over: Decimal,
|
||||
}
|
||||
|
||||
/// Book value (cost − accumulated depreciation) as of a date.
|
||||
pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Decimal {
|
||||
asset.cost.amount - accumulated_depreciation(asset, as_of)
|
||||
/// What a depreciation run would post for every month from the asset's
|
||||
/// first through `through` (year, month), in one pass: [`run_amount`] of
|
||||
/// each, as a viewer asks it of every month of a life (a 10,000-year life
|
||||
/// included). It stops at the later of the life's last month and the last
|
||||
/// month an entry in `posted` ([`depreciation_effects`]) falls in, if
|
||||
/// `through` is past both: every month after them would say the same as
|
||||
/// the first one after.
|
||||
///
|
||||
/// ```text
|
||||
/// INVARIANTS:
|
||||
/// 1. Each month's amount and over are run_amount's for it
|
||||
/// (kernel::catch_ups is catch_up of every suffix).
|
||||
/// 2. Empty when `through` is before the asset's first month.
|
||||
/// ```
|
||||
pub fn run_amounts(
|
||||
asset: &Asset,
|
||||
through: (i32, u32),
|
||||
posted: &[PostedRun],
|
||||
) -> Result<Vec<(i32, u32, RunAmount)>, LedgerError> {
|
||||
let first = (asset.purchase_date.year(), asset.purchase_date.month());
|
||||
if through < first {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let last_run = posted.iter().map(|r| (r.year, r.month)).max();
|
||||
let life_last = life_end(asset)?
|
||||
.pred_opt()
|
||||
.map_or(first, |d| (d.year(), d.month()));
|
||||
// One month past the life and the runs says what every later one does
|
||||
// (and past chrono's last month there is none).
|
||||
let after = life_last.max(last_run.unwrap_or(first));
|
||||
let settled = Some(next_month(after))
|
||||
.filter(|&(y, m)| NaiveDate::from_ymd_opt(y, m, 1).is_some())
|
||||
.unwrap_or(after);
|
||||
let through = through.min(settled);
|
||||
let latest = last_run.map_or(through, |r| r.max(through));
|
||||
let end = NaiveDate::from_ymd_opt(latest.0, latest.1, 1)
|
||||
.map(crate::schedules::month_end)
|
||||
.ok_or_else(|| {
|
||||
LedgerError::InvalidDepreciationPeriod(format!("{:04}-{:02}", latest.0, latest.1))
|
||||
})?;
|
||||
let lines = schedule_months(asset, end)?;
|
||||
let c = cumulatives(asset, first, latest, &lines, posted)?;
|
||||
let mut due = vec![Decimal::ZERO; c.months.len()];
|
||||
kernel::catch_ups(&c.scheduled, &c.carried, &mut due).ok_or_else(|| {
|
||||
LedgerError::TooLargeToCompute(format!("asset {}'s depreciation runs", asset.id))
|
||||
})?;
|
||||
let mut li = 0;
|
||||
Ok(c.months
|
||||
.into_iter()
|
||||
.zip(due)
|
||||
.take_while(|((y, m), _)| (*y, *m) <= through)
|
||||
.zip(c.scheduled.iter().zip(&c.carried))
|
||||
.map(|(((y, m), amount), (&s, &g))| {
|
||||
while lines.get(li).is_some_and(|l| (l.year, l.month) < (y, m)) {
|
||||
li += 1;
|
||||
}
|
||||
let own = lines
|
||||
.get(li)
|
||||
.filter(|l| (l.year, l.month) == (y, m))
|
||||
.map_or(Decimal::ZERO, |l| l.line.amount);
|
||||
(
|
||||
y,
|
||||
m,
|
||||
RunAmount {
|
||||
amount,
|
||||
scheduled: own,
|
||||
over: g
|
||||
.checked_sub(s)
|
||||
.map_or(Decimal::ZERO, |o| o.max(Decimal::ZERO)),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The month after (year, month).
|
||||
fn next_month((y, m): (i32, u32)) -> (i32, u32) {
|
||||
if m == 12 { (y + 1, 1) } else { (y, m + 1) }
|
||||
}
|
||||
|
||||
/// The schedule and what the books carry, each cumulative, at successive
|
||||
/// month ends.
|
||||
struct Cumulatives {
|
||||
/// The months, (year, month).
|
||||
months: Vec<(i32, u32)>,
|
||||
/// The schedule through each month's end.
|
||||
scheduled: Vec<Decimal>,
|
||||
/// What the books carry through each month's end: the effects
|
||||
/// ([`PostedRun`]) of months on or before it.
|
||||
carried: Vec<Decimal>,
|
||||
}
|
||||
|
||||
/// The months from `first` through `latest`, with the schedule and what
|
||||
/// the books carry, each cumulative, at every month end: one pass over the
|
||||
/// months, the lines and the effects. The schedule through a month's end
|
||||
/// is its line, or the last line before it (after the life, the whole of
|
||||
/// it).
|
||||
fn cumulatives(
|
||||
asset: &Asset,
|
||||
first: (i32, u32),
|
||||
latest: (i32, u32),
|
||||
lines: &[MonthLine],
|
||||
posted: &[PostedRun],
|
||||
) -> Result<Cumulatives, LedgerError> {
|
||||
let too_large =
|
||||
|| LedgerError::TooLargeToCompute(format!("asset {}'s depreciation run", asset.id));
|
||||
let mut runs: Vec<&PostedRun> = posted.iter().collect();
|
||||
runs.sort_by_key(|r| (r.year, r.month));
|
||||
let (mut months, mut scheduled, mut carried) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let (mut through, mut carry) = (Decimal::ZERO, Decimal::ZERO);
|
||||
let (mut li, mut ri) = (0, 0);
|
||||
let (mut y, mut m) = first;
|
||||
loop {
|
||||
while let Some(l) = lines.get(li).filter(|l| (l.year, l.month) <= (y, m)) {
|
||||
through = l.line.accumulated;
|
||||
li += 1;
|
||||
}
|
||||
while let Some(r) = runs.get(ri).filter(|r| (r.year, r.month) <= (y, m)) {
|
||||
carry = carry.checked_add(r.amount).ok_or_else(too_large)?;
|
||||
ri += 1;
|
||||
}
|
||||
months.push((y, m));
|
||||
scheduled.push(through);
|
||||
carried.push(carry);
|
||||
if (y, m) >= latest {
|
||||
break;
|
||||
}
|
||||
(y, m) = next_month((y, m));
|
||||
}
|
||||
Ok(Cumulatives {
|
||||
months,
|
||||
scheduled,
|
||||
carried,
|
||||
})
|
||||
}
|
||||
|
||||
/// What a depreciation run for `year`-`month` posts, dated at that month's
|
||||
/// end, given what the books carry for the asset (`posted`: every run and
|
||||
/// every reversal of one, each in its own month, [`depreciation_effects`]).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the books carry the schedule through every month end a run
|
||||
/// reaches; a month skipped is caught up in the month the run is
|
||||
/// made, and a run never posts more than the schedule says.
|
||||
/// SOURCE: plan/served.md P3 "Settled in design" (2026-10-20): a month of
|
||||
/// depreciation skipped is caught up in the month the run is made
|
||||
/// (an immaterial miss; a material one is restated, ASC 250, a
|
||||
/// correction for a human and the CPA); docs/spec/rules.md B-15.
|
||||
/// INVARIANTS:
|
||||
/// 1. amount = catch_up_room for the month: kernel::catch_up over the
|
||||
/// month ends M from this month's through the latest month an effect
|
||||
/// falls in, the schedule through M against what the books carry
|
||||
/// through M. So amount >= 0, and the books never carry more than the
|
||||
/// schedule at any of those month ends after this run (when they
|
||||
/// didn't before).
|
||||
/// 2. With nothing after this month, amount is everything due through
|
||||
/// its end: earlier months skipped, or a run reversed, are caught up
|
||||
/// here.
|
||||
/// 3. A month a later run already caught up posts nothing; so does a
|
||||
/// month whose run was reversed after the month ended (the books
|
||||
/// carried it then: the run for the reversal's month catches it up),
|
||||
/// so a month is never carried twice at its end.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF January ran, February was skipped, and March runs:
|
||||
/// - SHOULD post February's and March's depreciation in March
|
||||
/// BECAUSE a skipped month is caught up in the month the run is made.
|
||||
/// 2. IF February is run after that: SHOULD post nothing.
|
||||
/// 3. IF January's run is reversed on Feb 2 and January is run again:
|
||||
/// - SHOULD post nothing, and a February run 2,000.00
|
||||
/// - NEVER carry January twice on Jan 31
|
||||
/// BECAUSE the reversal is February's correction, caught up there.
|
||||
/// 4. IF the month is after the life and everything is posted: SHOULD
|
||||
/// post nothing; IF something is still due (a final month skipped,
|
||||
/// or in a closed period): SHOULD post it.
|
||||
/// 5. IF the month ends before the asset was placed in service: SHOULD
|
||||
/// refuse (InvalidDepreciationPeriod).
|
||||
/// ```
|
||||
pub fn run_amount(
|
||||
asset: &Asset,
|
||||
year: i32,
|
||||
month: u32,
|
||||
posted: &[PostedRun],
|
||||
) -> Result<RunAmount, LedgerError> {
|
||||
let period = format!("{year:04}-{month:02}");
|
||||
if month_end_of(year, month)? < asset.purchase_date {
|
||||
return Err(LedgerError::InvalidDepreciationPeriod(format!(
|
||||
"{period} ends before asset {} was placed in service on {}",
|
||||
asset.id, asset.purchase_date
|
||||
)));
|
||||
}
|
||||
catch_up_room(asset, year, month, posted)
|
||||
}
|
||||
|
||||
/// The last day of `year`-`month`, refused for a month chrono can't hold.
|
||||
fn month_end_of(year: i32, month: u32) -> Result<NaiveDate, LedgerError> {
|
||||
NaiveDate::from_ymd_opt(year, month, 1)
|
||||
.map(crate::schedules::month_end)
|
||||
.ok_or_else(|| LedgerError::InvalidDepreciationPeriod(format!("{year:04}-{month:02}")))
|
||||
}
|
||||
|
||||
/// The most an entry dated in `year`-`month` can add to what the books
|
||||
/// carry for the asset (`posted`, [`depreciation_effects`]) without their
|
||||
/// carrying more than its schedule at that month's end or any later one an
|
||||
/// effect reaches: what a run of the month posts ([`run_amount`]), and the
|
||||
/// most a reversal that re-instates a run may add back
|
||||
/// (`Store::check_reverse_entry`). A month before the asset's first counts
|
||||
/// its schedule as nothing.
|
||||
///
|
||||
/// ```text
|
||||
/// INVARIANTS:
|
||||
/// 1. amount = kernel::catch_up(scheduled[M..], carried[M..]) over the
|
||||
/// month ends from this month's through the latest an effect falls in;
|
||||
/// over = what the books carry beyond the schedule through this
|
||||
/// month's end (zero normally).
|
||||
/// 2. NEVER panics: a month chrono can't hold is refused.
|
||||
/// ```
|
||||
pub fn catch_up_room(
|
||||
asset: &Asset,
|
||||
year: i32,
|
||||
month: u32,
|
||||
posted: &[PostedRun],
|
||||
) -> Result<RunAmount, LedgerError> {
|
||||
month_end_of(year, month)?;
|
||||
let latest = posted
|
||||
.iter()
|
||||
.map(|r| (r.year, r.month))
|
||||
.chain([(year, month)])
|
||||
.max()
|
||||
.expect("this month is in the list");
|
||||
let lines = schedule_months(asset, month_end_of(latest.0, latest.1)?)?;
|
||||
let too_large =
|
||||
|| LedgerError::TooLargeToCompute(format!("asset {}'s depreciation run", asset.id));
|
||||
let c = cumulatives(asset, (year, month), latest, &lines, posted)?;
|
||||
let over = c.carried[0]
|
||||
.checked_sub(c.scheduled[0])
|
||||
.ok_or_else(too_large)?
|
||||
.max(Decimal::ZERO);
|
||||
Ok(RunAmount {
|
||||
amount: kernel::catch_up(&c.scheduled, &c.carried).ok_or_else(too_large)?,
|
||||
scheduled: lines
|
||||
.iter()
|
||||
.find(|l| (l.year, l.month) == (year, month))
|
||||
.map_or(Decimal::ZERO, |l| l.line.amount),
|
||||
over,
|
||||
})
|
||||
}
|
||||
|
||||
/// The amounts for a disposal entry: what to debit/credit where.
|
||||
@@ -480,17 +838,15 @@ pub struct DisposalAmounts {
|
||||
pub gain_loss: Decimal,
|
||||
}
|
||||
|
||||
/// Compute disposal amounts. Pure math; posting is the caller's job.
|
||||
/// A disposal may fall on the purchase date (one day held, accrual being
|
||||
/// inclusive, B-15 in docs/spec/rules.md); one before it is an error.
|
||||
pub fn disposal_amounts(
|
||||
asset: &Asset,
|
||||
date: NaiveDate,
|
||||
proceeds: Money,
|
||||
) -> Result<DisposalAmounts, LedgerError> {
|
||||
/// Whether an asset can be disposed of on `date` for `proceeds`: it is
|
||||
/// active, the proceeds aren't negative, and the date isn't before it was
|
||||
/// placed in service (a disposal on that date holds it one day, accrual
|
||||
/// being inclusive, B-15). The checks only: no depreciation is computed,
|
||||
/// so replaying a journaled disposal never depends on a figure (rules.md
|
||||
/// B-28).
|
||||
pub fn check_disposal(asset: &Asset, date: NaiveDate, proceeds: Money) -> Result<(), LedgerError> {
|
||||
if asset.status != AssetStatus::Active {
|
||||
let id = asset.id;
|
||||
return Err(LedgerError::AssetAlreadyDisposed(id));
|
||||
return Err(LedgerError::AssetAlreadyDisposed(asset.id));
|
||||
}
|
||||
if proceeds.amount < Decimal::ZERO {
|
||||
return Err(LedgerError::NegativeProceeds(proceeds.amount));
|
||||
@@ -498,12 +854,32 @@ pub fn disposal_amounts(
|
||||
if date < asset.purchase_date {
|
||||
return Err(LedgerError::DisposalBeforePurchase(date.to_string()));
|
||||
}
|
||||
let accumulated = accumulated_depreciation(asset, date);
|
||||
let book = asset.cost.amount - accumulated;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute disposal amounts. Pure math; posting is the caller's job.
|
||||
/// A disposal may fall on the purchase date (one day held, accrual being
|
||||
/// inclusive, B-15 in docs/spec/rules.md); one before it is an error
|
||||
/// ([`check_disposal`]). Accumulated depreciation is the schedule through
|
||||
/// the disposal day, inclusive: what the schedule of the asset, once
|
||||
/// disposed, shows ([`depreciation_schedule`] INVARIANT 4).
|
||||
pub fn disposal_amounts(
|
||||
asset: &Asset,
|
||||
date: NaiveDate,
|
||||
proceeds: Money,
|
||||
) -> Result<DisposalAmounts, LedgerError> {
|
||||
check_disposal(asset, date, proceeds)?;
|
||||
let accumulated = accumulated_depreciation(asset, date)?;
|
||||
let too_large = || LedgerError::TooLargeToCompute(format!("asset {}'s disposal", asset.id));
|
||||
let book = asset
|
||||
.cost
|
||||
.amount
|
||||
.checked_sub(accumulated)
|
||||
.ok_or_else(too_large)?;
|
||||
Ok(DisposalAmounts {
|
||||
book_value: book,
|
||||
accumulated,
|
||||
gain_loss: proceeds.amount - book,
|
||||
gain_loss: proceeds.amount.checked_sub(book).ok_or_else(too_large)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user