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