check / check (push) Canceled after 0s
The refusal property now starts from real books and adds compound commands with valid arguments (book an entry, register an asset or schedule, raise an invoice or bill, close a month to members), so every command's success path is exercised. Compound commands checkpoint after each step, so the law is checked against the one command refused. STORE_CASES sets the case count (256 by default). It found two bugs: - run_recurring created drafts dated inside a closed period (B-12). generate_recurring now refuses them, atomically; the store leaves them ungenerated and books.run_recurring reports them in skipped_closed_period for a human to decide. Replay uses replay_generate_recurring so older journals still open. The store also journals before mutating the ledger here, as everywhere else. - Disposing an asset for no proceeds (scrapping it) journaled a catch-up depreciation entry and a half-built draft with a 0.00 line, then refused. Zero lines are now left out (Store::disposal_lines, shared with the MCP preview), and the whole disposal is rehearsed on a clone before anything is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
356 lines
12 KiB
Rust
356 lines
12 KiB
Rust
//! 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;
|
|
}
|
|
|
|
/// Regression (B-12, found by the store's refusal property on
|
|
/// 2026-10-13): a recurring occurrence dated in a closed period is
|
|
/// refused, leaves nothing behind, and is reported as needing a human.
|
|
#[test]
|
|
fn a_recurring_occurrence_in_a_closed_period_is_refused() {
|
|
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: None,
|
|
})
|
|
.unwrap();
|
|
let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
|
b.l.close_period(jan).unwrap();
|
|
let next = b.l.next_entry_id();
|
|
assert!(matches!(
|
|
b.l.generate_recurring(id, d(1, 31)),
|
|
Err(LedgerError::ClosedPeriod(p, _)) if p == jan
|
|
));
|
|
assert_eq!(b.l.next_entry_id(), next, "no id spent");
|
|
assert!(b.l.get_entry(next).is_none(), "no draft left behind");
|
|
assert_eq!(b.l.recurring_in_closed_periods(d(2, 28)), [(id, d(1, 31))]);
|
|
// Later occurrences still generate.
|
|
assert_eq!(b.l.generate_recurring(id, d(2, 28)).unwrap(), next);
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_length_is_one_to_six_hundred_months() {
|
|
let b = books();
|
|
let sched = |months| NewSchedule {
|
|
description: "Insurance".into(),
|
|
kind: ScheduleKind::PrepaidExpense,
|
|
total: usd("600"),
|
|
start: d(1, 1),
|
|
months,
|
|
balance_account: b.prepaid,
|
|
pl_account: b.insurance,
|
|
};
|
|
let chart = b.l.chart();
|
|
for ok in [1, 600] {
|
|
assert!(
|
|
ledger_core::schedules::validate_schedule(&sched(ok), chart).is_ok(),
|
|
"{ok} months"
|
|
);
|
|
}
|
|
for bad in [0, 601] {
|
|
assert!(
|
|
matches!(
|
|
ledger_core::schedules::validate_schedule(&sched(bad), chart),
|
|
Err(LedgerError::InvalidSchedule(_))
|
|
),
|
|
"{bad} months"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recurring_templates_take_any_number_of_lines_and_may_end_on_their_start() {
|
|
let mut b = books();
|
|
// Three balanced lines are a valid template.
|
|
b.l.register_recurring(NewRecurring {
|
|
description: "Rent and insurance".into(),
|
|
lines: vec![
|
|
(b.rent, Side::Debit, usd("2000")),
|
|
(b.insurance, Side::Debit, usd("100")),
|
|
(b.cash, Side::Credit, usd("2100")),
|
|
],
|
|
frequency: Frequency::Monthly,
|
|
start: d(1, 1),
|
|
end: None,
|
|
})
|
|
.unwrap();
|
|
// An end on the start date is one occurrence, not an error.
|
|
let once =
|
|
b.l.register_recurring(NewRecurring {
|
|
description: "One-off".into(),
|
|
lines: vec![
|
|
(b.rent, Side::Debit, usd("2000")),
|
|
(b.cash, Side::Credit, usd("2000")),
|
|
],
|
|
frequency: Frequency::Monthly,
|
|
start: d(5, 1),
|
|
end: Some(d(5, 1)),
|
|
})
|
|
.unwrap();
|
|
let r = b.l.recurring_entries().find(|r| r.id == once).unwrap();
|
|
assert_eq!(r.due(d(12, 31)), [d(5, 1)]);
|
|
// An end before the start is.
|
|
let bad = b.l.register_recurring(NewRecurring {
|
|
description: "Backwards".into(),
|
|
lines: vec![
|
|
(b.rent, Side::Debit, usd("2000")),
|
|
(b.cash, Side::Credit, usd("2000")),
|
|
],
|
|
frequency: Frequency::Monthly,
|
|
start: d(5, 2),
|
|
end: Some(d(5, 1)),
|
|
});
|
|
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn frequencies_parse_and_step_from_the_start() {
|
|
assert_eq!(Frequency::parse("weekly"), Some(Frequency::Weekly));
|
|
assert_eq!(Frequency::parse(" Monthly "), Some(Frequency::Monthly));
|
|
assert_eq!(Frequency::parse("QUARTERLY"), Some(Frequency::Quarterly));
|
|
assert_eq!(Frequency::parse("annually"), Some(Frequency::Yearly));
|
|
assert_eq!(Frequency::parse("fortnightly"), None);
|
|
// Weekly: the kth occurrence is 7k days on.
|
|
let start = d(1, 1);
|
|
for (k, want) in [(0, d(1, 1)), (1, d(1, 8)), (2, d(1, 15)), (3, d(1, 22))] {
|
|
assert_eq!(Frequency::Weekly.nth(start, k), Some(want), "week {k}");
|
|
}
|
|
assert_eq!(Frequency::Quarterly.nth(start, 2), Some(d(7, 1)));
|
|
for (k, year) in [(1, 2027), (2, 2028), (3, 2029)] {
|
|
assert_eq!(
|
|
Frequency::Yearly.nth(start, k),
|
|
NaiveDate::from_ymd_opt(year, 1, 1),
|
|
"year {k}"
|
|
);
|
|
}
|
|
}
|