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:
swaitsandClaude Opus 5.5 committed 2026-10-11 21:09:43 -07:00
1 parent dbdc8e671f
commit ddb67a2ca2
14 files changed
+1691 -27

No files matched your search

+5
View File
@@ -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}")]
+174
View File
@@ -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.
+5
View File
@@ -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};
+341
View File
@@ -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));
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Amortization schedules and recurring entries. Numbers computed by hand.
use chrono::NaiveDate;
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
use ledger_core::{
AccountType, Currency, EntryStatus, Frequency, Ledger, LedgerError, Money, NewRecurring,
NewSchedule, ScheduleKind, Side,
};
use rust_decimal::Decimal;
use std::str::FromStr;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn usd(s: &str) -> Money {
Money::new(dec(s), Currency::USD)
}
struct Books {
l: Ledger,
cash: u64,
prepaid: u64,
deferred: u64,
insurance: u64,
revenue: u64,
rent: u64,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let cash = a("1000", "Cash", AccountType::Asset);
let prepaid = a("1300", "Prepaid Expenses", AccountType::Asset);
let deferred = a("2400", "Deferred Revenue", AccountType::Liability);
let insurance = a("6300", "Insurance", AccountType::Expense);
let revenue = a("4000", "Subscription Revenue", AccountType::Revenue);
let rent = a("6600", "Rent", AccountType::Expense);
Books {
l,
cash,
prepaid,
deferred,
insurance,
revenue,
rent,
}
}
fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) {
let e = l.create_entry(date, "x");
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
l.post_entry(e).unwrap();
}
#[test]
fn a_prepaid_expense_is_expensed_month_by_month() {
let mut b = books();
// A year of insurance, paid March 15: an asset, not an expense yet.
post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200");
let id =
b.l.register_schedule(NewSchedule {
description: "Liability insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("1200"),
start: d(3, 15),
months: 12,
balance_account: b.prepaid,
pl_account: b.insurance,
})
.unwrap();
// Through May: March, April, May, at month end, 100 each.
let due: Vec<_> = b.l.get_schedule(id).unwrap().due(d(5, 31));
assert_eq!(
due,
[
(d(3, 31), dec("100")),
(d(4, 30), dec("100")),
(d(5, 31), dec("100"))
]
);
for (month, _) in due {
b.l.post_schedule_month(id, month).unwrap();
}
assert!(
b.l.get_schedule(id).unwrap().due(d(5, 31)).is_empty(),
"idempotent"
);
assert!(matches!(
b.l.post_schedule_month(id, d(4, 30)),
Err(LedgerError::InvalidSchedule(_))
));
assert_eq!(b.l.get_schedule(id).unwrap().remaining(), dec("900"));
let bs = balance_sheet_as_of(&b.l, d(5, 31)).unwrap();
let prepaid = bs.assets.iter().find(|a| a.code == "1300").unwrap();
assert_eq!(prepaid.amount, dec("900"));
let pl = profit_and_loss_for_period(&b.l, d(3, 1), d(5, 31), None).unwrap();
assert_eq!(pl.total_expenses, dec("300"));
}
#[test]
fn deferred_revenue_is_earned_to_the_cent() {
let mut b = books();
// Three months of a subscription collected up front.
post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000");
let id =
b.l.register_schedule(NewSchedule {
description: "Quarterly plan, Acme".into(),
kind: ScheduleKind::DeferredRevenue,
total: usd("1000"),
start: d(1, 5),
months: 3,
balance_account: b.deferred,
pl_account: b.revenue,
})
.unwrap();
let plan: Vec<Decimal> =
b.l.get_schedule(id)
.unwrap()
.due(d(12, 31))
.into_iter()
.map(|(_, a)| a)
.collect();
assert_eq!(plan, [dec("333.33"), dec("333.33"), dec("333.34")]);
for (m, _) in b.l.get_schedule(id).unwrap().due(d(12, 31)) {
b.l.post_schedule_month(id, m).unwrap();
}
let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
assert_eq!(pl.total_revenue, dec("1000"));
let bs = balance_sheet_as_of(&b.l, d(3, 31)).unwrap();
assert!(bs.liabilities.is_empty(), "fully earned");
}
#[test]
fn schedules_respect_closed_periods_and_account_types() {
let mut b = books();
let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
b.l.close_period(jan).unwrap();
let id =
b.l.register_schedule(NewSchedule {
description: "Insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("120"),
start: d(1, 1),
months: 12,
balance_account: b.prepaid,
pl_account: b.insurance,
})
.unwrap();
assert!(matches!(
b.l.post_schedule_month(id, d(1, 31)),
Err(LedgerError::ClosedPeriod(..))
));
b.l.post_schedule_month(id, d(2, 28)).unwrap();
// A prepaid schedule needs an asset and an expense, not a liability.
let bad = b.l.register_schedule(NewSchedule {
description: "x".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("120"),
start: d(1, 1),
months: 12,
balance_account: b.deferred,
pl_account: b.insurance,
});
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
}
#[test]
fn recurring_entries_generate_drafts_never_posts() {
let mut b = books();
let id =
b.l.register_recurring(NewRecurring {
description: "Office rent".into(),
lines: vec![
(b.rent, Side::Debit, usd("2000")),
(b.cash, Side::Credit, usd("2000")),
],
frequency: Frequency::Monthly,
start: d(1, 31),
end: Some(d(4, 30)),
})
.unwrap();
// Through March: Jan 31, Feb 28, Mar 31 (month ends, no drift).
let due = b.l.recurring_entries().next().unwrap().due(d(3, 31));
assert_eq!(due, [d(1, 31), d(2, 28), d(3, 31)]);
let drafts: Vec<_> = due
.iter()
.map(|date| b.l.generate_recurring(id, *date).unwrap())
.collect();
for e in &drafts {
assert_eq!(b.l.get_entry(*e).unwrap().status(), EntryStatus::Draft);
}
// Drafts don't touch the books until posted.
let q1 = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
assert_eq!(q1.total_expenses, Decimal::ZERO);
b.l.post_entry(drafts[1]).unwrap();
let feb = profit_and_loss_for_period(&b.l, d(2, 1), d(2, 28), None).unwrap();
assert_eq!(feb.total_expenses, dec("2000"));
// Never twice; nothing after the end date.
assert!(matches!(
b.l.generate_recurring(id, d(2, 28)),
Err(LedgerError::InvalidSchedule(_))
));
assert_eq!(
b.l.recurring_entries().next().unwrap().due(d(12, 31)),
[d(4, 30)]
);
// Templates must balance.
let bad = b.l.register_recurring(NewRecurring {
description: "x".into(),
lines: vec![
(b.rent, Side::Debit, usd("2000")),
(b.cash, Side::Credit, usd("1999")),
],
frequency: Frequency::Monthly,
start: d(1, 1),
end: None,
});
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
let _ = b.revenue;
}