Files
wanderbooks/crates/ledger-core/tests/schedules.rs
T
swaitsandClaude Opus 5.5 bf3f5b4cdb test(core): every surviving mutant killed or argued equivalent
Max-testing pass, step 8. The final mutation run left 112 survivors
(89% caught). Three agents triaged them by file, by the playbook's
table: spec the boundary then test it, a law rather than one more
pinned value, assert the real value, never weaken a test. Every expected
value is hand-computed.

- reports.rs (35): 28 killed, chiefly cash flow (inclusive start date,
  each line's sign and label, a loss on disposal moving to investing,
  financing designation), general ledger and members' equity date
  boundaries, and the balanced() checks (a real report nudged by a cent
  must stop balancing). 7 equivalent: four >/>= after an early return on
  zero; two in dead code (removed in this commit); one sign of a zero
  cleaned on output.
- ledger.rs, kernel.rs, journal.rs, money.rs (52, plus one new): 49
  killed, including every replay_* mutant at the core level (ids,
  accumulation, status, vendor tags, out-of-order refusal), FX rates,
  0%/100% deductible boundaries, a dividend sweep with no income, the
  legacy member rounding at close, a bill paid on its own date, a
  liability-account reconciliation, mixed-currency entries (via serde),
  ISO 4217 minor units, Display/Debug. 3 equivalent: zero refused before
  a </<= check; a period whose end is in a closed period refused earlier;
  columns(0).
- schedules, tax, analysis, payouts, assets, form1099 (25): 23 killed
  (schedule length 1-600, recurring end on its start, frequency parsing
  and stepping, cost of goods in tax net, a zero payout, disposal on the
  purchase date, quarter starts, labels); 2 equivalent (zero adds zero;
  days_held is provably >= 1).

Two small production cleanups the triage surfaced: kernel::columns(0)
returned a negative-zero credit that would print "-0" (regression
a_zero_movement_has_no_negative_zero_column, shown to fail before); and
cash_flow computed a book value it never used.

Spec lines added where a boundary wasn't specified (cash flow's
inclusive start and line signs, members' equity start date, payout with
no deposit, disposal on the purchase date, bill paid on its own date).
`just mutants` now runs core and store tests (the store's replay tests),
not the whole workspace, which rebuilt the viewer for every mutant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:00:45 -06:00

324 lines
10 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;
}
#[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}"
);
}
}