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;
}
+253 -1
View File
@@ -23,7 +23,7 @@ use rust_decimal::Decimal;
use std::sync::{Arc, Mutex};
/// The agent operating contract, delivered via initialize.
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.";
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.";
/// Max receipt/document upload: 25 MB.
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
@@ -502,6 +502,48 @@ fn statement_range(
Ok((start, end))
}
fn schedule_out(ledger: &Ledger, s: &ledger_core::AmortizationSchedule) -> ScheduleOut {
let code = |id| {
ledger
.chart()
.get(id)
.map(|a| a.code.clone())
.unwrap_or_default()
};
ScheduleOut {
schedule_id: s.id,
description: s.description.clone(),
kind: match s.kind {
ledger_core::ScheduleKind::PrepaidExpense => "prepaid_expense",
ledger_core::ScheduleKind::DeferredRevenue => "deferred_revenue",
}
.to_string(),
balance_account: code(s.balance_account),
pl_account: code(s.pl_account),
total: money(s.total.amount),
remaining: money(s.remaining()),
months: ledger_core::schedules::amortization_plan(s.total.amount, s.start, s.months)
.into_iter()
.map(|(d, a)| ScheduleMonthOut {
date: d.to_string(),
amount: money(a),
posted: s.posted.contains(&d),
})
.collect(),
}
}
fn recurring_out(r: &ledger_core::RecurringEntry) -> RecurringOut {
RecurringOut {
recurring_id: r.id,
description: r.description.clone(),
frequency: format!("{:?}", r.frequency).to_lowercase(),
start: r.start.to_string(),
end: r.end.map(|e| e.to_string()),
generated: r.generated.iter().map(|d| d.to_string()).collect(),
}
}
/// Profit and loss for a range (see [`statement_range`]), optionally one class.
fn pnl_statement(
ledger: &Ledger,
@@ -1416,6 +1458,216 @@ impl BooksServer {
Ok(Json(ImportBankCsvOut { imported, count }))
}
#[tool(
name = "books.create_schedule",
description = "Spread a prepaid expense (asset expensed monthly, e.g. a year of insurance paid up front) or deferred revenue (liability earned monthly, e.g. an annual subscription collected up front) evenly over the months it covers, to the cent (the last month absorbs rounding). Record the original payment first (Dr prepaid / Cr cash, with its receipt; or Dr cash / Cr deferred revenue). Returns the monthly plan; post it with books.run_schedules."
)]
async fn create_schedule(
&self,
params: Parameters<CreateScheduleParams>,
) -> Result<Json<ScheduleOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let kind = match p.kind.as_str() {
"prepaid_expense" => ledger_core::ScheduleKind::PrepaidExpense,
"deferred_revenue" => ledger_core::ScheduleKind::DeferredRevenue,
other => {
return Err(format!(
"kind {other:?}: expected prepaid_expense or deferred_revenue"
));
}
};
let currency = store.ledger().base_currency();
let balance_account = resolve_account(&store, &p.balance_account)?;
let pl_account = resolve_account(&store, &p.pl_account)?;
let id = store
.register_schedule(ledger_core::NewSchedule {
description: p.description,
kind,
total: Money::new(parse_amount(&p.total)?, currency),
start: parse_date(&p.start)?,
months: p.months,
balance_account,
pl_account,
})
.map_err(|e| e.to_string())?;
Ok(Json(schedule_out(
store.ledger(),
store.ledger().get_schedule(id).expect("registered"),
)))
}
#[tool(
name = "books.list_schedules",
description = "Amortization schedules (prepaid expenses, deferred revenue): each month's amount, what has posted, and what remains.",
annotations(read_only_hint = true)
)]
async fn list_schedules(&self) -> Result<Json<ListSchedulesOut>, String> {
let store = self.lock()?;
let ledger = store.ledger();
Ok(Json(ListSchedulesOut {
schedules: ledger
.schedules()
.map(|s| schedule_out(ledger, s))
.collect(),
}))
}
#[tool(
name = "books.run_schedules",
description = "Post every amortization schedule's months ending on or before `through` (default today) that haven't posted. Idempotent: a month posts once. Without confirmed=true, returns the postings it would make. Exempt from the receipt policy: the schedule is the evidence (the original payment carries the receipt)."
)]
async fn run_schedules(
&self,
params: Parameters<RunSchedulesParams>,
) -> Result<Json<RunSchedulesOut>, String> {
let p = params.0;
let through = match &p.through {
Some(s) => parse_date(s)?,
None => today(),
};
let mut store = self.lock()?;
let mut due: Vec<(NaiveDate, u64, Decimal)> = store
.ledger()
.schedules()
.flat_map(|s| s.due(through).into_iter().map(move |(d, a)| (d, s.id, a)))
.collect();
due.sort();
let describe = |store: &Store, id: u64, date: NaiveDate| -> Result<String, String> {
Ok(store
.ledger()
.preview_schedule_month(id, date)
.map_err(|e| e.to_string())?
.1)
};
if !p.confirmed {
let postings = due
.into_iter()
.map(|(date, id, amount)| {
Ok(SchedulePostingOut {
schedule_id: id,
date: date.to_string(),
description: describe(&store, id, date)?,
amount: money(amount),
entry_id: None,
})
})
.collect::<Result<_, String>>()?;
return Ok(Json(RunSchedulesOut {
status: "preview".to_string(),
postings,
}));
}
let mut postings = Vec::new();
for (date, id, amount) in due {
let description = describe(&store, id, date)?;
let entry = store
.post_schedule_month(id, date)
.map_err(|e| e.to_string())?;
postings.push(SchedulePostingOut {
schedule_id: id,
date: date.to_string(),
description,
amount: money(amount),
entry_id: Some(entry),
});
}
Ok(Json(RunSchedulesOut {
status: "posted".to_string(),
postings,
}))
}
#[tool(
name = "books.create_recurring",
description = "A recurring entry (rent, a subscription, a loan payment): balanced lines and a frequency (weekly, monthly, quarterly, yearly) from a start date, optionally ending. books.run_recurring turns due occurrences into drafts, never posted entries: attach each actual receipt and post through the normal preview."
)]
async fn create_recurring(
&self,
params: Parameters<CreateRecurringParams>,
) -> Result<Json<RecurringOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let currency = store.ledger().base_currency();
let frequency = ledger_core::Frequency::parse(&p.frequency).ok_or_else(|| {
format!(
"frequency {:?}: expected weekly, monthly, quarterly, or yearly",
p.frequency
)
})?;
let lines = p
.lines
.iter()
.map(|l| {
Ok((
resolve_account(&store, &l.account)?,
parse_side(&l.side)?,
Money::new(parse_amount(&l.amount)?, currency),
))
})
.collect::<Result<Vec<_>, String>>()?;
let id = store
.register_recurring(ledger_core::NewRecurring {
description: p.description,
lines,
frequency,
start: parse_date(&p.start)?,
end: match &p.end {
Some(e) => Some(parse_date(e)?),
None => None,
},
})
.map_err(|e| e.to_string())?;
let r = store
.ledger()
.recurring_entries()
.find(|r| r.id == id)
.expect("registered");
Ok(Json(recurring_out(r)))
}
#[tool(
name = "books.list_recurring",
description = "Recurring entries and the occurrences generated so far.",
annotations(read_only_hint = true)
)]
async fn list_recurring(&self) -> Result<Json<ListRecurringOut>, String> {
let store = self.lock()?;
Ok(Json(ListRecurringOut {
recurring: store
.ledger()
.recurring_entries()
.map(recurring_out)
.collect(),
}))
}
#[tool(
name = "books.run_recurring",
description = "Turn every recurring entry's occurrences on or before `through` (default today) into drafts. Idempotent: an occurrence generates once. Each draft then goes through the normal flow: attach the receipt, preview, post."
)]
async fn run_recurring(
&self,
params: Parameters<RunRecurringParams>,
) -> Result<Json<RunRecurringOut>, String> {
let through = match &params.0.through {
Some(s) => parse_date(s)?,
None => today(),
};
let mut store = self.lock()?;
let drafts = store
.run_recurring(through)
.map_err(|e| e.to_string())?
.into_iter()
.map(|(id, date, entry)| RecurringDraftOut {
recurring_id: id,
date: date.to_string(),
entry_id: entry,
})
.collect();
Ok(Json(RunRecurringOut { drafts }))
}
#[tool(
name = "books.chart_template",
description = "A starter chart of accounts for a kind of business (sole proprietorship, partnership or multi-member LLC, S corporation, C corporation): the right equity and owner-pay accounts (draws; per-partner capital and distributions plus guaranteed payments; officer compensation and distributions; dividends and income tax), common income and expense accounts with tax categories assigned, and the wanderbooks.toml settings that go with it. A proposal: review it with the human, adjust, then books.import_chart_csv.",
+169
View File
@@ -54,6 +54,175 @@ pub struct ChartAccount {
pub deductible_percent: Option<String>,
}
/// Parameters for `books.create_schedule`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CreateScheduleParams {
/// "prepaid_expense" (asset expensed monthly) or "deferred_revenue"
/// (liability earned monthly).
pub kind: String,
/// What it is, e.g. "2027 liability insurance".
pub description: String,
/// Amount to spread, decimal string.
pub total: String,
/// Any date in the first month covered, YYYY-MM-DD.
pub start: String,
/// Months covered.
pub months: u32,
/// Prepaid (asset) or deferred revenue (liability) account code.
pub balance_account: String,
/// Expense or revenue account code.
pub pl_account: String,
}
/// One month of a schedule.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ScheduleMonthOut {
/// Month-end date the posting lands on.
pub date: String,
/// Amount, decimal string.
pub amount: String,
/// True once posted.
pub posted: bool,
}
/// An amortization schedule.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ScheduleOut {
/// Schedule id.
pub schedule_id: u64,
/// Description.
pub description: String,
/// "prepaid_expense" or "deferred_revenue".
pub kind: String,
/// Balance-sheet account code.
pub balance_account: String,
/// P&L account code.
pub pl_account: String,
/// Total, decimal string.
pub total: String,
/// Not yet recognized, decimal string.
pub remaining: String,
/// Every month.
pub months: Vec<ScheduleMonthOut>,
}
/// Result of `books.list_schedules`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListSchedulesOut {
/// Schedules.
pub schedules: Vec<ScheduleOut>,
}
/// Parameters for `books.run_schedules`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RunSchedulesParams {
/// Post months ending on or before this date (default today).
pub through: Option<String>,
/// Without true, a preview: nothing posts.
#[serde(default)]
pub confirmed: bool,
}
/// One posting from a schedule run.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SchedulePostingOut {
/// Schedule id.
pub schedule_id: u64,
/// Month-end date.
pub date: String,
/// Entry description.
pub description: String,
/// Amount, decimal string.
pub amount: String,
/// The posted entry (absent in a preview).
pub entry_id: Option<u64>,
}
/// Result of `books.run_schedules`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RunSchedulesOut {
/// "preview" or "posted".
pub status: String,
/// Postings due (preview) or made.
pub postings: Vec<SchedulePostingOut>,
}
/// One line of a recurring entry.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RecurringLineParam {
/// Account code.
pub account: String,
/// "debit" or "credit".
pub side: String,
/// Decimal string.
pub amount: String,
}
/// Parameters for `books.create_recurring`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct CreateRecurringParams {
/// Description for each generated draft.
pub description: String,
/// "weekly", "monthly", "quarterly", or "yearly".
pub frequency: String,
/// First occurrence, YYYY-MM-DD (monthly and longer repeat on this day,
/// clamped to month end).
pub start: String,
/// Last date an occurrence may fall on, YYYY-MM-DD; omit for none.
pub end: Option<String>,
/// Balanced lines.
pub lines: Vec<RecurringLineParam>,
}
/// A recurring entry.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RecurringOut {
/// Recurring entry id.
pub recurring_id: u64,
/// Description.
pub description: String,
/// Frequency.
pub frequency: String,
/// First occurrence.
pub start: String,
/// Last date, if it ends.
pub end: Option<String>,
/// Occurrences generated so far.
pub generated: Vec<String>,
}
/// Result of `books.list_recurring`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListRecurringOut {
/// Recurring entries.
pub recurring: Vec<RecurringOut>,
}
/// Parameters for `books.run_recurring`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RunRecurringParams {
/// Generate occurrences on or before this date (default today).
pub through: Option<String>,
}
/// One draft from a recurring run.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RecurringDraftOut {
/// Recurring entry id.
pub recurring_id: u64,
/// Occurrence date.
pub date: String,
/// The draft entry to review, attach a receipt to, and post.
pub entry_id: u64,
}
/// Result of `books.run_recurring`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RunRecurringOut {
/// Drafts created.
pub drafts: Vec<RecurringDraftOut>,
}
/// Parameters for `books.chart_template`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ChartTemplateParams {
+119 -1
View File
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: 23 narrow tools, chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 53, "expected 53 tools, got {}", tools.len());
assert_eq!(tools.len(), 59, "expected 59 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
@@ -1232,3 +1232,121 @@ fn mcp_partnership_from_template_over_stdio() {
]
);
}
#[test]
fn mcp_schedules_and_recurring_over_stdio() {
let dir = test_dir("schedules");
let init = |mcp: &mut Mcp| {
mcp.request(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
}),
);
mcp.notify("notifications/initialized");
};
let mut mcp = Mcp::spawn(&dir);
init(&mut mcp);
for (code, name, typ) in [
("1000", "Bank", "asset"),
("1300", "Prepaid Expenses", "asset"),
("6300", "Insurance", "expense"),
("6600", "Rent", "expense"),
] {
mcp.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": typ}),
);
}
// 1,000.00 of insurance over 3 months: 333.33, 333.33, 333.34.
let s = mcp.call(
"books.create_schedule",
json!({
"kind": "prepaid_expense",
"description": "Liability insurance",
"total": "1000.00",
"start": "2026-11-15",
"months": 3,
"balance_account": "1300",
"pl_account": "6300",
}),
);
let amounts: Vec<&str> = s["months"]
.as_array()
.unwrap()
.iter()
.map(|m| m["amount"].as_str().unwrap())
.collect();
assert_eq!(amounts, ["333.33", "333.33", "333.34"]);
let err = mcp.call_err(
"books.create_schedule",
json!({
"kind": "prepaid_expense", "description": "x", "total": "10",
"start": "2026-11-01", "months": 2,
"balance_account": "6300", "pl_account": "6300",
}),
);
assert!(err.contains("Asset"), "{err}");
// Preview posts nothing; confirmed posts November and December.
let preview = mcp.call("books.run_schedules", json!({"through": "2026-12-31"}));
assert_eq!(preview["status"], "preview");
assert_eq!(preview["postings"].as_array().unwrap().len(), 2);
let pl = mcp.call(
"books.profit_and_loss",
json!({"start": "2026-11-01", "end": "2026-12-31"}),
);
assert_eq!(pl["expenses"], "0.00");
let run = mcp.call(
"books.run_schedules",
json!({"through": "2026-12-31", "confirmed": true}),
);
assert_eq!(run["status"], "posted");
assert_eq!(run["postings"][1]["date"], "2026-12-31");
let again = mcp.call(
"books.run_schedules",
json!({"through": "2026-12-31", "confirmed": true}),
);
assert!(
again["postings"].as_array().unwrap().is_empty(),
"idempotent"
);
// Monthly rent: drafts only, never posted.
mcp.call(
"books.create_recurring",
json!({
"description": "Office rent",
"frequency": "monthly",
"start": "2026-11-01",
"lines": [
{"account": "6600", "side": "debit", "amount": "2000.00"},
{"account": "1000", "side": "credit", "amount": "2000.00"},
],
}),
);
let drafts = mcp.call("books.run_recurring", json!({"through": "2026-12-15"}));
assert_eq!(drafts["drafts"].as_array().unwrap().len(), 2);
let pl = mcp.call(
"books.profit_and_loss",
json!({"start": "2026-11-01", "end": "2026-12-31"}),
);
assert_eq!(pl["expenses"], "666.66", "rent drafts don't post");
drop(mcp);
// Restart: everything replays from the journal.
let mut mcp = Mcp::spawn(&dir);
init(&mut mcp);
let list = mcp.call("books.list_schedules", json!({}));
assert_eq!(list["schedules"][0]["remaining"], "333.34");
let rec = mcp.call("books.list_recurring", json!({}));
assert_eq!(
rec["recurring"][0]["generated"],
json!(["2026-11-01", "2026-12-01"])
);
let more = mcp.call("books.run_recurring", json!({"through": "2026-12-31"}));
assert!(more["drafts"].as_array().unwrap().is_empty(), "idempotent");
}
+281 -23
View File
@@ -1795,6 +1795,139 @@ impl Store {
Ok((id, sha, dedup))
}
/// Register an amortization schedule. Journaled as `schedule.registered`.
pub fn register_schedule(
&mut self,
new: ledger_core::NewSchedule,
) -> Result<ledger_core::ScheduleId, StoreError> {
ledger_core::schedules::validate_schedule(&new, self.ledger.chart())?;
let id = self.ledger.next_schedule_id();
let line = self.append_journal(
"schedule.registered",
json!({
"id": id,
"description": new.description,
"kind": serde_json::to_value(new.kind)?,
"total": {"amount": new.total.amount.to_string(), "currency": new.total.currency.code()},
"start": new.start.to_string(),
"months": new.months,
"balance_account": new.balance_account,
"pl_account": new.pl_account,
}),
)?;
let got = self.ledger.register_schedule(new)?;
debug_assert_eq!(got, id);
apply_line_to_sqlite(&self.conn, &line)?;
Ok(id)
}
/// Post one month of a schedule. Journaled as `schedule.posted` with the
/// whole entry, so a month is never half-posted or posted twice; replay
/// re-derives the entry and checks it line by line.
pub fn post_schedule_month(
&mut self,
id: ledger_core::ScheduleId,
month_end: NaiveDate,
) -> Result<EntryId, StoreError> {
// Pure pre-validation, so the mutation below cannot fail.
let (date, _, _) = self.ledger.preview_schedule_month(id, month_end)?;
if let Some(p) = self.ledger.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(p, date.to_string()).into());
}
let entry = self.ledger.post_schedule_month(id, month_end)?;
let line = self.append_journal(
"schedule.posted",
json!({
"schedule_id": id,
"month": date.to_string(),
"entry": entry_detail_json(&self.ledger, entry),
}),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(entry)
}
/// Post every schedule's due months through `through`, oldest first.
/// Returns (schedule, month, entry) for each.
pub fn run_schedules(
&mut self,
through: NaiveDate,
) -> Result<Vec<(ledger_core::ScheduleId, NaiveDate, EntryId)>, StoreError> {
let mut due: Vec<(NaiveDate, ledger_core::ScheduleId)> = self
.ledger
.schedules()
.flat_map(|s| s.due(through).into_iter().map(move |(d, _)| (d, s.id)))
.collect();
due.sort();
let mut out = Vec::with_capacity(due.len());
for (month, id) in due {
out.push((id, month, self.post_schedule_month(id, month)?));
}
Ok(out)
}
/// Register a recurring entry. Journaled as `recurring.registered`.
pub fn register_recurring(
&mut self,
new: ledger_core::NewRecurring,
) -> Result<ledger_core::RecurringId, StoreError> {
ledger_core::schedules::validate_recurring(&new, self.ledger.chart())?;
let id = self.ledger.next_recurring_id();
let lines: Vec<Value> = new
.lines
.iter()
.map(|(account, side, money)| {
json!({
"account": account,
"side": if *side == Side::Debit { "debit" } else { "credit" },
"amount": money.amount.to_string(),
"currency": money.currency.code(),
})
})
.collect();
let line = self.append_journal(
"recurring.registered",
json!({
"id": id,
"description": new.description,
"frequency": serde_json::to_value(new.frequency)?,
"start": new.start.to_string(),
"end": new.end.map(|e| e.to_string()),
"lines": lines,
}),
)?;
let got = self.ledger.register_recurring(new)?;
debug_assert_eq!(got, id);
apply_line_to_sqlite(&self.conn, &line)?;
Ok(id)
}
/// Generate every recurring entry's due occurrences through `through`
/// as drafts. Each journaled as `recurring.generated`. Returns
/// (recurring entry, date, draft) for each.
pub fn run_recurring(
&mut self,
through: NaiveDate,
) -> Result<Vec<(ledger_core::RecurringId, NaiveDate, EntryId)>, StoreError> {
let mut due: Vec<(NaiveDate, ledger_core::RecurringId)> = self
.ledger
.recurring_entries()
.flat_map(|r| r.due(through).into_iter().map(move |d| (d, r.id)))
.collect();
due.sort();
let mut out = Vec::with_capacity(due.len());
for (date, id) in due {
let entry = self.ledger.generate_recurring(id, date)?;
let line = self.append_journal(
"recurring.generated",
json!({"recurring_id": id, "date": date.to_string(), "entry_id": entry}),
)?;
apply_line_to_sqlite(&self.conn, &line)?;
out.push((id, date, entry));
}
Ok(out)
}
/// Set (or clear) an account's tax category and deductible percent.
/// Journaled as `account.tax_set`.
pub fn set_account_tax(
@@ -2742,6 +2875,79 @@ fn replay_line_into_ledger(
)));
}
}
"schedule.registered" => {
let want = get_u64(p, "id")?;
let got = ledger.register_schedule(ledger_core::NewSchedule {
description: get_str(p, "description")?.to_string(),
kind: serde_json::from_value(p["kind"].clone())?,
total: parse_money(p, "total")?,
start: parse_date(get_str(p, "start")?)?,
months: get_u64(p, "months")? as u32,
balance_account: get_u64(p, "balance_account")?,
pl_account: get_u64(p, "pl_account")?,
})?;
if got != want {
return Err(StoreError::ReplayMismatch(format!(
"schedule id: journal says {want}, replay got {got}"
)));
}
}
"schedule.posted" => {
let got = ledger.post_schedule_month(
get_u64(p, "schedule_id")?,
parse_date(get_str(p, "month")?)?,
)?;
check_replayed_entry(ledger, got, &p["entry"])?;
}
"recurring.registered" => {
let want = get_u64(p, "id")?;
let lines = p["lines"]
.as_array()
.ok_or_else(|| StoreError::JournalCorrupt("recurring lines".into()))?
.iter()
.map(|l| {
Ok((
get_u64(l, "account")?,
parse_side(get_str(l, "side")?)?,
Money::new(
get_str(l, "amount")?.parse().map_err(|_| {
StoreError::JournalCorrupt("recurring amount".into())
})?,
Currency::new(get_str(l, "currency")?).map_err(|_| {
StoreError::JournalCorrupt("recurring currency".into())
})?,
),
))
})
.collect::<Result<Vec<_>, StoreError>>()?;
let got = ledger.register_recurring(ledger_core::NewRecurring {
description: get_str(p, "description")?.to_string(),
lines,
frequency: serde_json::from_value(p["frequency"].clone())?,
start: parse_date(get_str(p, "start")?)?,
end: match p.get("end").and_then(|v| v.as_str()) {
Some(e) => Some(parse_date(e)?),
None => None,
},
})?;
if got != want {
return Err(StoreError::ReplayMismatch(format!(
"recurring id: journal says {want}, replay got {got}"
)));
}
}
"recurring.generated" => {
let want = get_u64(p, "entry_id")?;
let got = ledger.generate_recurring(
get_u64(p, "recurring_id")?,
parse_date(get_str(p, "date")?)?,
)?;
if got != want {
return Err(StoreError::ReplayMismatch(format!(
"recurring draft: journal says {want}, replay got {got}"
)));
}
}
"account.tax_set" => {
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
Some(s) => Some(s.parse().map_err(|_| {
@@ -3034,6 +3240,76 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
))
}
/// Record a posted entry that rides inside another event's payload (a
/// closing entry, a schedule posting) in the projection, like any posted
/// entry.
fn project_posted_entry(conn: &Connection, e: &Value) -> Result<(), StoreError> {
let eid = get_u64(e, "id")? as i64;
conn.execute(
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)",
params![eid, get_str(e, "date")?, get_str(e, "description")?],
)?;
for l in e
.get("lines")
.and_then(|v| v.as_array())
.ok_or_else(|| StoreError::JournalCorrupt("posted entry without lines".into()))?
{
conn.execute(
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount, class_id) VALUES (?,?,?,?,?,?,?,?)",
params![
eid,
get_u64(l, "account")? as i64,
get_str(l, "side")?,
get_str(l, "amount")?,
get_str(l, "currency")?,
l.get("foreign_currency").and_then(|v| v.as_str()),
l.get("foreign_amount").and_then(|v| v.as_str()),
l.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64),
],
)?;
}
Ok(())
}
/// The full detail of a posted entry, for events that carry one.
fn entry_detail_json(ledger: &Ledger, id: EntryId) -> Value {
let e = ledger.get_entry(id).expect("entry just posted");
json!({
"id": id,
"date": e.date().to_string(),
"description": e.description(),
"lines": e.lines().iter().map(journal_line_json).collect::<Vec<_>>(),
})
}
/// On replay, an entry re-derived from master data must match the journal
/// line for line.
fn check_replayed_entry(
ledger: &Ledger,
got: EntryId,
journaled: &Value,
) -> Result<(), StoreError> {
let want = get_u64(journaled, "id")?;
if got != want {
return Err(StoreError::ReplayMismatch(format!(
"entry id: journal says {want}, replay got {got}"
)));
}
let lines: Vec<Value> = ledger
.get_entry(got)
.expect("replay posted it")
.lines()
.iter()
.map(journal_line_json)
.collect();
if journaled["lines"].as_array() != Some(&lines) {
return Err(StoreError::ReplayMismatch(format!(
"entry {got}: replayed lines differ from the journal"
)));
}
Ok(())
}
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
let p = &line.payload;
match line.line_type.as_str() {
@@ -3158,6 +3434,10 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
],
)?;
}
"schedule.registered" | "recurring.registered" | "recurring.generated" => {
// Master data and drafts: the ledger holds them; no report rows.
}
"schedule.posted" => project_posted_entry(conn, &p["entry"])?,
"receipt.unlinked" => {
conn.execute(
"UPDATE attachments SET entry_id = NULL WHERE id = ?",
@@ -3237,29 +3517,7 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
// posted entry so the projection stays complete, then mark the
// period row closed.
if !p.get("entry").map(|v| v.is_null()).unwrap_or(true) {
let e = &p["entry"];
let eid = get_u64(e, "id")? as i64;
conn.execute(
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)",
params![eid, get_str(e, "date")?, get_str(e, "description")?],
)?;
for l in e.get("lines").and_then(|v| v.as_array()).ok_or_else(|| {
StoreError::JournalCorrupt("period.closed entry without lines".into())
})? {
conn.execute(
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount, class_id) VALUES (?,?,?,?,?,?,?,?)",
params![
eid,
get_u64(l, "account")? as i64,
get_str(l, "side")?,
get_str(l, "amount")?,
get_str(l, "currency")?,
l.get("foreign_currency").and_then(|v| v.as_str()),
l.get("foreign_amount").and_then(|v| v.as_str()),
l.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64),
],
)?;
}
project_posted_entry(conn, &p["entry"])?;
}
conn.execute(
"UPDATE periods SET status = 'closed', closing_entry = ? WHERE id = ?",
+75
View File
@@ -537,3 +537,78 @@ fn a_close_into_members_capital_replays_exactly() {
assert_eq!(balances(&store), want);
store.verify().unwrap();
}
#[test]
fn schedules_and_recurring_entries_replay_exactly() {
let dir = test_dir("schedules");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let prepaid = store
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
.unwrap();
let ins = store
.add_account("6300", "Insurance", AccountType::Expense, None, false)
.unwrap();
let rent = store
.add_account("6600", "Rent", AccountType::Expense, None, false)
.unwrap();
let date = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let usd_s = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
store
.register_schedule(ledger_core::NewSchedule {
description: "Insurance".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: usd_s("1000"),
start: date(1, 1),
months: 3,
balance_account: prepaid,
pl_account: ins,
})
.unwrap();
store
.register_recurring(ledger_core::NewRecurring {
description: "Rent".into(),
lines: vec![
(rent, Side::Debit, usd_s("500")),
(cash, Side::Credit, usd_s("500")),
],
frequency: ledger_core::Frequency::Monthly,
start: date(1, 1),
end: None,
})
.unwrap();
// 333.33, 333.33 through February; rent drafts for Jan 1 and Feb 1.
let posted = store.run_schedules(date(2, 28)).unwrap();
assert_eq!(posted.len(), 2);
let drafts = store.run_recurring(date(2, 28)).unwrap();
assert_eq!(
drafts.iter().map(|d| d.1).collect::<Vec<_>>(),
[date(1, 1), date(2, 1)]
);
store.post_entry(drafts[0].2).unwrap();
let state = |s: &Store| {
(
s.ledger().account_balance(ins).unwrap(),
s.ledger().account_balance(rent).unwrap(),
s.ledger().get_entry(drafts[1].2).unwrap().status(),
)
};
let want = (
Decimal::new(66666, 2),
Decimal::new(500, 0),
ledger_core::EntryStatus::Draft,
);
assert_eq!(state(&store), want);
drop(store);
let mut store = Store::open(&dir).unwrap();
assert_eq!(state(&store), want);
store.rebuild().unwrap();
assert_eq!(state(&store), want);
store.verify().unwrap();
// Idempotent after replay: nothing new is due through February.
assert!(store.run_schedules(date(2, 28)).unwrap().is_empty());
assert!(store.run_recurring(date(2, 28)).unwrap().is_empty());
}