check / check (push) Canceled after 0s
The refusal property gained the converse law (an accepted command journals something; only batch runs may find nothing to do) and checks the store's class and payroll listings. It found a schedule registered with an out-of-range total whose first month was refused after its entry was begun: the live ledger kept a draft the journal never had. - Core: compound commands (post_schedule_month, reverse_entry, close_period_to, create_invoice, create_bill, receive_payment, pay_bill, run_payroll, remit_payroll_taxes) run inside Ledger::atomically, so a refusal partway leaves the ledger exactly as it was. - Core: a schedule total no line could carry, or finer than a cent, and an asset cost beyond the line range, are refused at registration. Replay keeps older schedules (replay_register_schedule). - Store: add_line, add_line_fx, mark_cleared, set_fx_rate, post_schedule_month and close_period_to changed the live ledger before journaling; they now run on a copy, journal what it produced, then swap it in, so a failed write leaves the live books as journaled. New spec-blind suite tests/spec_store.rs (57 tests written from the store's specs alone). Its two findings, adjudicated: - A file filed twice under different names listed the first name and type for both (the projection kept them per blob). Schema v8 gives each attachment its own; older databases backfill and heal by verify. - Withdrawn attachments stay in the store's listing, marked (the MCP listing hides them): the spec and doc said "omit" and are corrected. Also removes journal::truncate_to_last_good_seq: unused, and it dropped everything after the first bad complete line, which the books now treat as corruption for a person, never repaired automatically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
410 lines
13 KiB
Rust
410 lines
13 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}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Regression (found by the store's refusal property, 2026-10-13): a
|
|
/// schedule whose total no line could carry, or finer than a cent, is
|
|
/// refused when registered, not at a month end.
|
|
#[test]
|
|
fn a_schedule_total_no_month_could_post_is_refused() {
|
|
let mut b = books();
|
|
let new = |total: &str| NewSchedule {
|
|
description: "Insurance".into(),
|
|
kind: ScheduleKind::PrepaidExpense,
|
|
total: usd(total),
|
|
start: d(1, 1),
|
|
months: 6,
|
|
balance_account: b.prepaid,
|
|
pl_account: b.insurance,
|
|
};
|
|
let huge = new("46116860184273879.03");
|
|
let sub_cent = new("120.005");
|
|
assert!(matches!(
|
|
b.l.register_schedule(huge),
|
|
Err(LedgerError::AmountOutOfRange)
|
|
));
|
|
assert!(matches!(
|
|
b.l.register_schedule(sub_cent),
|
|
Err(LedgerError::FinerThanMinorUnit { .. })
|
|
));
|
|
}
|
|
|
|
/// Regression: a schedule month refused after its entry was begun leaves
|
|
/// the ledger exactly as it was: no half-built draft, no id spent
|
|
/// (Ledger::atomically). The doomed schedule can only come from an older
|
|
/// journal now, so it is registered through the replay path.
|
|
#[test]
|
|
fn a_refused_schedule_month_leaves_nothing_behind() {
|
|
let mut b = books();
|
|
let id =
|
|
b.l.replay_register_schedule(NewSchedule {
|
|
description: "Insurance".into(),
|
|
kind: ScheduleKind::PrepaidExpense,
|
|
total: usd("46116860184273879.03"),
|
|
start: d(1, 1),
|
|
months: 6,
|
|
balance_account: b.prepaid,
|
|
pl_account: b.insurance,
|
|
})
|
|
.unwrap();
|
|
let before = b.l.clone();
|
|
assert!(b.l.post_schedule_month(id, d(1, 31)).is_err());
|
|
assert!(
|
|
b.l.differs_from(&before).is_empty(),
|
|
"{:?}",
|
|
b.l.differs_from(&before)
|
|
);
|
|
}
|