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

+509 -133
View File
@@ -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)?,
})
}
+138 -5
View File
@@ -18,8 +18,9 @@
//! ```
use crate::LedgerError;
use crate::kernel::MonthPart;
use crate::settings::Company;
use chrono::{Datelike, NaiveDate};
use chrono::{Datelike, Months, NaiveDate};
/// The first day the books keep.
pub const FIRST_DAY: NaiveDate = match NaiveDate::from_ymd_opt(1, 1, 1) {
@@ -248,10 +249,146 @@ impl PeriodDefaults {
}
}
/// 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).
pub 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).
pub fn next_month_start(month_start: NaiveDate) -> Option<NaiveDate> {
month_start.checked_add_months(Months::new(1))
}
/// One calendar month's share of a span of days ([`month_parts`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SpanPart {
/// The month's year.
pub year: i32,
/// The month, 1 to 12.
pub month: u32,
/// The span's first day in the month.
pub first: NaiveDate,
/// Its days in the month, of the month's days.
pub part: MonthPart,
/// True for the span's last month (the one holding its last day).
pub last: bool,
}
/// The calendar months of the span `[start, end)`, in order, each with the
/// span's days in it: lazily, so a walk through a date reads only the
/// months up to it.
///
/// ```text
/// GOAL: cut a span of days (an asset's life, a schedule's coverage) into
/// the calendar months it touches, for proration by days.
/// SOURCE: docs/spec/rules.md B-15, B-16; kernel::prorate_by_days.
/// INVARIANTS:
/// 1. The parts' days sum to the span's days; every part has 1 to its
/// month's days (a valid kernel::MonthPart), and only the last is
/// marked last.
/// 2. NEVER steps past chrono's last day: December 262142, whose next
/// month chrono can't hold, still has its 31 days, and its part ends
/// at `end`.
/// 3. An empty span (end <= start) has no parts.
/// ```
pub fn month_parts(start: NaiveDate, end: NaiveDate) -> impl Iterator<Item = SpanPart> {
let mut at = (start < end).then_some(start);
std::iter::from_fn(move || {
let first = at?;
let month_start = first.with_day(1)?;
let next = next_month_start(month_start);
// The span ends in this month when its end is no later than the
// next month's first day (or chrono has no next month).
let last = next.is_none_or(|n| end <= n);
let part_end = if last { end } else { next? };
let days = u32::try_from((part_end - first).num_days()).ok()?;
at = if last { None } else { next };
Some(SpanPart {
year: first.year(),
month: first.month(),
first,
part: MonthPart {
days,
month_days: days_in_month(first.year(), first.month()),
},
last,
})
})
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
/// INVARIANT 1, on a span from mid-month with a short final month.
#[test]
fn a_span_cuts_into_its_calendar_months() {
let parts: Vec<_> = month_parts(d(2026, 1, 15), d(2026, 4, 15)).collect();
let got: Vec<_> = parts
.iter()
.map(|p| (p.year, p.month, p.part.days, p.part.month_days, p.last))
.collect();
assert_eq!(
got,
[
(2026, 1, 17, 31, false),
(2026, 2, 28, 28, false),
(2026, 3, 31, 31, false),
(2026, 4, 14, 30, true),
]
);
assert_eq!(parts[0].first, d(2026, 1, 15));
assert_eq!(parts[3].first, d(2026, 4, 1));
// A span from the 1st: whole months, the last one marked.
let whole: Vec<_> = month_parts(d(2028, 1, 1), d(2028, 3, 1))
.map(|p| (p.part.days, p.part.month_days, p.last))
.collect();
assert_eq!(whole, [(31, 31, false), (29, 29, true)]);
// INVARIANT 3.
assert_eq!(month_parts(d(2026, 1, 1), d(2026, 1, 1)).count(), 0);
assert_eq!(month_parts(d(2026, 1, 2), d(2026, 1, 1)).count(), 0);
}
/// INVARIANT 2: chrono's last month is walked to the span's end.
#[test]
fn the_walk_reaches_chronos_last_month() {
let y = NaiveDate::MAX.year();
let parts: Vec<_> = month_parts(d(y, 11, 20), d(y, 12, 15)).collect();
assert_eq!(parts.len(), 2);
assert_eq!(
parts[1].part,
MonthPart {
days: 14,
month_days: 31
}
);
assert!(parts[1].last);
let to_max: Vec<_> = month_parts(d(y, 12, 1), NaiveDate::MAX).collect();
assert_eq!(to_max.len(), 1);
assert_eq!(
to_max[0].part,
MonthPart {
days: 30,
month_days: 31
}
);
assert_eq!(days_in_month(y, 12), 31);
assert_eq!(next_month_start(d(y, 12, 1)), None);
}
#[test]
fn the_calendar_is_four_digit_years() {
assert!(in_calendar(FIRST_DAY));
@@ -282,10 +419,6 @@ mod tests {
}
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn company(md: (u32, u32)) -> Company {
Company {
fiscal_year_start: md,
+19 -1
View File
@@ -419,10 +419,28 @@ pub enum LedgerError {
#[error("asset account links must be distinct; account id {0} is linked twice")]
DuplicateAssetAccount(u64),
/// purchase_date + useful life overflows the date range.
/// purchase_date + useful life overflows the date range: a new asset's
/// last day of depreciation is 9999-12-31 at the latest.
#[error("purchase date plus useful life overflows the calendar")]
AssetDateOverflow,
/// A depreciation run for a month that can't be run: not a month, or
/// one ending before the asset was placed in service.
#[error("invalid depreciation period: {0}")]
InvalidDepreciationPeriod(String),
/// An entry of a fixed asset's that can't be reversed (its disposal,
/// or depreciation the disposal took as final), with why.
#[error("entry {0} can't be reversed: {1}")]
AssetEntryIrreversible(u64, String),
/// A disposal refused because a depreciation run of the asset carries
/// at the end of the disposal's month other than what it carries now
/// (a run of a later month that stands; a reversal or re-instatement
/// dated after the month, not undone), with what to do.
#[error("the disposal is dated before depreciation the books carry: {0}")]
DepreciationPastDisposal(String),
/// An amortization schedule or recurring entry is invalid, unknown, or
/// already posted for that date.
#[error("invalid schedule: {0}")]
+611 -1
View File
@@ -42,7 +42,13 @@
//! zero, and mirrored by a reversal (B-14 rule 11);
//! * a party's part of an entry is signed as a line on the party's own
//! account would be, a reversal's is its original's negated, and what of
//! it counts as money or as property never exceeds it (B-35).
//! it counts as money or as property never exceeds it (B-35);
//! * a span prorated by days (depreciation, B-15; daily schedules, B-16)
//! splits into parts that sum to its total, each but the last its days
//! times the monthly rate within one unit, the last the remainder;
//! * a catch-up run brings the books to the schedule without ever carrying
//! more than it says at a month end already posted (B-15), and a
//! disposal trues what the books carry up or down to the schedule.
use crate::{AccountType, Side};
use rust_decimal::Decimal;
@@ -1022,6 +1028,380 @@ pub fn reverse_target<T: Amount>(t: PartyTarget<T>) -> Option<PartyTarget<T>> {
})
}
// ---------------------------------------------------------------------------
// Proration by days
// ---------------------------------------------------------------------------
/// Exact integer arithmetic the proration by days needs: money as a whole
/// number of its smallest step (`i128` in the engine; `i64` and, for the
/// harnesses whose properties relate a product to a quotient, `i32` under
/// Kani). Kept apart from [`Amount`]: proration multiplies and divides,
/// which no journal line ever does.
pub trait Units: Copy + Ord {
/// Zero.
const ZERO: Self;
/// One.
const ONE: Self;
/// `n`, exactly, or `None` when it doesn't fit.
fn from_u32(n: u32) -> Option<Self>;
/// `self + rhs`, or `None` on overflow.
fn checked_add(self, rhs: Self) -> Option<Self>;
/// `self - rhs`, or `None` on overflow.
fn checked_sub(self, rhs: Self) -> Option<Self>;
/// `self * rhs`, or `None` on overflow.
fn checked_mul(self, rhs: Self) -> Option<Self>;
/// `(self / rhs, self % rhs)` for `self >= 0` and `rhs > 0`; `None`
/// otherwise.
fn checked_div_rem(self, rhs: Self) -> Option<(Self, Self)>;
}
impl Units for i32 {
const ZERO: Self = 0;
const ONE: Self = 1;
fn from_u32(n: u32) -> Option<Self> {
i32::try_from(n).ok()
}
fn checked_add(self, rhs: Self) -> Option<Self> {
i32::checked_add(self, rhs)
}
fn checked_sub(self, rhs: Self) -> Option<Self> {
i32::checked_sub(self, rhs)
}
fn checked_mul(self, rhs: Self) -> Option<Self> {
i32::checked_mul(self, rhs)
}
fn checked_div_rem(self, rhs: Self) -> Option<(Self, Self)> {
if self < 0 || rhs <= 0 {
return None;
}
Some((self / rhs, self % rhs))
}
}
impl Units for i64 {
const ZERO: Self = 0;
const ONE: Self = 1;
fn from_u32(n: u32) -> Option<Self> {
Some(i64::from(n))
}
fn checked_add(self, rhs: Self) -> Option<Self> {
i64::checked_add(self, rhs)
}
fn checked_sub(self, rhs: Self) -> Option<Self> {
i64::checked_sub(self, rhs)
}
fn checked_mul(self, rhs: Self) -> Option<Self> {
i64::checked_mul(self, rhs)
}
fn checked_div_rem(self, rhs: Self) -> Option<(Self, Self)> {
if self < 0 || rhs <= 0 {
return None;
}
Some((self / rhs, self % rhs))
}
}
impl Units for i128 {
const ZERO: Self = 0;
const ONE: Self = 1;
fn from_u32(n: u32) -> Option<Self> {
Some(i128::from(n))
}
fn checked_add(self, rhs: Self) -> Option<Self> {
i128::checked_add(self, rhs)
}
fn checked_sub(self, rhs: Self) -> Option<Self> {
i128::checked_sub(self, rhs)
}
fn checked_mul(self, rhs: Self) -> Option<Self> {
i128::checked_mul(self, rhs)
}
fn checked_div_rem(self, rhs: Self) -> Option<(Self, Self)> {
if self < 0 || rhs <= 0 {
return None;
}
Some((self / rhs, self % rhs))
}
}
/// lcm(28, 29, 30, 31): every month's length divides it, so a day of any
/// month is a whole number of `MONTH_L`ths of a month, and proration needs
/// no fractions.
pub const MONTH_L: u32 = 377_580;
/// One calendar month's share of a span of days: `days` of the month's
/// `month_days`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MonthPart {
/// Days of the span in the month, 1 to `month_days`.
pub days: u32,
/// Days in the month, 28 to 31.
pub month_days: u32,
}
impl MonthPart {
/// Whether it is a share a calendar month can have: 28 to 31 days in
/// the month, 1 to all of them in the span.
pub fn is_valid(self) -> bool {
(28..=31).contains(&self.month_days) && (1..=self.month_days).contains(&self.days)
}
}
/// A part's weight in `MONTH_L`ths of a month: `days × (MONTH_L /
/// month_days)`, exactly. `None` for a part no month has.
pub fn part_weight<T: Units>(p: MonthPart) -> Option<T> {
if !p.is_valid() {
return None;
}
T::from_u32(p.days)?.checked_mul(T::from_u32(MONTH_L / p.month_days)?)
}
/// `num / den` rounded half away from zero to a multiple of `unit` (`num`
/// not negative; `den` and `unit` positive).
fn round_to_unit<T: Units>(num: T, den: T, unit: T) -> Option<T> {
let step = den.checked_mul(unit)?;
let (q, r) = num.checked_div_rem(step)?;
// Up when the remainder is half a step or more: compared as r >=
// step - r, so nothing is doubled and nothing can overflow.
let q = if r >= step.checked_sub(r)? {
q.checked_add(T::ONE)?
} else {
q
};
q.checked_mul(unit)
}
/// The exact cumulative charge of `total` (in units) spread over `months`
/// months by day, through `k` days of `part`, after earlier parts weighing
/// `before` `MONTH_L`ths; `last` marks the span's last part. Rounded half
/// away from zero (half up: nothing here is negative) to a multiple of
/// `unit`, or `total` itself when that rounds above it (a total that isn't
/// a whole number of units); and the last part's last day is `total`.
///
/// ```text
/// GOAL: how much of a total a span of days has used up by a given day,
/// each day of a month bearing the same share, the whole total
/// reached on the span's last day and never before.
/// SOURCE: docs/spec/rules.md B-15 (straight line, partial months by days;
/// ASC 360-10-35-4: "systematic and rational", as equitably as
/// possible to the periods served), B-16 (daily schedules, D-11),
/// B-21 (exact totals).
/// INVARIANTS (W = months × MONTH_L, m = part.month_days, d = part.days):
/// 1. Not the last part: round(total × (before + k × MONTH_L / m) / W):
/// the monthly rate total/months times the days held over the days
/// in each month.
/// 2. The last part: round(total × (before × d + (W − before) × k) /
/// (W × d)): what the earlier parts leave of the total, C = total ×
/// before / W, plus the remainder R = total − C spread evenly over
/// its d days. It is never "plugged": through an earlier day of the
/// last month it is prorated by days.
/// 3. It is non-decreasing in (part, k), never above total, continuous
/// at part boundaries ((i, days_i) equals (i + 1, 0)), and exactly
/// total at (last, d).
/// SCENARIOS:
/// 1. IF total is negative, months is 0, unit isn't positive, the part
/// is no month's (see MonthPart::is_valid), k > d, before is negative
/// or above W, a part but the last is charged through a day past W
/// (before + k × MONTH_L / m > W; prorate_by_days charges every part
/// but the last through its last day, so it refuses a part ending past
/// W), or any product overflows:
/// - SHOULD return None
/// - NEVER panic
/// BECAUSE a figure the books can't compute is refused (rules.md
/// B-28), and symbolic parts are not a calendar.
/// 2. IF 12,000.00 over 12 months from Jan 1, through Dec 15 (the last
/// part, 31 days, before = 11 months):
/// - SHOULD be 11,000.00 + 1,000.00 × 15/31 = 11,483.87
/// - NEVER 12,000.00 (the old plug)
/// BECAUSE the final month is charged by its days like any other.
/// ```
pub fn prorated_through<T: Units>(
total: T,
months: u32,
before: T,
part: MonthPart,
k: u32,
last: bool,
unit: T,
) -> Option<T> {
if total < T::ZERO
|| months == 0
|| unit <= T::ZERO
|| !part.is_valid()
|| k > part.days
|| before < T::ZERO
{
return None;
}
let whole = T::from_u32(months)?.checked_mul(T::from_u32(MONTH_L)?)?;
if before > whole {
return None;
}
let charged = if last {
if k == part.days {
// Exactly the total, even one that isn't a whole number of
// units (an asset an earlier version took in tenths of a cent).
return Some(total);
}
let d = T::from_u32(part.days)?;
let left = whole.checked_sub(before)?;
let elapsed = before
.checked_mul(d)?
.checked_add(left.checked_mul(T::from_u32(k)?)?)?;
round_to_unit(total.checked_mul(elapsed)?, whole.checked_mul(d)?, unit)?
} else {
let rate = T::from_u32(MONTH_L / part.month_days)?;
let elapsed = before.checked_add(T::from_u32(k)?.checked_mul(rate)?)?;
if elapsed > whole {
return None;
}
round_to_unit(total.checked_mul(elapsed)?, whole, unit)?
};
// A total that isn't a whole number of units can round above itself
// on the day before the last; the cap keeps every part non-negative.
Some(charged.min(total))
}
/// Split `total` (in units) over a span of `months` months cut into
/// calendar-month `parts`: `out[i]` is the cumulative charge through the
/// end of part `i` ([`prorated_through`]) less the one before it, and the
/// last part is `total` less all the others. `out` must be as long as
/// `parts`; on `None` its contents mean nothing.
///
/// ```text
/// GOAL: one month's posting of a span spread by day (depreciation,
/// daily prepaid and deferred-revenue schedules).
/// SOURCE: docs/spec/rules.md B-15, B-16 (D-11), B-21; plan/served.md P3
/// defect 9 (one shared prorate_by_days, Kani: parts sum to the
/// total, each is days × rate within one unit, the last absorbs
/// the remainder).
/// INVARIANTS:
/// 1. The parts sum to total exactly.
/// 2. Every part is >= 0.
/// 3. Each part but the last is within one unit of total × days_i /
/// (months × month_days_i).
/// 4. The last part is total − Σ others.
/// SCENARIOS:
/// 1. IF there are no parts, out isn't as long as parts, or prorated_
/// through refuses a part (the weights of all but the last above W,
/// among others): SHOULD return None, never panic.
/// 2. IF 1,200.00 over 12 months from Mar 15 (parts 17/31, eleven full
/// months, then 14 days):
/// - SHOULD be 54.84, eleven × 100.00, then 45.16
/// BECAUSE each day bears the same share, and the last part takes
/// what the others leave.
/// ```
///
/// Allocation-free and O(n): a loop over fixed slices, so the model
/// checker can prove it outright.
pub fn prorate_by_days<T: Units>(
total: T,
months: u32,
parts: &[MonthPart],
unit: T,
out: &mut [T],
) -> Option<()> {
if out.len() != parts.len() {
return None;
}
let (&last, rest) = parts.split_last()?;
let mut before = T::ZERO;
let mut charged = T::ZERO;
for (o, &p) in out.iter_mut().zip(rest) {
let through = prorated_through(total, months, before, p, p.days, false, unit)?;
*o = through.checked_sub(charged)?;
charged = through;
before = before.checked_add(part_weight(p)?)?;
}
// The last part is checked too (its weight before it within the span),
// and its whole is the total, so it takes the remainder.
let through = prorated_through(total, months, before, last, last.days, true, unit)?;
*out.last_mut()? = through.checked_sub(charged)?;
Some(())
}
// ---------------------------------------------------------------------------
// Catching up and truing up
// ---------------------------------------------------------------------------
/// What a run catches up: the largest amount, at least zero, that can be
/// added to what the books carry at every month end from the run's on
/// without carrying more than the schedule says there:
/// `max(0, min_i(scheduled[i] − posted[i]))`, where `scheduled[i]` and
/// `posted[i]` are cumulative at successive month ends starting with the
/// run's own. `None` when the slices differ in length or are empty, or on
/// overflow.
///
/// ```text
/// GOAL: a skipped month is caught up in the month the run is made, and a
/// run never overtakes the schedule at a month end already posted.
/// 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);
/// docs/spec/rules.md B-15.
/// INVARIANTS:
/// 1. The result is >= 0.
/// 2. Whenever every posted[i] <= scheduled[i], posted[i] + result <=
/// scheduled[i] for every i: the run never carries the books past the
/// schedule at any month end it reaches.
/// 3. The result is 0 or some scheduled[i] − posted[i]: with one month
/// (no run after this one), it is everything the schedule says is due
/// through it.
/// ```
pub fn catch_up<T: Amount>(scheduled: &[T], posted: &[T]) -> Option<T> {
if scheduled.len() != posted.len() {
return None;
}
let mut least: Option<T> = None;
for (&s, &p) in scheduled.iter().zip(posted) {
let gap = s.checked_sub(p)?;
least = Some(match least {
Some(l) if l <= gap => l,
_ => gap,
});
}
let least = least?;
Some(if least > T::ZERO { least } else { T::ZERO })
}
/// [`catch_up`] of every suffix at once: `out[i]` is `catch_up(&scheduled[i..],
/// &posted[i..])`, what a run at each month end posts. One pass from the
/// end, O(n), so a viewer can say of every month of a long life whether
/// anything is due for it. `None` when the slices differ in length, or on
/// overflow.
pub fn catch_ups<T: Amount>(scheduled: &[T], posted: &[T], out: &mut [T]) -> Option<()> {
if scheduled.len() != posted.len() || out.len() != scheduled.len() {
return None;
}
let mut least: Option<T> = None;
for ((&s, &p), o) in scheduled.iter().zip(posted).zip(out.iter_mut()).rev() {
let gap = s.checked_sub(p)?;
let l = match least {
Some(l) if l <= gap => l,
_ => gap,
};
least = Some(l);
*o = if l > T::ZERO { l } else { T::ZERO };
}
Some(())
}
/// Bring what the books carry (`posted`) to `target`: `(post, take_back)`,
/// both >= 0, one of them zero, with `posted + post − take_back = target`.
/// This is [`columns`] of `target − posted`: raising the books by `post`
/// and lowering them by `take_back` are a debit and a credit of the gap.
///
/// ```text
/// GOAL: a disposal clears exactly what the schedule says was used up
/// through its day, whatever the runs posted.
/// SOURCE: docs/spec/rules.md B-15 (a disposed asset depreciates through
/// its disposal day); Ledger::dispose_asset INVARIANT 1 (gain =
/// proceeds − book value at the disposal date).
/// ```
pub fn true_up<T: Amount>(target: T, posted: T) -> Option<(T, T)> {
columns(target.checked_sub(posted)?)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1140,6 +1520,236 @@ mod tests {
assert_eq!(Received.mirror(), Paid);
}
fn part(days: u32, month_days: u32) -> MonthPart {
MonthPart { days, month_days }
}
/// Every month length divides MONTH_L, and it is the least such.
#[test]
fn month_l_is_the_lcm_of_month_lengths() {
for m in 28..=31 {
assert_eq!(MONTH_L % m, 0, "{m}");
}
for smaller in 1..MONTH_L {
assert!((28..=31).any(|m| smaller % m != 0), "{smaller}");
}
assert_eq!(part_weight::<i64>(part(31, 31)), Some(i64::from(MONTH_L)));
assert_eq!(part_weight::<i64>(part(1, 28)), Some(13_485));
assert_eq!(part_weight::<i64>(part(0, 30)), None);
assert_eq!(part_weight::<i64>(part(32, 31)), None);
assert_eq!(part_weight::<i64>(part(27, 27)), None);
}
/// Spec scenario 2 of prorated_through, and the brief's 1,200.00 from
/// March 15 (prorate_by_days scenario 2), in cents.
#[test]
fn the_final_month_is_prorated_and_a_mid_month_span_splits_by_day() {
let l = i128::from(MONTH_L);
let dec15 = prorated_through(1_200_000i128, 12, 11 * l, part(31, 31), 15, true, 1);
assert_eq!(dec15, Some(1_148_387));
assert_eq!(
prorated_through(1_200_000i128, 12, 11 * l, part(31, 31), 31, true, 1),
Some(1_200_000)
);
let mut parts = vec![part(17, 31)];
parts.extend([30, 31, 30, 31, 31, 30, 31, 30, 31, 31, 28].map(|m| part(m, m)));
parts.push(part(14, 31));
let mut out = vec![0i128; parts.len()];
prorate_by_days(120_000i128, 12, &parts, 1, &mut out).unwrap();
let mut want = vec![5_484i128];
want.extend([10_000; 11]);
want.push(4_516);
assert_eq!(out, want);
}
/// Half a unit rounds away from zero (up, for the non-negative amounts
/// here): 0.01 over 2 months from the 1st gives 0.005 after one month.
#[test]
fn half_a_unit_rounds_up() {
let mut out = [0i64; 2];
prorate_by_days(1, 2, &[part(31, 31), part(28, 28)], 1, &mut out).unwrap();
assert_eq!(out, [1, 0]);
// Just under half a unit stays down: 0.01 over 3 months.
let mut out = [0i64; 3];
prorate_by_days(
1,
3,
&[part(31, 31), part(28, 28), part(31, 31)],
1,
&mut out,
)
.unwrap();
assert_eq!(out, [0, 1, 0]);
}
/// R4(a): a total that isn't a whole number of units (100.006 in
/// thousandths, rounded to cents) never makes the last part negative.
#[test]
fn a_total_finer_than_its_unit_never_gives_a_negative_part() {
let l = i64::from(MONTH_L);
let mut out = [0i64; 2];
prorate_by_days(100_006, 2, &[part(31, 31), part(28, 28)], 10, &mut out).unwrap();
assert_eq!(out, [50_000, 50_006]);
// 30 of 31 days of a one-month span: 100.006 × 30/31 = 96.780
// exactly, already whole cents.
assert_eq!(
prorated_through(100_006i64, 1, 0, part(31, 31), 30, true, 10),
Some(96_780)
);
// One day short of a 4,000-month span: 100.006 less 0.0008 is
// 100.0052, which rounds to 100.01, past the total: capped there,
// so the day left gets 0, never -0.004.
let w = 4_000 * l;
assert_eq!(
prorated_through(100_006i64, 4_000, w - l, part(30, 31), 30, false, 10),
Some(100_006)
);
}
/// Spec scenario 1 of prorated_through (R4(b), R4(c)): malformed input
/// is refused, never a panic.
#[test]
fn malformed_proration_is_refused() {
let l = i64::from(MONTH_L);
let p = part(10, 30);
assert_eq!(prorated_through(-1i64, 1, 0, p, 1, false, 1), None);
assert_eq!(prorated_through(1i64, 0, 0, p, 1, false, 1), None);
assert_eq!(prorated_through(1i64, 1, 0, p, 1, false, 0), None);
assert_eq!(prorated_through(1i64, 1, 0, p, 11, false, 1), None);
assert_eq!(prorated_through(1i64, 1, -1, p, 1, false, 1), None);
assert_eq!(prorated_through(1i64, 1, l + 1, p, 1, true, 1), None);
assert_eq!(prorated_through(1i64, 1, 0, part(0, 30), 0, false, 1), None);
assert_eq!(
prorated_through(1i64, 1, 0, part(31, 30), 1, false, 1),
None
);
// A part but the last that ends past the span.
assert_eq!(prorated_through(1i64, 1, l, part(1, 31), 1, false, 1), None);
let mut out = [0i64; 3];
assert_eq!(
prorate_by_days(
100,
1,
&[part(31, 31), part(31, 31), part(5, 30)],
1,
&mut out
),
None
);
assert_eq!(prorate_by_days(100i64, 1, &[], 1, &mut []), None);
assert_eq!(
prorate_by_days(100i64, 1, &[part(31, 31)], 1, &mut []),
None
);
assert_eq!(
prorated_through(i64::MAX, 600, 0, part(31, 31), 31, false, 1),
None
);
}
/// prorated_through over every span of 1 to 3 months starting on any
/// day of a common year and a leap year, walked day by day, for a
/// spread of totals and both units: it never falls (within a month and
/// across a month's end: the charge through a month's last day is the
/// next month's through none of its days), never exceeds the total, and
/// is the total on the last day. Exhaustive over those spans; the
/// relation between two divisions is too costly for Kani's solver
/// (docs/verification.md), and Lean proves the law for the model.
/// Not under Miri, which looks for undefined behavior: this is safe
/// integer arithmetic the other kernel tests already take through it,
/// and walked exhaustively under the interpreter it held `just miri`
/// for over ten minutes.
#[test]
#[cfg_attr(
miri,
ignore = "exhaustive arithmetic: the other kernel tests cover it under Miri"
)]
fn the_charge_never_falls_and_is_continuous_over_real_calendars() {
use chrono::NaiveDate;
let mut start = NaiveDate::from_ymd_opt(2027, 1, 1).unwrap();
let stop = NaiveDate::from_ymd_opt(2029, 1, 1).unwrap();
let mut spans = 0;
while start < stop {
for months in 1..=3u32 {
let end = start
.checked_add_months(chrono::Months::new(months))
.unwrap();
let parts: Vec<_> = crate::calendar::month_parts(start, end).collect();
for total in [0i64, 1, 3, 7, 10, 999, 100_005, 65_536] {
for unit in [1i64, 10] {
let mut before = 0i64;
let mut last_charge = 0i64;
for p in &parts {
for k in 0..=p.part.days {
let c = prorated_through(
total, months, before, p.part, k, p.last, unit,
)
.unwrap();
assert!(
c >= last_charge && c <= total,
"{start} + {months}: {c} after {last_charge}"
);
last_charge = c;
}
before += part_weight::<i64>(p.part).unwrap();
}
assert_eq!(last_charge, total, "{start} + {months}");
}
}
spans += 1;
}
start = start.succ_opt().unwrap();
}
assert_eq!(spans, 731 * 3);
}
/// catch_up: everything due through the run's month when no run comes
/// after it; never past a later month end already posted.
#[test]
fn a_catch_up_never_overtakes_a_posted_month_end() {
// Jan run, Feb skipped, a Feb run with nothing after it: Feb's.
assert_eq!(catch_up(&[200i64], &[100]), Some(100));
// March already caught February up: a Feb run posts nothing.
assert_eq!(catch_up(&[200i64, 300], &[100, 300]), Some(0));
// A run carried past the schedule (an old tie cent): nothing.
assert_eq!(catch_up(&[200i64], &[201]), Some(0));
// January reversed after March ran: January posts it back.
assert_eq!(catch_up(&[100i64, 200, 300], &[0, 100, 200]), Some(100));
assert_eq!(catch_up::<i64>(&[], &[]), None);
assert_eq!(catch_up(&[1i64], &[1, 2]), None);
}
/// catch_ups is catch_up of every suffix: positive and distinct ones
/// (a catch_ups that wrote zeros would fail), and a month carried past
/// the schedule (zero from it back).
#[test]
fn catch_ups_is_every_suffixs_catch_up() {
let (s, p) = ([100i64, 200, 300, 400], [0i64, 100, 150, 250]);
let mut out = [0i64; 4];
catch_ups(&s, &p, &mut out).unwrap();
for i in 0..4 {
assert_eq!(Some(out[i]), catch_up(&s[i..], &p[i..]), "{i}");
}
assert_eq!(out, [100, 100, 150, 150]);
let (s, p) = ([100i64, 200, 300, 400], [0i64, 300, 200, 400]);
catch_ups(&s, &p, &mut out).unwrap();
for i in 0..4 {
assert_eq!(Some(out[i]), catch_up(&s[i..], &p[i..]), "{i}");
}
assert_eq!(out, [0, 0, 0, 0]);
let mut none: [i64; 0] = [];
assert_eq!(catch_ups::<i64>(&[], &[], &mut none), Some(()));
assert_eq!(catch_ups(&s, &p, &mut [0i64; 3]), None);
}
/// true_up is the gap's columns.
#[test]
fn a_true_up_reaches_the_target() {
assert_eq!(true_up(5_500i64, 5_000), Some((500, 0)));
assert_eq!(true_up(5_500i64, 6_000), Some((0, 500)));
assert_eq!(true_up(5_500i64, 5_500), Some((0, 0)));
}
#[test]
fn max_line_amount_is_one_quadrillion() {
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
+186 -18
View File
@@ -345,8 +345,9 @@ impl Ledger {
}
/// [`Self::register_schedule`] without the total's range and minor-unit
/// checks added on 2026-10-13, for replaying journals written before
/// them. Never for new work.
/// checks added on 2026-10-13, or the calendar bound and the
/// equal-months start on the 1st (D-37) added since, for replaying
/// journals written before them. Never for new work.
pub fn replay_register_schedule(
&mut self,
new: NewSchedule,
@@ -366,11 +367,73 @@ impl Ledger {
balance_account: new.balance_account,
pl_account: new.pl_account,
posted: Default::default(),
convention: new.convention,
postings: Default::default(),
},
);
Ok(id)
}
/// Whether a schedule's month was ever posted (and so, if it isn't
/// posted now, a reversal un-posted it): posting it again needs a
/// journal an older version refuses (store feature `month_reposting`).
pub fn schedule_month_posted_before(&self, id: ScheduleId, month_end: NaiveDate) -> bool {
self.schedules
.get(&id)
.and_then(|s| s.postings.get(&month_end))
.is_some_and(|p| !p.is_empty())
}
/// The latest date after a schedule month's end that one of its
/// postings was reversed (or re-instated) on, if any: posting the month
/// again on its own date would have the books carry it twice from then
/// until that date, so it is left for a human
/// ([`Self::preview_schedule_month`], [`Self::schedule_month_hold`]).
///
/// ```text
/// GOAL: a schedule month is never recognized twice at a month end.
/// SOURCE: docs/spec/rules.md B-9 (a correction is dated when it is
/// made), B-16; P3 depreciation review R3 (a reversed month is
/// due again: when the reversal is the month's own).
/// ```
pub fn schedule_month_reversed_after(
&self,
id: ScheduleId,
month_end: NaiveDate,
) -> Option<NaiveDate> {
self.schedules
.get(&id)?
.postings
.get(&month_end)?
.iter()
.flat_map(|&e| {
self.reversal_chain(e)
.into_iter()
.skip_while(move |&c| c != e)
.skip(1)
})
.filter_map(|c| self.entries.get(&c).map(JournalEntry::date))
.filter(|d| *d > month_end)
.max()
}
/// Why a run of schedules leaves a due month for a human, if it must:
/// it is dated in a closed period (which takes no new entries), or its
/// posting was reversed after the month ended
/// ([`Self::schedule_month_reversed_after`]).
pub fn schedule_month_hold(&self, id: ScheduleId, month_end: NaiveDate) -> Option<String> {
if let Some(p) = self.closed_period_containing(month_end) {
return Some(format!(
"{month_end} is in closed period {p}: book it in an open one by hand, or not at all"
));
}
self.schedule_month_reversed_after(id, month_end).map(|r| {
format!(
"its posting was reversed on {r}, after the month ended: posting it again on {month_end} would recognize the month twice until then; book it by hand in an open period, or not at all"
)
})
}
/// The id the next schedule will receive (for journaling stores).
pub fn next_schedule_id(&self) -> ScheduleId {
self.next_schedule_id
@@ -409,6 +472,11 @@ impl Ledger {
"schedule {id} already posted {date}"
)));
}
if let Some(r) = self.schedule_month_reversed_after(id, date) {
return Err(LedgerError::InvalidSchedule(format!(
"schedule {id}'s {date} posting was reversed on {r}, after its month ended: posting it again on {date} would recognize the month twice until then (book it by hand in an open period, or not at all)"
)));
}
let money = Money::new(amount, s.total.currency);
let (debit, credit) = s.accounts();
let what = match s.kind {
@@ -448,11 +516,9 @@ impl Ledger {
self.add_line(entry, account, side, money)?;
}
self.post_entry(entry)?;
self.schedules
.get_mut(&id)
.expect("previewed")
.posted
.insert(date);
let s = self.schedules.get_mut(&id).expect("previewed");
s.posted.insert(date);
s.postings.entry(date).or_default().push(entry);
Ok(entry)
}
@@ -1615,6 +1681,14 @@ impl Ledger {
/// 4. IF `date` falls in a closed period:
/// - SHOULD refuse with `ClosedPeriod`
/// BECAUSE closed periods take no new postings.
/// 5. IF the entry reverses a schedule month's posting (so reversing
/// it re-instates that posting) and the month has been posted
/// again since:
/// - SHOULD refuse (InvalidSchedule), naming the entry that posts
/// it now
/// - NEVER recognize one month of a schedule twice
/// BECAUSE a reversed month is due again and may have been run
/// again (P3 depreciation review R3).
/// ```
pub fn check_reversal(&self, entry: EntryId, date: NaiveDate) -> Result<(), LedgerError> {
let original = self
@@ -1636,9 +1710,74 @@ impl Ledger {
if let Some(pid) = self.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
}
// SCENARIO 5. `entry` ends its chain (it isn't reversed), so
// reversing it flips whether the chain's original stands.
if let Some(top) = self.schedule_chain_top(entry)
&& !self.in_effect(top)
&& let Some((s, month)) = self.schedule_month_of(top)
&& let Some(now) = s.postings[&month]
.iter()
.find(|&&e| e != top && self.in_effect(e))
{
return Err(LedgerError::InvalidSchedule(format!(
"reversing entry {entry} would post schedule {}'s {month} a second time: entry {now} posts it now (reverse that one instead)",
s.id
)));
}
Ok(())
}
/// The original an entry's reversal chain starts from (the entry itself
/// when it reverses nothing), when some schedule month was ever posted:
/// books without one skip the chain walk.
fn schedule_chain_top(&self, entry: EntryId) -> Option<EntryId> {
if self.schedules.values().all(|s| s.postings.is_empty()) {
return None;
}
Some(self.reversal_chain(entry).first().copied().unwrap_or(entry))
}
/// Whether a posted entry's effect stands: nothing reverses it, or what
/// reverses it is itself reversed, and so on (an even number of
/// reversals after it in its chain). A draft or an unknown id stands
/// for nothing.
///
/// ```text
/// SOURCE: docs/spec/rules.md B-9 (a mistake is corrected by an
/// opposite entry), B-10 (reversed at most once; reversing the
/// reversal re-instates it).
/// ```
pub fn in_effect(&self, entry: EntryId) -> bool {
if self
.entries
.get(&entry)
.is_none_or(|e| e.status() != EntryStatus::Posted)
{
return false;
}
let chain = self.reversal_chain(entry);
chain
.iter()
.position(|&e| e == entry)
.is_some_and(|i| (chain.len() - 1 - i).is_multiple_of(2))
}
/// The schedule and month an entry posted, if it posted one (a
/// schedule month's posting; not its reversal).
pub fn schedule_posting(&self, entry: EntryId) -> Option<(ScheduleId, NaiveDate)> {
self.schedule_month_of(entry).map(|(s, m)| (s.id, m))
}
/// The schedule and month an entry posted, if it posted one.
fn schedule_month_of(&self, entry: EntryId) -> Option<(&AmortizationSchedule, NaiveDate)> {
self.schedules.values().find_map(|s| {
s.postings
.iter()
.find(|(_, es)| es.contains(&entry))
.map(|(m, _)| (s, *m))
})
}
/// Post a reversing entry: same lines with sides flipped, linked back to
/// the original via `reversal_of`. The books never forget; they correct.
/// Refused (see [`Self::check_reversal`]) before anything changes.
@@ -1695,6 +1834,21 @@ impl Ledger {
if let Some(why) = self.entry_no_party.get(&entry).cloned() {
self.entry_no_party.insert(id, why);
}
// A reversed schedule month is un-posted, so it is due again; a
// reversed reversal posts it back (P3 depreciation review R3).
if let Some(top) = self.schedule_chain_top(entry)
&& let Some((sid, month)) = self.schedule_month_of(top).map(|(s, m)| (s.id, m))
{
let posted = self.schedules[&sid].postings[&month]
.iter()
.any(|&e| self.in_effect(e));
let s = self.schedules.get_mut(&sid).expect("found");
if posted {
s.posted.insert(month);
} else {
s.posted.remove(&month);
}
}
Ok(id)
}
@@ -1709,19 +1863,23 @@ impl Ledger {
/// Pure pre-validation for [`Self::register_asset`]: every check that
/// does not mutate, so journaling stores can validate before journaling.
/// [`Self::validate_new_asset_journaled`], and both ends of the life
/// inside the calendar the books keep (crate::calendar): a life that
/// runs past 9999-12-31 is refused like one past chrono's last day (P0
/// review round 3, S1).
/// [`Self::validate_new_asset_journaled`]; the in-service date and the
/// life's last day of depreciation inside the calendar the books keep
/// (crate::calendar): a life whose last day is after 9999-12-31 is
/// refused like one past chrono's last day (P0 review round 3, S1; P3:
/// the bound is the last day held, so a life ending on 9999-12-31 is
/// taken); and the cost and salvage in the currency's minor unit
/// (rules.md B-3: every depreciation and disposal line derives from
/// them, and a line finer than a cent could never post).
pub fn validate_new_asset(&self, new: &NewAsset) -> Result<(), LedgerError> {
self.check_new_asset(new, true)
}
/// The checks every journaled `asset.registered` and `asset.corrected`
/// passed when it was written (the rule through 2026-10-16):
/// [`Self::validate_new_asset`] without the calendar bound, the end of
/// the life only representable. Replay applies exactly these
/// (rules.md B-28).
/// [`Self::validate_new_asset`] without the calendar bound or the minor
/// unit, the end of the life only representable. Replay applies
/// exactly these (rules.md B-28).
pub fn validate_new_asset_journaled(&self, new: &NewAsset) -> Result<(), LedgerError> {
self.check_new_asset(new, false)
}
@@ -1758,17 +1916,25 @@ impl Ledger {
if new.useful_life_months < 1 {
return Err(LedgerError::InvalidUsefulLife(new.useful_life_months));
}
if strict {
check_minor_units(new.cost)?;
check_minor_units(new.salvage_value)?;
}
// The schedule walks every month of the asset's life; a purchase
// date near the end of the calendar would overflow date arithmetic
// (and hang or panic the schedule). Reject it up front. New ones
// keep both ends inside the calendar the books keep.
// keep the in-service date and the life's last day (the day before
// its exclusive end) inside the calendar the books keep, so every
// month of the life is a month the books can post.
if strict {
crate::calendar::check(new.purchase_date)?;
}
let end = new
.purchase_date
.checked_add_months(Months::new(new.useful_life_months));
if !end.is_some_and(|end| !strict || crate::calendar::in_calendar(end)) {
if !end
.is_some_and(|end| !strict || end.pred_opt().is_some_and(crate::calendar::in_calendar))
{
return Err(LedgerError::AssetDateOverflow);
}
assets::validate_asset_accounts(
@@ -2273,8 +2439,10 @@ impl Ledger {
});
}
let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?;
// Pure math validates activeness, proceeds sign, and date order.
crate::disposal_amounts(asset, date, proceeds)?;
// Activeness, proceeds sign, and date order; no depreciation is
// computed, so a journaled disposal replays even where the figure
// can't be (rules.md B-28).
assets::check_disposal(asset, date, proceeds)?;
if strict {
// Proceeds are a line of the disposal entry, and its date one
// the books keep (P0 review round 3, S1).
+4 -3
View File
@@ -52,8 +52,9 @@ pub use arap::{
};
pub use assets::{
Asset, AssetCorrection, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset,
ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation, book_value,
depreciation_schedule, disposal_amounts,
PostedRun, RunAmount, ScheduleLine, TaxBasis, TaxBasisRule, accumulated_depreciation,
book_value, catch_up_room, check_disposal, depreciation_effects, depreciation_schedule,
disposal_amounts, life_end, run_amount, run_amounts,
};
pub use cashflow::{
CashFlowFooting, CashFlowKind, CashFlowLine, CashFlowParams, CashFlowSection,
@@ -91,7 +92,7 @@ pub use reports::{
};
pub use schedules::{
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
ScheduleId, ScheduleKind, TemplateLine,
ScheduleConvention, ScheduleId, ScheduleKind, TemplateLine,
};
pub use shares::{PartyMember, PartyShare, Share};
pub use tax::{
+91
View File
@@ -148,6 +148,70 @@ impl fmt::Debug for Money {
}
}
/// A non-negative amount as a whole number of steps, for exact proration
/// by days (kernel::prorate_by_days): `units` steps of 10^-`scale` each,
/// where `scale` is the amount's own places or its currency's minor unit,
/// whichever is more, and `unit` is one minor unit in steps.
///
/// ```text
/// GOAL: depreciation and daily schedules round to the currency's own
/// smallest unit (cents, whole yen, thousandths of a dinar), never a
/// fixed two places.
/// SOURCE: docs/spec/rules.md B-3 (no fraction of the minor unit; ISO 4217,
/// sources/iso-4217.md).
/// INVARIANTS:
/// 1. amount = units × 10^-scale exactly; unit = 10^(scale − minor).
/// 2. A currency with no minor unit (gold, SDRs) counts as two places.
/// 3. NEVER rounds: an amount that doesn't fit is None.
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Steps {
/// The amount, in steps.
pub units: i128,
/// One minor unit, in steps.
pub unit: i128,
/// Decimal places of one step.
pub scale: u32,
/// The currency's minor unit (two when it has none).
pub minor: u32,
}
impl Steps {
/// `amount` (not negative) in `currency`'s steps.
pub fn of(amount: Decimal, currency: Currency) -> Option<Steps> {
if amount.is_sign_negative() && !amount.is_zero() {
return None;
}
let minor = currency.minor_units().unwrap_or(2);
let n = amount.normalize();
let scale = n.scale().max(minor);
let units = n
.mantissa()
.checked_mul(10i128.checked_pow(scale - n.scale())?)?;
Some(Steps {
units,
unit: 10i128.checked_pow(scale - minor)?,
scale,
minor,
})
}
/// `n` steps as a decimal, with the currency's minor places at least
/// (12,000 dollars prints 12000.00, 8,333 yen 8333). `None` past
/// Decimal's range.
pub fn decimal(&self, n: i128) -> Option<Decimal> {
let d = Decimal::try_from_i128_with_scale(n, self.scale).ok()?;
if self.scale == self.minor {
return Some(d);
}
let mut d = d.normalize();
if d.scale() < self.minor {
d.rescale(self.minor);
}
Some(d)
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.amount, self.currency)
@@ -214,6 +278,33 @@ mod tests {
assert!(!usd(d(-1)).is_zero());
}
/// Steps INVARIANTS 1-3: cents for dollars, whole yen, thousandths of
/// a dinar, two places for gold; an amount finer than its currency
/// keeps its own places, with the unit a minor unit.
#[test]
fn steps_are_the_currencys_minor_unit() {
let s =
|a: &str, c: &str| Steps::of(a.parse().unwrap(), Currency::new(c).unwrap()).unwrap();
let usd = s("12000", "USD");
assert_eq!((usd.units, usd.unit, usd.scale), (1_200_000, 1, 2));
assert_eq!(usd.decimal(1_148_387).unwrap().to_string(), "11483.87");
assert_eq!(usd.decimal(1_200_000).unwrap().to_string(), "12000.00");
let jpy = s("100000", "JPY");
assert_eq!((jpy.units, jpy.unit, jpy.scale), (100_000, 1, 0));
assert_eq!(jpy.decimal(8_333).unwrap().to_string(), "8333");
let kwd = s("1000.000", "KWD");
assert_eq!((kwd.units, kwd.unit, kwd.scale), (1_000_000, 1, 3));
let gold = s("5", "XAU");
assert_eq!((gold.units, gold.unit, gold.scale), (500, 1, 2));
let fine = s("100.006", "USD");
assert_eq!((fine.units, fine.unit, fine.scale), (100_006, 10, 3));
assert_eq!(fine.decimal(100_010).unwrap().to_string(), "100.01");
assert_eq!(fine.decimal(100_006).unwrap().to_string(), "100.006");
assert_eq!(fine.decimal(0).unwrap().to_string(), "0.00");
assert_eq!(Steps::of(d(-1), Currency::USD), None);
assert_eq!(usd.decimal(i128::MAX), None);
}
#[test]
fn checked_arithmetic_rejects_currency_mismatch() {
let eur = Money::new(d(100), Currency::EUR);
+328 -7
View File
@@ -4,17 +4,22 @@
//! harness: CBMC explores *every* input within the stated bounds, not a
//! sample, and fails if any one of them breaks an assertion, panics, or
//! overflows. The code under proof is the engine's own [`crate::kernel`],
//! instantiated at `i64` (exact integer money, in the smallest unit); the
//! engine runs the same functions on `Decimal`, and the `kernel_bridge`
//! property test checks the two instantiations agree.
//! instantiated at `i64` (exact integer money, in the smallest unit; the
//! four charge harnesses of the proration by days at `i32`); the engine
//! runs the same functions on `Decimal` (proration on `i128`), and the
//! `kernel_bridge` property tests check the instantiations agree.
//!
//! Bounds: entries of up to [`N`] lines over up to [`K`] accounts, and
//! amounts up to 2^40 in magnitude; the statement of cash flows' entry
//! harnesses, up to [`NF`] lines, each in any of the four places. The Lean
//! model in `verify/lean` proves the same laws for any number of lines and
//! accounts.
//! harnesses, up to [`NF`] lines, each in any of the four places;
//! proration over up to [`PR`] month parts, 1 to 3 months, totals up to
//! [`PR_TOTAL`] units (the charges: 1 or 2 months, totals to 2,048 units,
//! 64 in a span's last month). The Lean model in `verify/lean` proves the
//! same laws for any number of lines and accounts, and the proration's
//! for any number of parts and any sizes (docs/verification.md says
//! which, and how it ties to the kernel's formula).
use crate::kernel::{self, Amount, LineError};
use crate::kernel::{self, Amount, LineError, MONTH_L, MonthPart};
use crate::{AccountType, Side};
/// Most lines per entry the harnesses explore.
@@ -1354,6 +1359,322 @@ fn reverse_target_is_an_involution() {
kani::cover!(t.amount == i64::MIN, "the range's edge");
}
// ---------------------------------------------------------------------------
// Proration by days (depreciation, B-15; daily schedules, B-16)
// ---------------------------------------------------------------------------
/// Most month parts the proration harnesses explore.
const PR: usize = 3;
/// Any share a calendar month can have: 28 to 31 days in the month, 1 to
/// all of them.
fn any_part() -> MonthPart {
let month_days: u32 = kani::any();
kani::assume((28..=31).contains(&month_days));
let days: u32 = kani::any();
kani::assume(days >= 1 && days <= month_days);
MonthPart { days, month_days }
}
/// The largest total the proration harnesses explore, in units: 2^16
/// (655.36 in cents). The kernel divides a symbolic 64-bit product by a
/// symbolic divisor, which the SAT solver finds costly; narrowed from 2^20
/// first, as the P3 brief says, before anything else (docs/verification.md
/// says what no bound made tractable, and what proves it instead).
const PR_TOTAL: i64 = 1 << 16;
/// A total up to `PR_TOTAL` units, 1 to 3 months, 1 to `PR` parts, a unit
/// of 1 or 10 (cents, or cents of an amount in tenths of a cent).
fn any_proration() -> (i64, u32, [MonthPart; PR], usize, i64) {
let total: i64 = kani::any();
kani::assume(total >= 0 && total <= PR_TOTAL);
let months: u32 = kani::any();
kani::assume(months >= 1 && months <= 3);
let parts: [MonthPart; PR] = core::array::from_fn(|_| any_part());
let n: usize = kani::any();
kani::assume(n >= 1 && n <= PR);
let unit = if kani::any() { 1 } else { 10 };
(total, months, parts, n, unit)
}
/// The weight of parts, in MONTH_L-ths of a month.
fn weight(parts: &[MonthPart]) -> i64 {
parts
.iter()
.map(|p| i64::from(p.days) * i64::from(MONTH_L / p.month_days))
.sum()
}
/// The parts of a total prorated by days sum to it exactly, and none is
/// negative; and every split whose parts but the last fit in the span is
/// computed (only symbolic parts that are no calendar's are refused).
#[kani::proof]
#[kani::unwind(5)]
fn prorate_parts_sum_to_total() {
let (total, months, parts, n, unit) = any_proration();
let mut out = [0i64; PR];
let got = kernel::prorate_by_days(total, months, &parts[..n], unit, &mut out[..n]);
let fits = weight(&parts[..n - 1]) <= i64::from(months) * i64::from(MONTH_L);
assert_eq!(got.is_some(), fits);
if got.is_some() {
let mut sum = 0i64;
for &o in &out[..n] {
assert!(o >= 0);
sum += o;
}
assert_eq!(sum, total);
kani::cover!(n == PR && total > 0, "three parts of a positive total");
}
}
/// The last part is the total less all the others: it absorbs the
/// remainder, the final month's drift included, and is never negative.
/// A corollary of [`prorate_parts_sum_to_total`] (the parts sum to the
/// total and none is negative), kept as its own harness because the plan
/// names the law (plan/served.md P3 defect 9).
#[kani::proof]
#[kani::unwind(5)]
fn prorate_last_part_absorbs_the_remainder() {
let (total, months, parts, n, unit) = any_proration();
let mut out = [0i64; PR];
if kernel::prorate_by_days(total, months, &parts[..n], unit, &mut out[..n]).is_none() {
return;
}
let mut others = 0i64;
for &o in &out[..n - 1] {
others += o;
}
assert_eq!(out[n - 1], total - others);
assert!(out[n - 1] >= 0);
kani::cover!(
n == PR && parts[0].days < parts[0].month_days,
"a partial first month, then the last absorbing the drift"
);
}
/// A cumulative charge is its exact share rounded half up to a multiple of
/// the unit (or the total itself, when that rounds above it): a multiple of
/// the unit or the total, with −unit × den < 2 × (charge × den − num) <=
/// unit × den (a tie goes up, never down), where num/den is the exact
/// share of the total the kernel rounds (a month but the last: total ×
/// (before + k × MONTH_L / days in month) / W; the last month: total ×
/// (before × d + (W − before) × k) / (W × d)); never negative, never above
/// the total; and the total exactly on the last month's last day. Each
/// part but the last, a difference of two such charges over W, is then
/// its days times the monthly rate within one unit (plan/served.md P3
/// defect 9: "each part equals days × rate within 0.01", for any minor
/// unit): Lean `Prorate.part_within_a_unit` does that arithmetic, for any
/// parts.
///
/// At `i32` (the kernel is generic over [`kernel::Units`]), 1 or 2 months,
/// totals to `max_total`, a unit fixed per harness: a property relating a
/// product to a quotient is costly for the solver, and at `i64` these ran
/// past twenty minutes (docs/verification.md). The bridge checks the `i32`
/// kernel against the engine's `i128` one. Returns (charge, total, num,
/// den) for the callers' covers; `None` on the last month's last day.
fn charge_is_within_half_a_unit(
last: bool,
unit: i32,
max_total: i32,
) -> Option<(i64, i64, i64, i64)> {
let total: i32 = kani::any();
kani::assume(total >= 0 && total <= max_total);
let months: u32 = kani::any();
kani::assume(months >= 1 && months <= 2);
let part = any_part();
let whole = months as i32 * MONTH_L as i32;
let before: i32 = kani::any();
kani::assume(before >= 0 && before <= whole);
let k: u32 = kani::any();
kani::assume(k <= part.days);
let rate = (MONTH_L / part.month_days) as i32;
// A month but the last ends inside the span.
kani::assume(last || before + k as i32 * rate <= whole);
let c = kernel::prorated_through(total, months, before, part, k, last, unit)
.expect("well formed, in the span, and no overflow at these sizes");
assert!(0 <= c && c <= total);
if last && k == part.days {
assert_eq!(c, total);
return None;
}
assert!(c % unit == 0 || c == total);
let (total, whole, before, k, unit) = (
i64::from(total),
i64::from(whole),
i64::from(before),
i64::from(k),
i64::from(unit),
);
let (num, den) = if last {
let d = i64::from(part.days);
(total * (before * d + (whole - before) * k), whole * d)
} else {
(total * (before + k * i64::from(rate)), whole)
};
let c = i64::from(c);
let e = 2 * (c * den - num);
assert!(e <= unit * den && e > -(unit * den));
kani::cover!(c > 0 && c < total, "a charge strictly inside the total");
kani::cover!(e == unit * den, "a half-unit tie rounds up");
Some((c, total, num, den))
}
/// The cap reached: a total that isn't a whole number of units, whose
/// exact share rounds above it before the last day, is charged itself.
fn cover_the_cap(got: Option<(i64, i64, i64, i64)>) {
kani::cover!(
got.is_some_and(|(c, total, num, den)| c == total && total % 10 != 0 && num < total * den),
"the charge capped at a total finer than its unit"
);
}
/// [`charge_is_within_half_a_unit`] for a month but the last, in cents
/// (totals to 2,048 units).
#[kani::proof]
fn a_months_charge_is_within_half_a_cent() {
charge_is_within_half_a_unit(false, 1, 2_048);
}
/// [`charge_is_within_half_a_unit`] for a month but the last, in cents of
/// an amount kept in tenths of a cent (an asset an earlier version took).
#[kani::proof]
fn a_months_charge_in_tenths_is_within_half_a_cent() {
cover_the_cap(charge_is_within_half_a_unit(false, 10, 2_048));
}
/// [`charge_is_within_half_a_unit`] for the span's last month, in cents
/// (totals to 64 units: its numerator is the total times up to 31 months'
/// worth of MONTH_L-ths).
#[kani::proof]
fn the_last_months_charge_is_within_half_a_cent() {
charge_is_within_half_a_unit(true, 1, 64);
}
/// [`charge_is_within_half_a_unit`] for the span's last month, in cents of
/// an amount kept in tenths of a cent.
#[kani::proof]
fn the_last_months_charge_in_tenths_is_within_half_a_cent() {
cover_the_cap(charge_is_within_half_a_unit(true, 10, 64));
}
/// Malformed input is refused, never a panic: a negative total, no months,
/// a unit that isn't positive, a part no month has, a day past the part,
/// a weight before it outside the span, or a part but the last ending past
/// it.
#[kani::proof]
fn prorated_through_refuses_malformed_input() {
let total: i64 = kani::any();
kani::assume(total.unsigned_abs() <= 1 << 20);
let months: u32 = kani::any();
kani::assume(months <= 3);
let before: i64 = kani::any();
kani::assume(before.unsigned_abs() <= 4 * u64::from(MONTH_L));
let part = MonthPart {
days: kani::any(),
month_days: kani::any(),
};
kani::assume(part.days <= 40 && part.month_days <= 40);
let k: u32 = kani::any();
kani::assume(k <= 40);
let last: bool = kani::any();
let unit: i64 = kani::any();
kani::assume(unit.unsigned_abs() <= 10);
let got = kernel::prorated_through(total, months, before, part, k, last, unit);
let whole = i64::from(months) * i64::from(MONTH_L);
let malformed = total < 0
|| months == 0
|| unit <= 0
|| !(28..=31).contains(&part.month_days)
|| part.days == 0
|| part.days > part.month_days
|| k > part.days
|| before < 0
|| before > whole;
kani::cover!(malformed, "malformed input");
kani::cover!(
!malformed && got.is_none(),
"a part but the last past the span"
);
if malformed {
assert!(got.is_none());
}
if !malformed {
// At these sizes nothing overflows: only a part but the last that
// ends past the span is refused.
let ends_past =
!last && before + i64::from(k) * i64::from(MONTH_L / part.month_days) > whole;
assert_eq!(got.is_none(), ends_past);
}
kani::cover!(got.is_some(), "well-formed input is computed");
}
// ---------------------------------------------------------------------------
// Catching up and truing up (B-15)
// ---------------------------------------------------------------------------
/// A catch-up run posts max(0, min gap), never negative, and never carries
/// the books past the schedule at a month end already posted.
#[kani::proof]
#[kani::unwind(5)]
fn catch_up_never_overtakes_the_schedule() {
let n = any_len();
kani::assume(n >= 1 && n <= PR);
let scheduled: [i64; PR] = core::array::from_fn(|_| any_amount());
let posted: [i64; PR] = core::array::from_fn(|_| any_amount());
let got = kernel::catch_up(&scheduled[..n], &posted[..n]).expect("in range");
assert!(got >= 0);
let mut least = scheduled[0] - posted[0];
for i in 0..n {
least = least.min(scheduled[i] - posted[i]);
}
assert_eq!(got, least.max(0));
// When the books carry no more than the schedule anywhere, the run
// keeps it so at every month end.
if least >= 0 {
for i in 0..n {
assert!(posted[i] + got <= scheduled[i]);
}
}
kani::cover!(n == PR && got > 0, "a catch-up across three month ends");
}
/// What a run of every month posts at once (a viewer's one pass) is the
/// catch-up of every suffix, month by month.
#[kani::proof]
#[kani::unwind(5)]
fn catch_ups_is_every_suffixs_catch_up() {
let n = any_len();
kani::assume(n >= 1 && n <= PR);
let scheduled: [i64; PR] = core::array::from_fn(|_| any_amount());
let posted: [i64; PR] = core::array::from_fn(|_| any_amount());
let mut out = [0i64; PR];
kernel::catch_ups(&scheduled[..n], &posted[..n], &mut out[..n]).expect("in range");
for i in 0..n {
assert_eq!(
Some(out[i]),
kernel::catch_up(&scheduled[i..n], &posted[i..n])
);
}
kani::cover!(
n == PR && out[0] > 0 && out[0] < out[1],
"distinct positive suffixes"
);
}
/// A disposal's true-up reaches the target from what the books carry: up
/// or down, never both, both non-negative.
#[kani::proof]
fn true_up_reaches_the_target() {
let target = any_amount();
let posted = any_amount();
let (up, down) = kernel::true_up(target, posted).expect("in range");
assert!(up >= 0 && down >= 0);
assert!(up == 0 || down == 0);
assert_eq!(posted + up - down, target);
kani::cover!(up > 0, "depreciation still due");
kani::cover!(down > 0, "depreciation past the day, credited back");
}
// ---------------------------------------------------------------------------
// The checker itself
// ---------------------------------------------------------------------------
+4 -3
View File
@@ -1261,8 +1261,9 @@ impl AssetRegister {
/// unposted_depreciation and unregistered_cost are zero exactly when
/// register and books agree.
/// 6. NEVER panics: a contributor basis is as journaled, of any size
/// (rules.md B-28), and a total past exact arithmetic is refused
/// (TooLargeToCompute).
/// (rules.md B-28), and a total past exact arithmetic, or a schedule
/// that can't be computed, is refused (TooLargeToCompute,
/// DateOutOfRange).
/// ```
pub fn fixed_asset_register(
ledger: &Ledger,
@@ -1290,7 +1291,7 @@ pub fn fixed_asset_register(
let acc = if disposed.is_some() {
Decimal::ZERO
} else {
crate::assets::accumulated_depreciation(a, as_of)
crate::assets::accumulated_depreciation(a, as_of)?
};
if disposed.is_none() {
cost = cost
+370 -26
View File
@@ -3,21 +3,27 @@
//! Two kinds, both master data owned by [`crate::Ledger`] and journaled:
//!
//! - **Amortization schedules** spread a prepaid expense or deferred revenue
//! evenly over the months it covers: a year of insurance paid up front is
//! an asset expensed 1/12 a month; a year of subscription revenue
//! collected up front is a liability earned 1/12 a month. Like
//! depreciation, the schedule is the evidence for its postings, so they
//! need no receipts. Each month posts once (idempotent runs).
//! over the days it covers: a year of insurance paid up front is an asset
//! expensed as each day passes; a year of subscription revenue collected
//! up front is a liability earned as each day passes. New schedules
//! spread by day ([`ScheduleConvention::Daily`], D-11, D-37: a policy
//! from March 15 posts a small March, full months, and a small final
//! March); schedules an earlier version registered keep equal months
//! ([`ScheduleConvention::MonthlyEven`]). Like depreciation, the
//! schedule is the evidence for its postings, so they need no receipts.
//! Each month posts once (idempotent runs); a reversed month is due
//! again.
//! - **Recurring entries** (rent, subscriptions, payroll) generate *drafts*
//! on their dates, never posted entries: the agent attaches the actual
//! receipt and posts through the normal preview, so the receipt policy is
//! never bypassed.
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money, Side};
use crate::money::Steps;
use crate::{AccountId, AccountType, ChartOfAccounts, EntryId, LedgerError, Money, Side, kernel};
use chrono::{Datelike, Days, Months, NaiveDate};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
/// Opaque handle for an amortization schedule.
pub type ScheduleId = u64;
@@ -38,6 +44,52 @@ pub enum ScheduleKind {
DeferredRevenue,
}
/// How a schedule spreads its total over the time it covers.
///
/// ```text
/// SOURCE: docs/spec/rules.md B-16; plan/served.md D-11 (new schedules
/// daily, "matching depreciation"; existing ones stay
/// monthly_even), D-37 (owner, 2026-10-20: confirmed; an
/// equal-months schedule starts on the 1st of a month, for
/// prepaid expenses and deferred revenue alike).
/// ```
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleConvention {
/// Equal months, the last absorbing rounding ([`amortization_plan`]):
/// every schedule registered before P3, and a new one that asks for it
/// (which then starts on the 1st). Unchanged forever: a posted month's
/// replay re-derives it.
#[default]
MonthlyEven,
/// By the day from the start date, as depreciation is ([`daily_plan`]):
/// full months equal, the first and last partial months by their days.
Daily,
}
impl ScheduleConvention {
/// Its wire name: `monthly_even` or `daily`.
pub fn key(self) -> &'static str {
match self {
ScheduleConvention::MonthlyEven => "monthly_even",
ScheduleConvention::Daily => "daily",
}
}
/// The convention named `key`.
pub fn parse(key: &str) -> Option<Self> {
[Self::MonthlyEven, Self::Daily]
.into_iter()
.find(|c| c.key() == key)
}
/// True for [`ScheduleConvention::MonthlyEven`] (which the journal and
/// the serialized ledger leave unsaid, as every schedule before P3 is).
pub fn is_monthly_even(&self) -> bool {
*self == ScheduleConvention::MonthlyEven
}
}
/// An amortization schedule.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AmortizationSchedule {
@@ -49,7 +101,9 @@ pub struct AmortizationSchedule {
pub kind: ScheduleKind,
/// The amount to spread.
pub total: Money,
/// Any date in the first month covered.
/// The first day covered (daily); any day of the first month covered
/// for a monthly_even schedule an earlier version took (new ones start
/// on the 1st).
pub start: NaiveDate,
/// Months covered.
pub months: u32,
@@ -58,8 +112,18 @@ pub struct AmortizationSchedule {
pub balance_account: AccountId,
/// The P&L account: expense or revenue.
pub pl_account: AccountId,
/// Months already posted, as their month-end dates.
/// Months posted and in effect (not reversed), as their month-end
/// dates.
pub posted: BTreeSet<NaiveDate>,
/// How the total spreads. Left unsaid when monthly_even, so books from
/// before P3 serialize as they did.
#[serde(default, skip_serializing_if = "ScheduleConvention::is_monthly_even")]
pub convention: ScheduleConvention,
/// Every entry that ever posted each month, in order: a reversal of
/// one un-posts its month, and reversing that reversal posts it again
/// (Ledger::reverse_entry). Derived on replay, never serialized.
#[serde(skip)]
pub postings: BTreeMap<NaiveDate, Vec<EntryId>>,
}
/// Parameters for a new schedule.
@@ -71,7 +135,8 @@ pub struct NewSchedule {
pub kind: ScheduleKind,
/// The amount to spread (positive).
pub total: Money,
/// Any date in the first month covered.
/// The first day covered: the 1st of a month for monthly_even (any day
/// of the first month for one an earlier version journaled).
pub start: NaiveDate,
/// Months covered (at least 1).
pub months: u32,
@@ -79,6 +144,9 @@ pub struct NewSchedule {
pub balance_account: AccountId,
/// Expense or revenue account.
pub pl_account: AccountId,
/// How the total spreads. Explicit here; a tool resolves the default
/// (`books.schedule_convention`, else daily).
pub convention: ScheduleConvention,
}
/// The last day of `d`'s month: in chrono's last month (December 262142),
@@ -149,22 +217,134 @@ pub fn amortization_plan(
.collect()
}
/// The schedule's postings by the day: one per calendar month the span
/// `[start, start + months)` touches, dated at the month's end, the total
/// spread as depreciation spreads an asset's cost
/// (kernel::prorate_by_days, rounded to the currency's minor unit).
///
/// ```text
/// GOAL: recognize a prepaid expense, or earn deferred revenue, as each
/// day it covers passes.
/// SOURCE: docs/spec/rules.md B-16 (ASC 606-10-25-23: revenue as the
/// obligation is satisfied; ASC 340-10-05-4: a prepaid is used up
/// over the time it covers); plan/served.md D-11, D-37.
/// INVARIANTS:
/// 1. The amounts sum to total exactly; every one is > 0 (a month that
/// rounds to nothing is left out: no entry carries a zero line).
/// 2. Full months are total / months each within one minor unit; the
/// first partial month is its days times that rate within one unit;
/// the last month takes what the others leave.
/// 3. A start on the 1st gives `months` postings; any other start one
/// more, the first and last partial.
/// 4. Unchanged forever: a posted month's replay re-derives the plan
/// (store `schedule.posted`), so this and the kernel are pinned by
/// exact tests, chrono's month-end clamping included.
/// 5. NEVER panics: a span past chrono's last day is DateOutOfRange, a
/// total past exact arithmetic TooLargeToCompute.
/// SCENARIOS:
/// 1. IF 1,200.00 from 2026-03-15 for 12 months:
/// - SHOULD be 54.84 on 03-31 (17 of 31 days), eleven of 100.00, and
/// 45.16 on 2027-03-31: thirteen month ends
/// BECAUSE each day covered bears the same share.
/// ```
pub fn daily_plan(
total: Money,
start: NaiveDate,
months: u32,
) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
let too_large = || {
LedgerError::TooLargeToCompute(format!("a {months}-month daily plan of {}", total.amount))
};
let end = start
.checked_add_months(Months::new(months))
.ok_or(LedgerError::DateOutOfRange(start))?;
let steps = Steps::of(total.amount, total.currency).ok_or_else(too_large)?;
let spans: Vec<crate::calendar::SpanPart> = crate::calendar::month_parts(start, end).collect();
let parts: Vec<kernel::MonthPart> = spans.iter().map(|s| s.part).collect();
let mut amounts = vec![0i128; parts.len()];
kernel::prorate_by_days(steps.units, months, &parts, steps.unit, &mut amounts)
.ok_or_else(too_large)?;
spans
.iter()
.zip(amounts)
.filter(|(_, a)| *a != 0)
.map(|(s, a)| Ok((month_end(s.first), steps.decimal(a).ok_or_else(too_large)?)))
.collect()
}
/// A schedule's postings under `convention` ([`amortization_plan`] or
/// [`daily_plan`]).
pub fn plan_for(
convention: ScheduleConvention,
total: Money,
start: NaiveDate,
months: u32,
) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
match convention {
ScheduleConvention::MonthlyEven => amortization_plan(total.amount, start, months),
ScheduleConvention::Daily => daily_plan(total, start, months),
}
}
/// Validate a new schedule against the chart: [`validate_schedule_shape`],
/// the total in range and in cents, and its first and last months inside
/// the calendar the books keep (crate::calendar; P0 review round 3, S1),
/// so every month end of the plan is a date.
/// the total in range and in the currency's minor unit, its first day and
/// last month (monthly_even) or last day (daily) inside the calendar the
/// books keep (crate::calendar; P0 review round 3, S1), so every month end
/// of the plan is a date, an equal-months schedule starting on the 1st
/// (D-37), and every month of the plan a whole number of minor units.
///
/// ```text
/// SCENARIOS:
/// 1. IF a new monthly_even schedule starts on any day but the 1st:
/// - SHOULD refuse, naming the 1st to start on, or the daily
/// convention
/// - NEVER let a schedule starting March 15 recognize all of March
/// BECAUSE revenue is never recognized before it is earned (ASC
/// 606-10-25-23), and one rule for prepaid expenses and deferred
/// revenue alike is simpler and never early (D-37). Replay keeps
/// schedules an earlier version took mid-month (shape checks only).
/// 2. IF a daily schedule's last day is after 9999-12-31 (one month from
/// 9999-12-15): SHOULD refuse.
/// 3. IF a monthly_even month would be finer than the minor unit (¥1,000
/// over 3 months is ¥333.33 a month): SHOULD refuse, naming daily
/// BECAUSE such a month could never post.
/// ```
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
validate_schedule_shape(new, chart)?;
crate::calendar::check(new.start)?;
if !new
.start
.checked_add_months(Months::new(new.months - 1))
.is_some_and(crate::calendar::in_calendar)
{
return Err(LedgerError::InvalidSchedule(format!(
let past_the_calendar = || {
Err(LedgerError::InvalidSchedule(format!(
"{} months from {} run past 9999-12-31, the last day the books keep",
new.months, new.start
)));
)))
};
match new.convention {
ScheduleConvention::MonthlyEven => {
if new.start.day() != 1 {
return Err(LedgerError::InvalidSchedule(format!(
"an equal-months (monthly_even) schedule starts on the 1st of a month, so it never recognizes days it doesn't cover (revenue is never earned early: ASC 606-10-25-23; the same rule for prepaid expenses, D-37): start it on {}, or spread it by day (convention daily) from {}",
month_first(new.start),
new.start
)));
}
if !new
.start
.checked_add_months(Months::new(new.months - 1))
.is_some_and(crate::calendar::in_calendar)
{
return past_the_calendar();
}
}
ScheduleConvention::Daily => {
if !new
.start
.checked_add_months(Months::new(new.months))
.and_then(|end| end.pred_opt())
.is_some_and(crate::calendar::in_calendar)
{
return past_the_calendar();
}
}
}
// Every month's posting derives from the total: one no line could
// carry, or finer than a cent, is refused here, not at a month end
@@ -172,7 +352,23 @@ pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(
if new.total.amount > crate::kernel::MAX_LINE_AMOUNT {
return Err(LedgerError::AmountOutOfRange);
}
crate::ledger::check_minor_units(new.total)
crate::ledger::check_minor_units(new.total)?;
let currency = new.total.currency;
if let Some((_, fine)) = plan_for(new.convention, new.total, new.start, new.months)?
.into_iter()
.find(|(_, a)| crate::ledger::check_minor_units(Money::new(*a, currency)).is_err())
{
return Err(LedgerError::InvalidSchedule(format!(
"{} {currency} in equal months over {} months gives {fine} {currency} a month, finer than the {currency} minor unit, so it could never post: spread it by day (convention daily)",
new.total.amount, new.months
)));
}
Ok(())
}
/// The 1st of `d`'s month.
fn month_first(d: NaiveDate) -> NaiveDate {
d.with_day(1).unwrap_or(d)
}
/// [`validate_schedule`] without the total's range and minor-unit checks
@@ -221,21 +417,58 @@ impl AmortizationSchedule {
}
}
/// Its monthly postings ([`amortization_plan`]); refused, never a
/// panic, for one that can't be computed.
/// Its monthly postings under its convention ([`amortization_plan`],
/// [`daily_plan`]); refused, never a panic, for one that can't be
/// computed.
pub fn plan(&self) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
amortization_plan(self.total.amount, self.start, self.months)
plan_for(self.convention, self.total, self.start, self.months)
}
/// Months due through `through` and not yet posted.
/// The first day it covers: its start (daily), or the 1st of its start's
/// month (monthly_even, whose months are whole).
pub fn first_day(&self) -> NaiveDate {
match self.convention {
ScheduleConvention::Daily => self.start,
ScheduleConvention::MonthlyEven => month_first(self.start),
}
}
/// The last day it covers: the day before start + months (daily), or
/// the end of its last month (monthly_even). `None` past chrono's last
/// day (a schedule an earlier version took).
pub fn last_day(&self) -> Option<NaiveDate> {
match self.convention {
ScheduleConvention::Daily => self
.start
.checked_add_months(Months::new(self.months))?
.pred_opt(),
ScheduleConvention::MonthlyEven => month_first(self.start)
.checked_add_months(Months::new(self.months.checked_sub(1)?))
.map(month_end),
}
}
/// Months due through `through` and not yet posted. A month whose
/// amount is zero (an equal-months plan of a total smaller than its
/// months) is never due: no entry carries a zero line.
pub fn due(&self, through: NaiveDate) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
Ok(self
.plan()?
.into_iter()
.filter(|(d, _)| *d <= through && !self.posted.contains(d))
.filter(|(d, a)| *d <= through && !a.is_zero() && !self.posted.contains(d))
.collect())
}
/// The next month end to post: the first month of the plan with an
/// amount, not posted. `None` once every one is.
pub fn next(&self) -> Result<Option<NaiveDate>, LedgerError> {
Ok(self
.plan()?
.into_iter()
.find(|(d, a)| !a.is_zero() && !self.posted.contains(d))
.map(|(d, _)| d))
}
/// The amount not yet recognized: the sum of the plan's unposted
/// months.
pub fn remaining(&self) -> Result<Decimal, LedgerError> {
@@ -482,12 +715,123 @@ mod tests {
balance_account: 1,
pl_account: 2,
posted: BTreeSet::new(),
convention: ScheduleConvention::MonthlyEven,
postings: BTreeMap::new(),
};
assert!(s.due(NaiveDate::MAX).is_err());
assert!(s.remaining().is_err());
assert!(s.next().is_err());
assert_eq!(s.last_day(), None);
let s = AmortizationSchedule { months: 2, ..s };
assert_eq!(s.due(NaiveDate::MAX).unwrap().len(), 2);
assert_eq!(s.remaining(), Ok(Decimal::new(300, 0)));
assert_eq!(s.last_day(), Some(NaiveDate::MAX));
assert_eq!(s.first_day(), d(262_142, 11, 1));
// A daily schedule at chrono's last month: computed where it can
// be (Nov 9-30 is 22 of the month's 30 days, Dec 1-8 the rest),
// refused where it can't.
let daily = AmortizationSchedule {
convention: ScheduleConvention::Daily,
months: 1,
..s.clone()
};
assert_eq!(
daily.plan().unwrap(),
[
(d(262_142, 11, 30), Decimal::new(22_000, 2)),
(NaiveDate::MAX, Decimal::new(8_000, 2)),
]
);
let daily = AmortizationSchedule { months: 2, ..daily };
assert_eq!(
daily.plan(),
Err(LedgerError::DateOutOfRange(d(262_142, 11, 9)))
);
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), crate::Currency::USD)
}
/// daily_plan SCENARIO 1 and INVARIANT 3: 1,200.00 from March 15 is
/// thirteen month ends; from the 1st it is twelve equal ones.
#[test]
fn a_daily_plan_spreads_by_the_day() {
let plan = daily_plan(usd(120_000), d(2026, 3, 15), 12).unwrap();
assert_eq!(plan.len(), 13);
assert_eq!(plan[0], (d(2026, 3, 31), cents(5_484)));
for (i, (date, amount)) in plan[1..12].iter().enumerate() {
assert_eq!(*amount, cents(10_000), "month {i}");
assert_eq!(*date, month_end(*date));
}
assert_eq!(plan[11].0, d(2027, 2, 28));
assert_eq!(plan[12], (d(2027, 3, 31), cents(4_516)));
let from_first = daily_plan(usd(120_000), d(2026, 3, 1), 12).unwrap();
assert_eq!(from_first.len(), 12);
assert!(from_first.iter().all(|(_, a)| *a == cents(10_000)));
assert_eq!(from_first[11].0, d(2027, 2, 28));
}
/// daily_plan INVARIANT 4 (P3 depreciation review R11(b)): chrono's
/// month-end clamping is pinned. One month from Jan 29, 30 or 31
/// covers through Feb 27 (2026; Jan 29 + 1 month is Feb 28, Jan 30
/// and 31 clamp to it); one month from Feb 29 (2028) through Mar 28.
#[test]
fn daily_plans_pin_month_end_clamping() {
let one = |start| daily_plan(usd(31_000), start, 1).unwrap();
// Jan 29-31 (3/31 of 310.00 = 30.00), Feb 1-27: the rest.
assert_eq!(
one(d(2026, 1, 29)),
[
(d(2026, 1, 31), cents(3_000)),
(d(2026, 2, 28), cents(28_000))
]
);
// Jan 30-31 (2/31 = 20.00), Feb 1-27 the rest.
assert_eq!(
one(d(2026, 1, 30)),
[
(d(2026, 1, 31), cents(2_000)),
(d(2026, 2, 28), cents(29_000))
]
);
// Jan 31 (1/31 = 10.00), Feb 1-27 the rest.
assert_eq!(
one(d(2026, 1, 31)),
[
(d(2026, 1, 31), cents(1_000)),
(d(2026, 2, 28), cents(30_000))
]
);
// Feb 29 2028 (1/29 of 290.00 = 10.00), Mar 1-28 the rest.
assert_eq!(
daily_plan(usd(29_000), d(2028, 2, 29), 1).unwrap(),
[
(d(2028, 2, 29), cents(1_000)),
(d(2028, 3, 31), cents(28_000))
]
);
}
/// The conventions' wire names, and what the serialized ledger leaves
/// unsaid.
#[test]
fn conventions_have_wire_names() {
for c in [ScheduleConvention::MonthlyEven, ScheduleConvention::Daily] {
assert_eq!(ScheduleConvention::parse(c.key()), Some(c));
assert_eq!(serde_json::to_value(c).unwrap(), c.key());
}
assert_eq!(ScheduleConvention::parse("weekly"), None);
assert_eq!(
ScheduleConvention::default(),
ScheduleConvention::MonthlyEven
);
assert!(ScheduleConvention::MonthlyEven.is_monthly_even());
assert!(!ScheduleConvention::Daily.is_monthly_even());
}
#[test]
+16
View File
@@ -153,6 +153,7 @@ const PARTY_POLICY: &[(&str, &[&str])] =
const APPROVAL_MODE: &[(&str, &[&str])] = &[("off", &[]), ("enforce", &[])];
const TAX_CATEGORY_POLICY: &[(&str, &[&str])] =
&[("warn", &[]), ("refuse", &["required", "require"])];
const SCHEDULE_CONVENTION: &[(&str, &[&str])] = &[("daily", &[]), ("monthly_even", &[])];
/// A return's key as a setting keeps it.
pub fn tax_form_key(f: TaxForm) -> &'static str {
@@ -334,6 +335,13 @@ pub static KEYS: &[Key] = &[
sensitivity: Sensitivity::Public,
doc: "Prefix of generated asset tags (\"AC\" for AC-0001): one to eight letters or digits. Unset: the company name's initials.",
},
Key {
name: "books.schedule_convention",
toml: ("books", "schedule_convention"),
kind: Kind::Choice(SCHEDULE_CONVENTION),
sensitivity: Sensitivity::Public,
doc: "How a new prepaid-expense or deferred-revenue schedule spreads its total: daily (from its start date: full months equal, the first and last partial months by their days, like depreciation) or monthly_even (equal months; the schedule starts on the 1st of a month). Unset: daily (D-11). Existing schedules keep theirs.",
},
Key {
name: "policy.evidence.threshold",
toml: ("books", "receipt_threshold"),
@@ -1296,6 +1304,14 @@ impl Settings {
self.str("books.asset_tag_prefix")
}
/// `books.schedule_convention`: how a new schedule spreads its total.
/// Unset: daily (plan/served.md D-11, confirmed in D-37).
pub fn schedule_convention(&self) -> crate::ScheduleConvention {
self.str("books.schedule_convention")
.and_then(crate::ScheduleConvention::parse)
.unwrap_or(crate::ScheduleConvention::Daily)
}
/// `policy.parties`.
pub fn parties(&self) -> PartyPolicy {
self.str("policy.parties")