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

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

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

No files matched your search

+370 -26
View File
@@ -3,21 +3,27 @@
//! Two kinds, both master data owned by [`crate::Ledger`] and journaled:
//!
//! - **Amortization schedules** spread a prepaid expense or deferred revenue
//! evenly over the months it covers: a year of insurance paid up front is
//! an asset expensed 1/12 a month; a year of subscription revenue
//! collected up front is a liability earned 1/12 a month. Like
//! depreciation, the schedule is the evidence for its postings, so they
//! need no receipts. Each month posts once (idempotent runs).
//! over the days it covers: a year of insurance paid up front is an asset
//! expensed as each day passes; a year of subscription revenue collected
//! up front is a liability earned as each day passes. New schedules
//! spread by day ([`ScheduleConvention::Daily`], D-11, D-37: a policy
//! from March 15 posts a small March, full months, and a small final
//! March); schedules an earlier version registered keep equal months
//! ([`ScheduleConvention::MonthlyEven`]). Like depreciation, the
//! schedule is the evidence for its postings, so they need no receipts.
//! Each month posts once (idempotent runs); a reversed month is due
//! again.
//! - **Recurring entries** (rent, subscriptions, payroll) generate *drafts*
//! on their dates, never posted entries: the agent attaches the actual
//! receipt and posts through the normal preview, so the receipt policy is
//! never bypassed.
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money, Side};
use crate::money::Steps;
use crate::{AccountId, AccountType, ChartOfAccounts, EntryId, LedgerError, Money, Side, kernel};
use chrono::{Datelike, Days, Months, NaiveDate};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
/// Opaque handle for an amortization schedule.
pub type ScheduleId = u64;
@@ -38,6 +44,52 @@ pub enum ScheduleKind {
DeferredRevenue,
}
/// How a schedule spreads its total over the time it covers.
///
/// ```text
/// SOURCE: docs/spec/rules.md B-16; plan/served.md D-11 (new schedules
/// daily, "matching depreciation"; existing ones stay
/// monthly_even), D-37 (owner, 2026-10-20: confirmed; an
/// equal-months schedule starts on the 1st of a month, for
/// prepaid expenses and deferred revenue alike).
/// ```
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleConvention {
/// Equal months, the last absorbing rounding ([`amortization_plan`]):
/// every schedule registered before P3, and a new one that asks for it
/// (which then starts on the 1st). Unchanged forever: a posted month's
/// replay re-derives it.
#[default]
MonthlyEven,
/// By the day from the start date, as depreciation is ([`daily_plan`]):
/// full months equal, the first and last partial months by their days.
Daily,
}
impl ScheduleConvention {
/// Its wire name: `monthly_even` or `daily`.
pub fn key(self) -> &'static str {
match self {
ScheduleConvention::MonthlyEven => "monthly_even",
ScheduleConvention::Daily => "daily",
}
}
/// The convention named `key`.
pub fn parse(key: &str) -> Option<Self> {
[Self::MonthlyEven, Self::Daily]
.into_iter()
.find(|c| c.key() == key)
}
/// True for [`ScheduleConvention::MonthlyEven`] (which the journal and
/// the serialized ledger leave unsaid, as every schedule before P3 is).
pub fn is_monthly_even(&self) -> bool {
*self == ScheduleConvention::MonthlyEven
}
}
/// An amortization schedule.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AmortizationSchedule {
@@ -49,7 +101,9 @@ pub struct AmortizationSchedule {
pub kind: ScheduleKind,
/// The amount to spread.
pub total: Money,
/// Any date in the first month covered.
/// The first day covered (daily); any day of the first month covered
/// for a monthly_even schedule an earlier version took (new ones start
/// on the 1st).
pub start: NaiveDate,
/// Months covered.
pub months: u32,
@@ -58,8 +112,18 @@ pub struct AmortizationSchedule {
pub balance_account: AccountId,
/// The P&L account: expense or revenue.
pub pl_account: AccountId,
/// Months already posted, as their month-end dates.
/// Months posted and in effect (not reversed), as their month-end
/// dates.
pub posted: BTreeSet<NaiveDate>,
/// How the total spreads. Left unsaid when monthly_even, so books from
/// before P3 serialize as they did.
#[serde(default, skip_serializing_if = "ScheduleConvention::is_monthly_even")]
pub convention: ScheduleConvention,
/// Every entry that ever posted each month, in order: a reversal of
/// one un-posts its month, and reversing that reversal posts it again
/// (Ledger::reverse_entry). Derived on replay, never serialized.
#[serde(skip)]
pub postings: BTreeMap<NaiveDate, Vec<EntryId>>,
}
/// Parameters for a new schedule.
@@ -71,7 +135,8 @@ pub struct NewSchedule {
pub kind: ScheduleKind,
/// The amount to spread (positive).
pub total: Money,
/// Any date in the first month covered.
/// The first day covered: the 1st of a month for monthly_even (any day
/// of the first month for one an earlier version journaled).
pub start: NaiveDate,
/// Months covered (at least 1).
pub months: u32,
@@ -79,6 +144,9 @@ pub struct NewSchedule {
pub balance_account: AccountId,
/// Expense or revenue account.
pub pl_account: AccountId,
/// How the total spreads. Explicit here; a tool resolves the default
/// (`books.schedule_convention`, else daily).
pub convention: ScheduleConvention,
}
/// The last day of `d`'s month: in chrono's last month (December 262142),
@@ -149,22 +217,134 @@ pub fn amortization_plan(
.collect()
}
/// The schedule's postings by the day: one per calendar month the span
/// `[start, start + months)` touches, dated at the month's end, the total
/// spread as depreciation spreads an asset's cost
/// (kernel::prorate_by_days, rounded to the currency's minor unit).
///
/// ```text
/// GOAL: recognize a prepaid expense, or earn deferred revenue, as each
/// day it covers passes.
/// SOURCE: docs/spec/rules.md B-16 (ASC 606-10-25-23: revenue as the
/// obligation is satisfied; ASC 340-10-05-4: a prepaid is used up
/// over the time it covers); plan/served.md D-11, D-37.
/// INVARIANTS:
/// 1. The amounts sum to total exactly; every one is > 0 (a month that
/// rounds to nothing is left out: no entry carries a zero line).
/// 2. Full months are total / months each within one minor unit; the
/// first partial month is its days times that rate within one unit;
/// the last month takes what the others leave.
/// 3. A start on the 1st gives `months` postings; any other start one
/// more, the first and last partial.
/// 4. Unchanged forever: a posted month's replay re-derives the plan
/// (store `schedule.posted`), so this and the kernel are pinned by
/// exact tests, chrono's month-end clamping included.
/// 5. NEVER panics: a span past chrono's last day is DateOutOfRange, a
/// total past exact arithmetic TooLargeToCompute.
/// SCENARIOS:
/// 1. IF 1,200.00 from 2026-03-15 for 12 months:
/// - SHOULD be 54.84 on 03-31 (17 of 31 days), eleven of 100.00, and
/// 45.16 on 2027-03-31: thirteen month ends
/// BECAUSE each day covered bears the same share.
/// ```
pub fn daily_plan(
total: Money,
start: NaiveDate,
months: u32,
) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
let too_large = || {
LedgerError::TooLargeToCompute(format!("a {months}-month daily plan of {}", total.amount))
};
let end = start
.checked_add_months(Months::new(months))
.ok_or(LedgerError::DateOutOfRange(start))?;
let steps = Steps::of(total.amount, total.currency).ok_or_else(too_large)?;
let spans: Vec<crate::calendar::SpanPart> = crate::calendar::month_parts(start, end).collect();
let parts: Vec<kernel::MonthPart> = spans.iter().map(|s| s.part).collect();
let mut amounts = vec![0i128; parts.len()];
kernel::prorate_by_days(steps.units, months, &parts, steps.unit, &mut amounts)
.ok_or_else(too_large)?;
spans
.iter()
.zip(amounts)
.filter(|(_, a)| *a != 0)
.map(|(s, a)| Ok((month_end(s.first), steps.decimal(a).ok_or_else(too_large)?)))
.collect()
}
/// A schedule's postings under `convention` ([`amortization_plan`] or
/// [`daily_plan`]).
pub fn plan_for(
convention: ScheduleConvention,
total: Money,
start: NaiveDate,
months: u32,
) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
match convention {
ScheduleConvention::MonthlyEven => amortization_plan(total.amount, start, months),
ScheduleConvention::Daily => daily_plan(total, start, months),
}
}
/// Validate a new schedule against the chart: [`validate_schedule_shape`],
/// the total in range and in cents, and its first and last months inside
/// the calendar the books keep (crate::calendar; P0 review round 3, S1),
/// so every month end of the plan is a date.
/// the total in range and in the currency's minor unit, its first day and
/// last month (monthly_even) or last day (daily) inside the calendar the
/// books keep (crate::calendar; P0 review round 3, S1), so every month end
/// of the plan is a date, an equal-months schedule starting on the 1st
/// (D-37), and every month of the plan a whole number of minor units.
///
/// ```text
/// SCENARIOS:
/// 1. IF a new monthly_even schedule starts on any day but the 1st:
/// - SHOULD refuse, naming the 1st to start on, or the daily
/// convention
/// - NEVER let a schedule starting March 15 recognize all of March
/// BECAUSE revenue is never recognized before it is earned (ASC
/// 606-10-25-23), and one rule for prepaid expenses and deferred
/// revenue alike is simpler and never early (D-37). Replay keeps
/// schedules an earlier version took mid-month (shape checks only).
/// 2. IF a daily schedule's last day is after 9999-12-31 (one month from
/// 9999-12-15): SHOULD refuse.
/// 3. IF a monthly_even month would be finer than the minor unit (¥1,000
/// over 3 months is ¥333.33 a month): SHOULD refuse, naming daily
/// BECAUSE such a month could never post.
/// ```
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
validate_schedule_shape(new, chart)?;
crate::calendar::check(new.start)?;
if !new
.start
.checked_add_months(Months::new(new.months - 1))
.is_some_and(crate::calendar::in_calendar)
{
return Err(LedgerError::InvalidSchedule(format!(
let past_the_calendar = || {
Err(LedgerError::InvalidSchedule(format!(
"{} months from {} run past 9999-12-31, the last day the books keep",
new.months, new.start
)));
)))
};
match new.convention {
ScheduleConvention::MonthlyEven => {
if new.start.day() != 1 {
return Err(LedgerError::InvalidSchedule(format!(
"an equal-months (monthly_even) schedule starts on the 1st of a month, so it never recognizes days it doesn't cover (revenue is never earned early: ASC 606-10-25-23; the same rule for prepaid expenses, D-37): start it on {}, or spread it by day (convention daily) from {}",
month_first(new.start),
new.start
)));
}
if !new
.start
.checked_add_months(Months::new(new.months - 1))
.is_some_and(crate::calendar::in_calendar)
{
return past_the_calendar();
}
}
ScheduleConvention::Daily => {
if !new
.start
.checked_add_months(Months::new(new.months))
.and_then(|end| end.pred_opt())
.is_some_and(crate::calendar::in_calendar)
{
return past_the_calendar();
}
}
}
// Every month's posting derives from the total: one no line could
// carry, or finer than a cent, is refused here, not at a month end
@@ -172,7 +352,23 @@ pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(
if new.total.amount > crate::kernel::MAX_LINE_AMOUNT {
return Err(LedgerError::AmountOutOfRange);
}
crate::ledger::check_minor_units(new.total)
crate::ledger::check_minor_units(new.total)?;
let currency = new.total.currency;
if let Some((_, fine)) = plan_for(new.convention, new.total, new.start, new.months)?
.into_iter()
.find(|(_, a)| crate::ledger::check_minor_units(Money::new(*a, currency)).is_err())
{
return Err(LedgerError::InvalidSchedule(format!(
"{} {currency} in equal months over {} months gives {fine} {currency} a month, finer than the {currency} minor unit, so it could never post: spread it by day (convention daily)",
new.total.amount, new.months
)));
}
Ok(())
}
/// The 1st of `d`'s month.
fn month_first(d: NaiveDate) -> NaiveDate {
d.with_day(1).unwrap_or(d)
}
/// [`validate_schedule`] without the total's range and minor-unit checks
@@ -221,21 +417,58 @@ impl AmortizationSchedule {
}
}
/// Its monthly postings ([`amortization_plan`]); refused, never a
/// panic, for one that can't be computed.
/// Its monthly postings under its convention ([`amortization_plan`],
/// [`daily_plan`]); refused, never a panic, for one that can't be
/// computed.
pub fn plan(&self) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
amortization_plan(self.total.amount, self.start, self.months)
plan_for(self.convention, self.total, self.start, self.months)
}
/// Months due through `through` and not yet posted.
/// The first day it covers: its start (daily), or the 1st of its start's
/// month (monthly_even, whose months are whole).
pub fn first_day(&self) -> NaiveDate {
match self.convention {
ScheduleConvention::Daily => self.start,
ScheduleConvention::MonthlyEven => month_first(self.start),
}
}
/// The last day it covers: the day before start + months (daily), or
/// the end of its last month (monthly_even). `None` past chrono's last
/// day (a schedule an earlier version took).
pub fn last_day(&self) -> Option<NaiveDate> {
match self.convention {
ScheduleConvention::Daily => self
.start
.checked_add_months(Months::new(self.months))?
.pred_opt(),
ScheduleConvention::MonthlyEven => month_first(self.start)
.checked_add_months(Months::new(self.months.checked_sub(1)?))
.map(month_end),
}
}
/// Months due through `through` and not yet posted. A month whose
/// amount is zero (an equal-months plan of a total smaller than its
/// months) is never due: no entry carries a zero line.
pub fn due(&self, through: NaiveDate) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
Ok(self
.plan()?
.into_iter()
.filter(|(d, _)| *d <= through && !self.posted.contains(d))
.filter(|(d, a)| *d <= through && !a.is_zero() && !self.posted.contains(d))
.collect())
}
/// The next month end to post: the first month of the plan with an
/// amount, not posted. `None` once every one is.
pub fn next(&self) -> Result<Option<NaiveDate>, LedgerError> {
Ok(self
.plan()?
.into_iter()
.find(|(d, a)| !a.is_zero() && !self.posted.contains(d))
.map(|(d, _)| d))
}
/// The amount not yet recognized: the sum of the plan's unposted
/// months.
pub fn remaining(&self) -> Result<Decimal, LedgerError> {
@@ -482,12 +715,123 @@ mod tests {
balance_account: 1,
pl_account: 2,
posted: BTreeSet::new(),
convention: ScheduleConvention::MonthlyEven,
postings: BTreeMap::new(),
};
assert!(s.due(NaiveDate::MAX).is_err());
assert!(s.remaining().is_err());
assert!(s.next().is_err());
assert_eq!(s.last_day(), None);
let s = AmortizationSchedule { months: 2, ..s };
assert_eq!(s.due(NaiveDate::MAX).unwrap().len(), 2);
assert_eq!(s.remaining(), Ok(Decimal::new(300, 0)));
assert_eq!(s.last_day(), Some(NaiveDate::MAX));
assert_eq!(s.first_day(), d(262_142, 11, 1));
// A daily schedule at chrono's last month: computed where it can
// be (Nov 9-30 is 22 of the month's 30 days, Dec 1-8 the rest),
// refused where it can't.
let daily = AmortizationSchedule {
convention: ScheduleConvention::Daily,
months: 1,
..s.clone()
};
assert_eq!(
daily.plan().unwrap(),
[
(d(262_142, 11, 30), Decimal::new(22_000, 2)),
(NaiveDate::MAX, Decimal::new(8_000, 2)),
]
);
let daily = AmortizationSchedule { months: 2, ..daily };
assert_eq!(
daily.plan(),
Err(LedgerError::DateOutOfRange(d(262_142, 11, 9)))
);
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), crate::Currency::USD)
}
/// daily_plan SCENARIO 1 and INVARIANT 3: 1,200.00 from March 15 is
/// thirteen month ends; from the 1st it is twelve equal ones.
#[test]
fn a_daily_plan_spreads_by_the_day() {
let plan = daily_plan(usd(120_000), d(2026, 3, 15), 12).unwrap();
assert_eq!(plan.len(), 13);
assert_eq!(plan[0], (d(2026, 3, 31), cents(5_484)));
for (i, (date, amount)) in plan[1..12].iter().enumerate() {
assert_eq!(*amount, cents(10_000), "month {i}");
assert_eq!(*date, month_end(*date));
}
assert_eq!(plan[11].0, d(2027, 2, 28));
assert_eq!(plan[12], (d(2027, 3, 31), cents(4_516)));
let from_first = daily_plan(usd(120_000), d(2026, 3, 1), 12).unwrap();
assert_eq!(from_first.len(), 12);
assert!(from_first.iter().all(|(_, a)| *a == cents(10_000)));
assert_eq!(from_first[11].0, d(2027, 2, 28));
}
/// daily_plan INVARIANT 4 (P3 depreciation review R11(b)): chrono's
/// month-end clamping is pinned. One month from Jan 29, 30 or 31
/// covers through Feb 27 (2026; Jan 29 + 1 month is Feb 28, Jan 30
/// and 31 clamp to it); one month from Feb 29 (2028) through Mar 28.
#[test]
fn daily_plans_pin_month_end_clamping() {
let one = |start| daily_plan(usd(31_000), start, 1).unwrap();
// Jan 29-31 (3/31 of 310.00 = 30.00), Feb 1-27: the rest.
assert_eq!(
one(d(2026, 1, 29)),
[
(d(2026, 1, 31), cents(3_000)),
(d(2026, 2, 28), cents(28_000))
]
);
// Jan 30-31 (2/31 = 20.00), Feb 1-27 the rest.
assert_eq!(
one(d(2026, 1, 30)),
[
(d(2026, 1, 31), cents(2_000)),
(d(2026, 2, 28), cents(29_000))
]
);
// Jan 31 (1/31 = 10.00), Feb 1-27 the rest.
assert_eq!(
one(d(2026, 1, 31)),
[
(d(2026, 1, 31), cents(1_000)),
(d(2026, 2, 28), cents(30_000))
]
);
// Feb 29 2028 (1/29 of 290.00 = 10.00), Mar 1-28 the rest.
assert_eq!(
daily_plan(usd(29_000), d(2028, 2, 29), 1).unwrap(),
[
(d(2028, 2, 29), cents(1_000)),
(d(2028, 3, 31), cents(28_000))
]
);
}
/// The conventions' wire names, and what the serialized ledger leaves
/// unsaid.
#[test]
fn conventions_have_wire_names() {
for c in [ScheduleConvention::MonthlyEven, ScheduleConvention::Daily] {
assert_eq!(ScheduleConvention::parse(c.key()), Some(c));
assert_eq!(serde_json::to_value(c).unwrap(), c.key());
}
assert_eq!(ScheduleConvention::parse("weekly"), None);
assert_eq!(
ScheduleConvention::default(),
ScheduleConvention::MonthlyEven
);
assert!(ScheduleConvention::MonthlyEven.is_monthly_even());
assert!(!ScheduleConvention::Daily.is_monthly_even());
}
#[test]