Files
wanderbooks/crates/ledger-core/src/schedules.rs
T
swaitsandClaude Opus 5.5 b0a9fd8ad4 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
2026-10-21 14:04:11 -06:00

861 lines
34 KiB
Rust

//! Things that happen on a schedule.
//!
//! Two kinds, both master data owned by [`crate::Ledger`] and journaled:
//!
//! - **Amortization schedules** spread a prepaid expense or deferred revenue
//! 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::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::{BTreeMap, BTreeSet};
/// Opaque handle for an amortization schedule.
pub type ScheduleId = u64;
/// Opaque handle for a recurring entry.
pub type RecurringId = u64;
/// One line of a template entry: account, side, amount.
pub type TemplateLine = (AccountId, Side, Money);
/// What an amortization schedule recognizes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleKind {
/// A prepaid expense: each month debits the expense account and
/// credits the prepaid (asset) account.
PrepaidExpense,
/// Deferred (unearned) revenue: each month debits the deferred revenue
/// (liability) account and credits the revenue account.
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 {
/// Opaque handle.
pub id: ScheduleId,
/// What it is, e.g. "2027 general liability insurance".
pub description: String,
/// Prepaid expense or deferred revenue.
pub kind: ScheduleKind,
/// The amount to spread.
pub total: Money,
/// 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,
/// The balance-sheet account: prepaid (asset) or deferred revenue
/// (liability).
pub balance_account: AccountId,
/// The P&L account: expense or revenue.
pub pl_account: AccountId,
/// 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.
#[derive(Clone, Debug)]
pub struct NewSchedule {
/// What it is.
pub description: String,
/// Prepaid expense or deferred revenue.
pub kind: ScheduleKind,
/// The amount to spread (positive).
pub total: Money,
/// 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,
/// Prepaid (asset) or deferred revenue (liability) account.
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),
/// NaiveDate::MAX, never a panic.
pub fn month_end(d: NaiveDate) -> NaiveDate {
d.with_day(1)
.and_then(|first| first.checked_add_months(Months::new(1)))
.and_then(|next| next.checked_sub_days(Days::new(1)))
.unwrap_or(NaiveDate::MAX)
}
/// The smallest total whose plan isn't computed exactly in cents (10^26):
/// 26 integer digits and 2 decimals fill Decimal's 28 significant digits.
pub const EXACT_LIMIT: Decimal =
Decimal::from_parts(0xe400_0000, 0xdcc8_0cd2, 0x0052_b7d2, false, 0);
/// The schedule's monthly postings: (month-end date, amount). Equal
/// amounts to the cent, the last month absorbing rounding, so they sum to
/// the total exactly.
///
/// ```text
/// INVARIANTS:
/// 1. The amounts sum to `total` exactly; all but the last are equal.
/// 2. NEVER panics: a schedule an earlier version journaled whose months
/// run past chrono's last day is refused with DateOutOfRange, and one
/// whose amounts exact arithmetic can't hold with TooLargeToCompute
/// (rules.md B-28). `months` is at least 1 (validate_schedule_shape).
/// 3. NEVER rounds silently: a total of EXACT_LIMIT or more is refused
/// with TooLargeToCompute. Below it every amount in cents, and every
/// sum of them (none exceeds the total), fits Decimal's 28 significant
/// digits, so INVARIANT 1 holds exactly; at it, Decimal's checked
/// operations round instead of failing.
/// ```
///
/// Replay is unaffected: a posted schedule month passed the line limit
/// (kernel::MAX_LINE_AMOUNT), so no journaled schedule.posted belongs to a
/// schedule with a total anywhere near EXACT_LIMIT.
pub fn amortization_plan(
total: Decimal,
start: NaiveDate,
months: u32,
) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
let too_large = || LedgerError::TooLargeToCompute(format!("a {months}-month plan of {total}"));
if total.abs() >= EXACT_LIMIT {
return Err(too_large());
}
let each = total
.checked_div(Decimal::from(months))
.ok_or_else(too_large)?
.round_dp(2);
let first = start
.with_day(1)
.ok_or(LedgerError::DateOutOfRange(start))?;
(0..months)
.map(|i| {
let month = first
.checked_add_months(Months::new(i))
.ok_or(LedgerError::DateOutOfRange(start))?;
let amount = if i + 1 == months {
each.checked_mul(Decimal::from(months - 1))
.and_then(|before| total.checked_sub(before))
.ok_or_else(too_large)?
} else {
each
};
Ok((month_end(month), amount))
})
.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 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)?;
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
// (found by the store's refusal property, 2026-10-13).
if new.total.amount > crate::kernel::MAX_LINE_AMOUNT {
return Err(LedgerError::AmountOutOfRange);
}
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
/// added on 2026-10-13 or the calendar bound added in P0: the checks every
/// journaled `schedule.registered` passed when it was written, for
/// replaying journals written before them (rules.md B-28).
pub fn validate_schedule_shape(
new: &NewSchedule,
chart: &ChartOfAccounts,
) -> Result<(), LedgerError> {
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
if new.description.trim().is_empty() {
return bad("description is required".into());
}
if new.total.amount <= Decimal::ZERO {
return bad(format!("total must be positive, got {}", new.total.amount));
}
if new.months == 0 || new.months > 600 {
return bad(format!("months must be 1 to 600, got {}", new.months));
}
let (want_balance, want_pl) = match new.kind {
ScheduleKind::PrepaidExpense => (AccountType::Asset, AccountType::Expense),
ScheduleKind::DeferredRevenue => (AccountType::Liability, AccountType::Revenue),
};
for (id, want) in [
(new.balance_account, want_balance),
(new.pl_account, want_pl),
] {
let a = chart.get(id).ok_or(LedgerError::UnknownAccount(id))?;
if a.account_type != want || a.placeholder {
return bad(format!(
"account {} must be a postable {want:?} account for a {:?} schedule",
a.code, new.kind
));
}
}
Ok(())
}
impl AmortizationSchedule {
/// The postings for one month: (debit account, credit account).
pub fn accounts(&self) -> (AccountId, AccountId) {
match self.kind {
ScheduleKind::PrepaidExpense => (self.pl_account, self.balance_account),
ScheduleKind::DeferredRevenue => (self.balance_account, self.pl_account),
}
}
/// 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> {
plan_for(self.convention, self.total, self.start, self.months)
}
/// 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, 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> {
self.plan()?
.into_iter()
.filter(|(d, _)| !self.posted.contains(d))
.try_fold(Decimal::ZERO, |sum, (_, a)| sum.checked_add(a))
.ok_or_else(|| {
LedgerError::TooLargeToCompute(format!("schedule {}'s remaining amount", self.id))
})
}
}
/// How often a recurring entry happens.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Frequency {
/// Every 7 days.
Weekly,
/// Every month, on the start date's day (clamped to month end).
Monthly,
/// Every three months.
Quarterly,
/// Every year.
Yearly,
}
impl Frequency {
/// Parse "weekly", "monthly", "quarterly", "yearly" ("annually").
pub fn parse(s: &str) -> Option<Frequency> {
match s.trim().to_lowercase().as_str() {
"weekly" => Some(Frequency::Weekly),
"monthly" => Some(Frequency::Monthly),
"quarterly" => Some(Frequency::Quarterly),
"yearly" | "annually" | "annual" => Some(Frequency::Yearly),
_ => None,
}
}
/// The `k`th occurrence from `start`, computed from the start each
/// time so month-end dates don't drift (Jan 31 → Feb 28 → Mar 31).
pub fn nth(&self, start: NaiveDate, k: u32) -> Option<NaiveDate> {
match self {
Frequency::Weekly => start.checked_add_days(Days::new(7 * k as u64)),
Frequency::Monthly => start.checked_add_months(Months::new(k)),
Frequency::Quarterly => start.checked_add_months(Months::new(3 * k)),
Frequency::Yearly => start.checked_add_months(Months::new(12 * k)),
}
}
}
/// A recurring entry: a balanced template and when it happens.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RecurringEntry {
/// Opaque handle.
pub id: RecurringId,
/// Description for each generated entry.
pub description: String,
/// The balanced lines.
pub lines: Vec<TemplateLine>,
/// How often.
pub frequency: Frequency,
/// First occurrence.
pub start: NaiveDate,
/// Last date an occurrence may fall on, if it ends.
pub end: Option<NaiveDate>,
/// Occurrences already generated, by date.
pub generated: BTreeSet<NaiveDate>,
}
/// Parameters for a new recurring entry.
#[derive(Clone, Debug)]
pub struct NewRecurring {
/// Description for each generated entry.
pub description: String,
/// The balanced lines.
pub lines: Vec<TemplateLine>,
/// How often.
pub frequency: Frequency,
/// First occurrence.
pub start: NaiveDate,
/// Optional last date.
pub end: Option<NaiveDate>,
}
/// Validate a new recurring entry: [`validate_recurring_journaled`], its
/// dates inside the calendar the books keep, and no line larger than a
/// journal line may be (P0 review round 3, S1).
pub fn validate_recurring(new: &NewRecurring, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
// The size first: a line past the limit is out of range, whatever its
// sum would be.
if new
.lines
.iter()
.any(|(_, _, money)| money.amount > crate::kernel::MAX_LINE_AMOUNT)
{
return Err(LedgerError::AmountOutOfRange);
}
validate_recurring_journaled(new, chart)?;
crate::calendar::check(new.start)?;
if let Some(end) = new.end {
crate::calendar::check(end)?;
}
Ok(())
}
/// The checks every journaled `recurring.registered` passed when it was
/// written (the rule through 2026-10-16): at least two lines, positive
/// amounts, postable accounts, balanced, and an end not before the start.
/// Replay applies exactly these (rules.md B-28). Sums too large for exact
/// arithmetic are refused (TooLargeToCompute), never a panic.
pub fn validate_recurring_journaled(
new: &NewRecurring,
chart: &ChartOfAccounts,
) -> Result<(), LedgerError> {
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
if new.description.trim().is_empty() {
return bad("description is required".into());
}
if new.lines.len() < 2 {
return bad("a recurring entry needs at least two lines".into());
}
if new.end.is_some_and(|e| e < new.start) {
return bad("end is before start".into());
}
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
for (account, side, money) in &new.lines {
let a = chart
.get(*account)
.ok_or(LedgerError::UnknownAccount(*account))?;
if a.placeholder {
return bad(format!("account {} is a placeholder", a.code));
}
if money.amount <= Decimal::ZERO {
return bad(format!(
"line amounts must be positive, got {}",
money.amount
));
}
let total = match side {
Side::Debit => &mut debits,
Side::Credit => &mut credits,
};
*total = total
.checked_add(money.amount)
.ok_or_else(|| LedgerError::TooLargeToCompute("the recurring entry's total".into()))?;
}
if debits != credits {
return bad(format!(
"lines don't balance: debits {debits}, credits {credits}"
));
}
Ok(())
}
impl RecurringEntry {
/// Occurrences on or before `through` (and the end), not yet generated.
pub fn due(&self, through: NaiveDate) -> Vec<NaiveDate> {
let last = self.end.map_or(through, |e| e.min(through));
(0..)
.map_while(|k| self.frequency.nth(self.start, k).filter(|d| *d <= last))
.filter(|d| !self.generated.contains(d))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn plans_sum_exactly_and_land_on_month_ends() {
let plan = amortization_plan(Decimal::new(100_000, 2), d(2026, 11, 15), 3).unwrap();
// 1,000.00 / 3 = 333.33, 333.33, 333.34.
assert_eq!(
plan,
[
(d(2026, 11, 30), Decimal::new(33_333, 2)),
(d(2026, 12, 31), Decimal::new(33_333, 2)),
(d(2027, 1, 31), Decimal::new(33_334, 2)),
]
);
let leap = amortization_plan(Decimal::new(1200, 0), d(2028, 2, 1), 1).unwrap();
assert_eq!(leap, [(d(2028, 2, 29), Decimal::new(1200, 0))]);
}
/// A schedule an earlier version journaled, at chrono's last month or
/// with Decimal's largest total: its plan is computed where it can be,
/// refused where it can't, never a panic (rules.md B-28).
#[test]
fn plans_at_the_edges_are_computed_or_refused() {
assert_eq!(month_end(NaiveDate::MAX), NaiveDate::MAX);
assert_eq!(month_end(d(262_142, 12, 1)), NaiveDate::MAX);
assert_eq!(month_end(d(262_142, 11, 5)), d(262_142, 11, 30));
assert_eq!(month_end(NaiveDate::MIN), d(NaiveDate::MIN.year(), 1, 31));
// The last month chrono holds is a month of the plan.
assert_eq!(
amortization_plan(Decimal::new(300, 0), d(262_142, 11, 9), 2).unwrap(),
[
(d(262_142, 11, 30), Decimal::new(150, 0)),
(NaiveDate::MAX, Decimal::new(150, 0)),
]
);
// One month further is none.
assert_eq!(
amortization_plan(Decimal::new(300, 0), d(262_142, 11, 9), 3),
Err(LedgerError::DateOutOfRange(d(262_142, 11, 9)))
);
// Past the line limit (replay takes any positive total): exact, the
// last month the remainder, right up to just under EXACT_LIMIT.
for big in ["2000000000000000.00", "99999999999999999999999999.99"] {
let big: Decimal = big.parse().unwrap();
let plan = amortization_plan(big, d(2026, 1, 1), 600).unwrap();
assert_eq!(plan.len(), 600);
assert_eq!(
plan.iter()
.try_fold(Decimal::ZERO, |s, (_, a)| s.checked_add(*a)),
Some(big)
);
}
// At EXACT_LIMIT and beyond, Decimal would round: refused, never
// rounded (INVARIANT 3), in either sign.
assert_eq!(
EXACT_LIMIT,
Decimal::from_i128_with_scale(10_i128.pow(26), 0)
);
for total in [EXACT_LIMIT, -EXACT_LIMIT, Decimal::MAX, Decimal::MIN] {
assert!(matches!(
amortization_plan(total, d(2026, 1, 1), 600),
Err(LedgerError::TooLargeToCompute(_))
));
}
let s = AmortizationSchedule {
id: 1,
description: "edge".into(),
kind: ScheduleKind::PrepaidExpense,
total: Money::new(Decimal::new(300, 0), crate::Currency::USD),
start: d(262_142, 11, 9),
months: 3,
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]
fn monthly_occurrences_do_not_drift() {
let f = Frequency::Monthly;
let dates: Vec<NaiveDate> = (0..4).map(|k| f.nth(d(2026, 1, 31), k).unwrap()).collect();
assert_eq!(
dates,
[
d(2026, 1, 31),
d(2026, 2, 28),
d(2026, 3, 31),
d(2026, 4, 30)
]
);
assert_eq!(
Frequency::Quarterly.nth(d(2026, 1, 15), 3),
Some(d(2026, 10, 15))
);
assert_eq!(
Frequency::Weekly.nth(d(2026, 1, 1), 2),
Some(d(2026, 1, 15))
);
assert_eq!(Frequency::parse("Annually"), Some(Frequency::Yearly));
}
}