feat: prepaid/deferred amortization schedules and recurring entries
Amortization schedules spread a prepaid expense or deferred revenue over the months it covers, to the cent, posting on month ends; runs preview first, are idempotent, honor closed periods, and are exempt from the receipt policy like depreciation. Recurring entries generate drafts only, never posts. Six new tools (59 total); four new journal events, replayed and checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
14 files changed
+1691
-27
No files matched your search
@@ -132,6 +132,11 @@ pub enum LedgerError {
|
||||
#[error("purchase date plus useful life overflows the calendar")]
|
||||
AssetDateOverflow,
|
||||
|
||||
/// An amortization schedule or recurring entry is invalid, unknown, or
|
||||
/// already posted for that date.
|
||||
#[error("invalid schedule: {0}")]
|
||||
InvalidSchedule(String),
|
||||
|
||||
/// A tax category doesn't exist, doesn't fit the account, or has a bad
|
||||
/// deductible percent.
|
||||
#[error("invalid tax category: {0}")]
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! is no other way to change the books, which is what makes the ledger an
|
||||
//! audit trail and what will make the future oracle harness trustworthy.
|
||||
|
||||
use crate::schedules::{
|
||||
self, AmortizationSchedule, NewRecurring, NewSchedule, RecurringEntry, RecurringId, ScheduleId,
|
||||
};
|
||||
use crate::{
|
||||
Account, AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId,
|
||||
ChartOfAccounts, Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus,
|
||||
@@ -95,6 +98,12 @@ pub struct Ledger {
|
||||
next_bill_id: BillId,
|
||||
next_payroll_run_id: PayrollRunId,
|
||||
next_class_id: ClassId,
|
||||
/// Amortization schedules (prepaid expenses, deferred revenue).
|
||||
schedules: std::collections::BTreeMap<ScheduleId, AmortizationSchedule>,
|
||||
next_schedule_id: ScheduleId,
|
||||
/// Recurring entries.
|
||||
recurring: std::collections::BTreeMap<RecurringId, RecurringEntry>,
|
||||
next_recurring_id: RecurringId,
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
@@ -125,9 +134,174 @@ impl Ledger {
|
||||
next_bill_id: 1,
|
||||
next_payroll_run_id: 1,
|
||||
next_class_id: 1,
|
||||
schedules: std::collections::BTreeMap::new(),
|
||||
next_schedule_id: 1,
|
||||
recurring: std::collections::BTreeMap::new(),
|
||||
next_recurring_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Amortization schedules and recurring entries
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Register an amortization schedule. Returns its id.
|
||||
pub fn register_schedule(&mut self, new: NewSchedule) -> Result<ScheduleId, LedgerError> {
|
||||
schedules::validate_schedule(&new, &self.chart)?;
|
||||
let id = self.next_schedule_id;
|
||||
self.next_schedule_id += 1;
|
||||
self.schedules.insert(
|
||||
id,
|
||||
AmortizationSchedule {
|
||||
id,
|
||||
description: new.description,
|
||||
kind: new.kind,
|
||||
total: new.total,
|
||||
start: new.start,
|
||||
months: new.months,
|
||||
balance_account: new.balance_account,
|
||||
pl_account: new.pl_account,
|
||||
posted: Default::default(),
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The id the next schedule will receive (for journaling stores).
|
||||
pub fn next_schedule_id(&self) -> ScheduleId {
|
||||
self.next_schedule_id
|
||||
}
|
||||
|
||||
/// All schedules, by id.
|
||||
pub fn schedules(&self) -> impl Iterator<Item = &AmortizationSchedule> + '_ {
|
||||
self.schedules.values()
|
||||
}
|
||||
|
||||
/// Look up a schedule.
|
||||
pub fn get_schedule(&self, id: ScheduleId) -> Option<&AmortizationSchedule> {
|
||||
self.schedules.get(&id)
|
||||
}
|
||||
|
||||
/// The entry [`Self::post_schedule_month`] would post: (date,
|
||||
/// description, lines). Errors if the month isn't due and unposted.
|
||||
pub fn preview_schedule_month(
|
||||
&self,
|
||||
id: ScheduleId,
|
||||
month_end: NaiveDate,
|
||||
) -> Result<(NaiveDate, String, Vec<crate::schedules::TemplateLine>), LedgerError> {
|
||||
let s = self
|
||||
.schedules
|
||||
.get(&id)
|
||||
.ok_or_else(|| LedgerError::InvalidSchedule(format!("unknown schedule {id}")))?;
|
||||
let (date, amount) = schedules::amortization_plan(s.total.amount, s.start, s.months)
|
||||
.into_iter()
|
||||
.find(|(d, _)| *d == month_end)
|
||||
.ok_or_else(|| {
|
||||
LedgerError::InvalidSchedule(format!("{month_end} is not a month of schedule {id}"))
|
||||
})?;
|
||||
if s.posted.contains(&date) {
|
||||
return Err(LedgerError::InvalidSchedule(format!(
|
||||
"schedule {id} already posted {date}"
|
||||
)));
|
||||
}
|
||||
let money = Money::new(amount, s.total.currency);
|
||||
let (debit, credit) = s.accounts();
|
||||
let what = match s.kind {
|
||||
schedules::ScheduleKind::PrepaidExpense => "Prepaid expense",
|
||||
schedules::ScheduleKind::DeferredRevenue => "Deferred revenue",
|
||||
};
|
||||
Ok((
|
||||
date,
|
||||
format!("{what}: {} ({})", s.description, date.format("%Y-%m")),
|
||||
vec![(debit, Side::Debit, money), (credit, Side::Credit, money)],
|
||||
))
|
||||
}
|
||||
|
||||
/// Post one month of a schedule: the entry from
|
||||
/// [`Self::preview_schedule_month`], posted and recorded so the month
|
||||
/// never posts twice. Returns the entry id.
|
||||
pub fn post_schedule_month(
|
||||
&mut self,
|
||||
id: ScheduleId,
|
||||
month_end: NaiveDate,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
let (date, description, lines) = self.preview_schedule_month(id, month_end)?;
|
||||
if let Some(p) = self.closed_period_containing(date) {
|
||||
return Err(LedgerError::ClosedPeriod(p, date.to_string()));
|
||||
}
|
||||
let entry = self.create_entry(date, &description);
|
||||
for (account, side, money) in lines {
|
||||
self.add_line(entry, account, side, money)?;
|
||||
}
|
||||
self.post_entry(entry)?;
|
||||
self.schedules
|
||||
.get_mut(&id)
|
||||
.expect("previewed")
|
||||
.posted
|
||||
.insert(date);
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Register a recurring entry. Returns its id.
|
||||
pub fn register_recurring(&mut self, new: NewRecurring) -> Result<RecurringId, LedgerError> {
|
||||
schedules::validate_recurring(&new, &self.chart)?;
|
||||
let id = self.next_recurring_id;
|
||||
self.next_recurring_id += 1;
|
||||
self.recurring.insert(
|
||||
id,
|
||||
RecurringEntry {
|
||||
id,
|
||||
description: new.description,
|
||||
lines: new.lines,
|
||||
frequency: new.frequency,
|
||||
start: new.start,
|
||||
end: new.end,
|
||||
generated: Default::default(),
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The id the next recurring entry will receive (for journaling stores).
|
||||
pub fn next_recurring_id(&self) -> RecurringId {
|
||||
self.next_recurring_id
|
||||
}
|
||||
|
||||
/// All recurring entries, by id.
|
||||
pub fn recurring_entries(&self) -> impl Iterator<Item = &RecurringEntry> + '_ {
|
||||
self.recurring.values()
|
||||
}
|
||||
|
||||
/// Generate one occurrence of a recurring entry as a *draft* (never
|
||||
/// posted: the agent attaches the receipt and posts it). `date` must be
|
||||
/// an occurrence not yet generated. Returns the draft's id.
|
||||
pub fn generate_recurring(
|
||||
&mut self,
|
||||
id: RecurringId,
|
||||
date: NaiveDate,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
let r = self
|
||||
.recurring
|
||||
.get(&id)
|
||||
.ok_or_else(|| LedgerError::InvalidSchedule(format!("unknown recurring entry {id}")))?;
|
||||
if !r.due(date).contains(&date) {
|
||||
return Err(LedgerError::InvalidSchedule(format!(
|
||||
"{date} is not an ungenerated occurrence of recurring entry {id}"
|
||||
)));
|
||||
}
|
||||
let (description, lines) = (r.description.clone(), r.lines.clone());
|
||||
let entry = self.create_entry(date, &description);
|
||||
for (account, side, money) in lines {
|
||||
self.add_line(entry, account, side, money)?;
|
||||
}
|
||||
self.recurring
|
||||
.get_mut(&id)
|
||||
.expect("checked")
|
||||
.generated
|
||||
.insert(date);
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// The id the next account added will receive.
|
||||
///
|
||||
/// Exposed for journaling stores that record the id before mutating.
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod money;
|
||||
pub mod payroll;
|
||||
pub mod periods;
|
||||
pub mod reports;
|
||||
pub mod schedules;
|
||||
pub mod tax;
|
||||
|
||||
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
||||
@@ -52,4 +53,8 @@ pub use reports::{
|
||||
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger,
|
||||
members_equity, profit_and_loss_for_period, trial_balance_as_of,
|
||||
};
|
||||
pub use schedules::{
|
||||
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
|
||||
ScheduleId, ScheduleKind, TemplateLine,
|
||||
};
|
||||
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
|
||||
@@ -0,0 +1,341 @@
|
||||
//! 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
|
||||
//! 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).
|
||||
//! - **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 chrono::{Datelike, Days, Months, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Any date in the first month covered.
|
||||
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 already posted, as their month-end dates.
|
||||
pub posted: BTreeSet<NaiveDate>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Any date in the first month covered.
|
||||
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,
|
||||
}
|
||||
|
||||
/// The last day of `d`'s month.
|
||||
pub fn month_end(d: NaiveDate) -> NaiveDate {
|
||||
let first = NaiveDate::from_ymd_opt(d.year(), d.month(), 1).expect("valid");
|
||||
first + Months::new(1) - Days::new(1)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn amortization_plan(
|
||||
total: Decimal,
|
||||
start: NaiveDate,
|
||||
months: u32,
|
||||
) -> Vec<(NaiveDate, Decimal)> {
|
||||
let each = (total / Decimal::from(months)).round_dp(2);
|
||||
let first = NaiveDate::from_ymd_opt(start.year(), start.month(), 1).expect("valid");
|
||||
(0..months)
|
||||
.map(|i| {
|
||||
let date = month_end(first + Months::new(i));
|
||||
let amount = if i + 1 == months {
|
||||
total - each * Decimal::from(months - 1)
|
||||
} else {
|
||||
each
|
||||
};
|
||||
(date, amount)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate a new schedule against the chart.
|
||||
pub fn validate_schedule(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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Months due through `through` and not yet posted.
|
||||
pub fn due(&self, through: NaiveDate) -> Vec<(NaiveDate, Decimal)> {
|
||||
amortization_plan(self.total.amount, self.start, self.months)
|
||||
.into_iter()
|
||||
.filter(|(d, _)| *d <= through && !self.posted.contains(d))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The amount not yet recognized.
|
||||
pub fn remaining(&self) -> Decimal {
|
||||
amortization_plan(self.total.amount, self.start, self.months)
|
||||
.into_iter()
|
||||
.filter(|(d, _)| !self.posted.contains(d))
|
||||
.map(|(_, a)| a)
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: at least two lines, positive amounts,
|
||||
/// postable accounts, balanced, and an end not before the start.
|
||||
pub fn validate_recurring(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
|
||||
));
|
||||
}
|
||||
match side {
|
||||
Side::Debit => debits += money.amount,
|
||||
Side::Credit => credits += money.amount,
|
||||
}
|
||||
}
|
||||
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);
|
||||
// 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);
|
||||
assert_eq!(leap, [(d(2028, 2, 29), Decimal::new(1200, 0))]);
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user