P3: depreciation straight-line by days through the final month, schedules by day (defects 22, 9, D-37)
The final month takes what the months before leave, spread over its days; a disposed asset stops on its disposal day. A skipped month is caught up in the month the run is made; a reversed run un-posts its month. New schedules spread by day; equal-months schedules must start on the 1st. Amounts round to the currency's own minor unit. Kani and Lean proofs; an exhaustive search over runs, reversals and disposals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
69 files changed
+10529
-841
No files matched your search
@@ -47,6 +47,8 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
PARTY_CREDITORS,
|
||||
PAYMENT_INSTRUMENTS,
|
||||
VENDOR_1099_DECISIONS,
|
||||
MONTH_REPOSTING,
|
||||
SCHEDULE_CONVENTION,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -88,6 +90,17 @@ pub const PAYMENT_INSTRUMENTS: &str = "payment_instruments";
|
||||
/// (`vendor.w9_flag_set`).
|
||||
pub const VENDOR_1099_DECISIONS: &str = "vendor_1099_decisions";
|
||||
|
||||
/// Feature: a schedule month or a depreciation period posted again after a
|
||||
/// reversal un-posted it. An older version would refuse the second
|
||||
/// `schedule.posted` as posted twice, and its projection would ignore the
|
||||
/// second `asset.depreciation_posted` (P3 depreciation review R3).
|
||||
pub const MONTH_REPOSTING: &str = "month_reposting";
|
||||
|
||||
/// Feature: `schedule.registered` carries `convention` (`daily` or
|
||||
/// `monthly_even`). An older version would read a daily schedule as equal
|
||||
/// months (P3 defect 9, plan/served.md D-11).
|
||||
pub const SCHEDULE_CONVENTION: &str = "schedule_convention";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -525,6 +538,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// P3's features (schedule_convention, month_reposting): this version
|
||||
/// reads them; a version before them (KNOWN_FEATURES empty, as through
|
||||
/// P2) stops at their declaration with NewerJournal, never misreading a
|
||||
/// daily schedule as equal months or a second run of a month as none.
|
||||
#[test]
|
||||
fn an_older_reader_stops_at_p3s_features() {
|
||||
for f in [SCHEDULE_CONVENTION, MONTH_REPOSTING] {
|
||||
assert!(KNOWN_FEATURES.contains(&f));
|
||||
let l = line(FORMAT_SET, json!({"format": 2, "features": [f]}));
|
||||
assert_eq!(
|
||||
declared(&l).unwrap(),
|
||||
JournalFormat {
|
||||
format: 2,
|
||||
features: vec![f.to_string()]
|
||||
}
|
||||
);
|
||||
match declared_knowing(&l, &[]) {
|
||||
Err(StoreError::NewerJournal { need, .. }) => {
|
||||
assert_eq!(need, format!("journal features {f}"))
|
||||
}
|
||||
other => panic!("expected NewerJournal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
// Declared together, sorted.
|
||||
assert_eq!(
|
||||
JournalFormat::default()
|
||||
.declaration(2, &[SCHEDULE_CONVENTION, MONTH_REPOSTING])
|
||||
.unwrap(),
|
||||
json!({"format": 2, "features": ["month_reposting", "schedule_convention"]})
|
||||
);
|
||||
}
|
||||
|
||||
/// A format newer than this version reads is newer whatever shape the
|
||||
/// rest of its declaration takes, and however large its number: a
|
||||
/// newer format may define its features differently, so they are
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 48a4bd4129ca2d47a2e5f07eff290d0ad86b9af2e241e6d09b072f766abfb552 # shrinks to ops = [Run(1), Dispose(2026-02-01)]
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Every order of depreciation runs, reversals, re-runs, re-instatements,
|
||||
//! disposals and closed periods, on one asset: exhaustive over short
|
||||
//! sequences, and random over longer ones (P3 review round 2).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: whatever a human does, and in whatever order, the books never
|
||||
//! carry an asset's depreciation past its schedule at a month end,
|
||||
//! never carry a month twice, take exactly cost less salvage over
|
||||
//! the life, and dispose at proceeds less the book value of the
|
||||
//! days held.
|
||||
//! SOURCE: docs/spec/rules.md B-9, B-10, B-15; plan/served.md P3 (settled
|
||||
//! in design: a skipped month is caught up in the month the run is
|
||||
//! made); Ledger::dispose_asset INVARIANT 1.
|
||||
//! INVARIANTS (checked after every command, refused or not):
|
||||
//! 1. A refused command journals nothing.
|
||||
//! 2. At every month end before the disposal (all of them, while the
|
||||
//! asset is active), the depreciation expense is the accumulated
|
||||
//! depreciation credit, and at most the schedule through that day.
|
||||
//! 3. No month has two runs in effect.
|
||||
//! 4. Disposed on D: at the end of D's month and in the end, the
|
||||
//! expense is the schedule through D, accumulated depreciation 0.00,
|
||||
//! the cost cleared, and the gain proceeds less (cost less that).
|
||||
//! 5. Active, then run month by month past the life (a closed month
|
||||
//! refused): the expense is exactly cost less salvage, and every
|
||||
//! month end still within the schedule.
|
||||
//! 6. The books replay to the same figures (verify, at the search's
|
||||
//! leaves and each random sequence's end).
|
||||
//! ```
|
||||
//!
|
||||
//! The asset: 3,000.00, no salvage, 3 months from 2026-01-16, so January
|
||||
//! holds 16 of its 31 days (516.13), February and March 1,000.00 each, and
|
||||
//! April 1 to 15 the rest (483.87): rounding in the first and last months.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, AssetStatus, Currency, Money, NewAsset};
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, Store};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"wanderbooks-orders-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
fn usd(cents: i64) -> Money {
|
||||
Money::new(Decimal::new(cents, 2), Currency::USD)
|
||||
}
|
||||
|
||||
fn month_end(m: u32) -> NaiveDate {
|
||||
ledger_core::schedules::month_end(day(2026, m, 1))
|
||||
}
|
||||
|
||||
/// The books' accounts, by code.
|
||||
const COST: &str = "1500";
|
||||
const ACCUM: &str = "1501";
|
||||
const EXPENSE: &str = "6100";
|
||||
const GAIN: &str = "4900";
|
||||
const CASH: &str = "1000";
|
||||
const PROCEEDS: i64 = 100_000;
|
||||
|
||||
/// One command.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Op {
|
||||
/// Run 2026-`m`.
|
||||
Run(u32),
|
||||
/// Reverse the `n`th asset entry made so far (a run, a reversal, the
|
||||
/// disposal or its final depreciation), on a day.
|
||||
Reverse(usize, NaiveDate),
|
||||
/// Dispose of the asset on a day for 1,000.00.
|
||||
Dispose(NaiveDate),
|
||||
/// Close 2026-`m`.
|
||||
Close(u32),
|
||||
}
|
||||
|
||||
/// Books with the asset, a period per month of 2026, and the asset's id.
|
||||
fn books(dir: &Path) -> u64 {
|
||||
let mut s = Store::create(dir, Currency::USD).unwrap();
|
||||
let mut add = |code: &str, t| s.add_account(code, code, t, None, false).unwrap();
|
||||
let cash = add(CASH, AccountType::Asset);
|
||||
let cost = add(COST, AccountType::Asset);
|
||||
let accum = add(ACCUM, AccountType::Asset);
|
||||
let expense = add(EXPENSE, AccountType::Expense);
|
||||
add(GAIN, AccountType::Revenue);
|
||||
add("3000", AccountType::Equity);
|
||||
let _ = cash;
|
||||
for m in 1..=12 {
|
||||
s.create_period(&format!("2026-{m:02}"), day(2026, m, 1), month_end(m))
|
||||
.unwrap();
|
||||
}
|
||||
s.register_asset(NewAsset {
|
||||
description: "Asset".into(),
|
||||
purchase_date: day(2026, 1, 16),
|
||||
cost: usd(300_000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 3,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The entries the asset's commands made, in order: what a reversal picks
|
||||
/// from. Read from the journal so a copy of the books knows them.
|
||||
fn asset_entries(store: &Store) -> Vec<u64> {
|
||||
let mut out = Vec::new();
|
||||
let text = std::fs::read_to_string(store.data_dir().join(JOURNAL_FILE)).unwrap();
|
||||
for line in text.lines() {
|
||||
let v: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
let p = &v["payload"];
|
||||
let id = match v["type"].as_str().unwrap() {
|
||||
"asset.depreciation_posted" | "asset.disposed" => p["entry_id"].as_u64(),
|
||||
"entry.reversed" => p["id"].as_u64(),
|
||||
_ => None,
|
||||
};
|
||||
out.extend(id);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn code_id(store: &Store, code: &str) -> u64 {
|
||||
store.ledger().chart().find_by_code(code).unwrap().id
|
||||
}
|
||||
|
||||
/// An account's movement (debit positive) through the end of `on`, its
|
||||
/// closing entries left out: what was charged to it, closed or not.
|
||||
fn balance(store: &Store, code: &str, on: NaiveDate) -> Decimal {
|
||||
let l = store.ledger();
|
||||
let id = code_id(store, code);
|
||||
let closing = l.closing_entry_ids();
|
||||
l.posted_entries()
|
||||
.filter(|e| e.date() <= on && !closing.contains(&e.id()))
|
||||
.flat_map(|e| e.lines().iter())
|
||||
.filter(|x| x.account == id)
|
||||
.map(|x| match x.side {
|
||||
ledger_core::Side::Debit => x.amount.amount,
|
||||
ledger_core::Side::Credit => -x.amount.amount,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Apply one command; Ok(true) when it changed the books.
|
||||
fn apply(store: &mut Store, asset: u64, op: Op) -> bool {
|
||||
let head = store.head();
|
||||
let done = match op {
|
||||
Op::Run(m) => store
|
||||
.run_depreciation(asset, &format!("2026-{m:02}"))
|
||||
.map(|r| r.entry_id.is_some() && !r.already_posted),
|
||||
Op::Reverse(n, on) => {
|
||||
let entries = asset_entries(store);
|
||||
match entries.get(n) {
|
||||
Some(&e) => store.reverse_entry(e, on, "reversed").map(|_| true),
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
Op::Dispose(on) => {
|
||||
let (cash, gain) = (code_id(store, CASH), code_id(store, GAIN));
|
||||
store
|
||||
.dispose_asset(asset, on, usd(PROCEEDS), cash, gain)
|
||||
.map(|_| true)
|
||||
}
|
||||
Op::Close(m) => {
|
||||
let id = store
|
||||
.ledger()
|
||||
.list_periods()
|
||||
.into_iter()
|
||||
.find(|p| p.name == format!("2026-{m:02}"))
|
||||
.unwrap()
|
||||
.id;
|
||||
store.close_period(id).map(|_| true)
|
||||
}
|
||||
};
|
||||
match done {
|
||||
Ok(changed) => {
|
||||
if !changed {
|
||||
assert_eq!(store.head(), head, "{op:?} changed nothing yet journaled");
|
||||
}
|
||||
changed
|
||||
}
|
||||
Err(_) => {
|
||||
assert_eq!(store.head(), head, "{op:?} was refused yet journaled");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The month ends checked: from before the asset through after the life.
|
||||
fn ends() -> Vec<NaiveDate> {
|
||||
std::iter::once(day(2025, 12, 31))
|
||||
.chain((1..=12).map(month_end))
|
||||
.chain([NaiveDate::MAX])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Invariants 2 to 4 (and 6, when `verify`).
|
||||
fn check(store: &Store, asset: u64, ops: &[Op], verify: bool) {
|
||||
let a = store.ledger().get_asset(asset).unwrap().clone();
|
||||
let mut active = a.clone();
|
||||
active.status = AssetStatus::Active;
|
||||
let sched = |on: NaiveDate| ledger_core::accumulated_depreciation(&active, on).unwrap();
|
||||
// INVARIANT 3: one run in effect per month.
|
||||
let text = std::fs::read_to_string(store.data_dir().join(JOURNAL_FILE)).unwrap();
|
||||
let mut live: Vec<String> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let v: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
if v["type"] == "asset.depreciation_posted"
|
||||
&& store
|
||||
.ledger()
|
||||
.in_effect(v["payload"]["entry_id"].as_u64().unwrap())
|
||||
{
|
||||
let p = v["payload"]["period"].as_str().unwrap().to_string();
|
||||
assert!(!live.contains(&p), "{ops:?}: two runs in effect for {p}");
|
||||
live.push(p);
|
||||
}
|
||||
}
|
||||
let disposed = match a.status {
|
||||
AssetStatus::Disposed { date, .. } => Some(date),
|
||||
AssetStatus::Active => None,
|
||||
};
|
||||
for e in ends() {
|
||||
let x = balance(store, EXPENSE, e);
|
||||
let acc = balance(store, ACCUM, e);
|
||||
if disposed.is_none_or(|d| e < d) {
|
||||
assert_eq!(x, -acc, "{ops:?}: expense and accumulated apart on {e}");
|
||||
assert!(
|
||||
x <= sched(e),
|
||||
"{ops:?}: {x} carried on {e}, past the schedule's {}",
|
||||
sched(e)
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(d) = disposed {
|
||||
let target = sched(d);
|
||||
for e in [ledger_core::schedules::month_end(d), NaiveDate::MAX] {
|
||||
assert_eq!(
|
||||
balance(store, EXPENSE, e),
|
||||
target,
|
||||
"{ops:?}: expense on {e}"
|
||||
);
|
||||
assert_eq!(
|
||||
balance(store, ACCUM, e),
|
||||
Decimal::ZERO,
|
||||
"{ops:?}: accum on {e}"
|
||||
);
|
||||
// No purchase entry here: the disposal credits the whole cost.
|
||||
assert_eq!(
|
||||
balance(store, COST, e),
|
||||
-a.cost.amount,
|
||||
"{ops:?}: cost on {e}"
|
||||
);
|
||||
let gain = Decimal::new(PROCEEDS, 2) - (a.cost.amount - target);
|
||||
assert_eq!(balance(store, GAIN, e), -gain, "{ops:?}: gain on {e}");
|
||||
}
|
||||
}
|
||||
if verify {
|
||||
store.verify().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Invariant 5, on a copy: run every month in turn past the life.
|
||||
fn check_completion(dir: &Path, asset: u64, ops: &[Op]) {
|
||||
let copy = test_dir("complete");
|
||||
copy_books(dir, ©.0);
|
||||
let mut store = Store::open(©.0).unwrap();
|
||||
if store.ledger().get_asset(asset).unwrap().status != AssetStatus::Active {
|
||||
return;
|
||||
}
|
||||
for m in 1..=12 {
|
||||
apply(&mut store, asset, Op::Run(m));
|
||||
}
|
||||
let a = store.ledger().get_asset(asset).unwrap().clone();
|
||||
for e in ends() {
|
||||
let x = balance(&store, EXPENSE, e);
|
||||
assert!(
|
||||
x <= ledger_core::accumulated_depreciation(&a, e).unwrap(),
|
||||
"{ops:?} then every month: {x} on {e}"
|
||||
);
|
||||
}
|
||||
let whole = a.cost.amount - a.salvage_value.amount;
|
||||
assert_eq!(
|
||||
balance(&store, EXPENSE, NaiveDate::MAX),
|
||||
whole,
|
||||
"{ops:?} then every month: the lifetime total"
|
||||
);
|
||||
assert_eq!(balance(&store, ACCUM, NaiveDate::MAX), -whole, "{ops:?}");
|
||||
}
|
||||
|
||||
/// Copy the books (journal and projection) to `to`, closed.
|
||||
fn copy_books(from: &Path, to: &Path) {
|
||||
for f in [JOURNAL_FILE, DB_FILE] {
|
||||
let src = from.join(f);
|
||||
if src.exists() {
|
||||
std::fs::copy(&src, to.join(f)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every command at a node: runs of each month, reversals of each asset
|
||||
/// entry made so far on three days (before, in and after most entries'
|
||||
/// months), two disposals (mid-month, and in the life's final month), and
|
||||
/// closes of January and February.
|
||||
fn alphabet(entries: usize) -> Vec<Op> {
|
||||
let mut ops: Vec<Op> = (1..=5).map(Op::Run).collect();
|
||||
for n in 0..entries {
|
||||
for on in [day(2026, 1, 31), day(2026, 3, 31), day(2026, 5, 2)] {
|
||||
ops.push(Op::Reverse(n, on));
|
||||
}
|
||||
}
|
||||
ops.extend([Op::Dispose(day(2026, 2, 15)), Op::Dispose(day(2026, 4, 10))]);
|
||||
ops.extend([Op::Close(1), Op::Close(2)]);
|
||||
ops
|
||||
}
|
||||
|
||||
/// Depth-first over every sequence of commands up to `depth` long, each
|
||||
/// node a closed copy of its parent's books.
|
||||
fn explore(dir: &Path, asset: u64, ops: &mut Vec<Op>, depth: usize, seen: &mut u64) {
|
||||
if ops.len() == depth {
|
||||
return;
|
||||
}
|
||||
let entries = asset_entries(&Store::open(dir).unwrap()).len();
|
||||
for op in alphabet(entries) {
|
||||
let child = test_dir("node");
|
||||
copy_books(dir, &child.0);
|
||||
let mut store = Store::open(&child.0).unwrap();
|
||||
let changed = apply(&mut store, asset, op);
|
||||
ops.push(op);
|
||||
*seen += 1;
|
||||
if changed {
|
||||
check(&store, asset, ops, ops.len() == depth);
|
||||
drop(store);
|
||||
check_completion(&child.0, asset, ops);
|
||||
explore(&child.0, asset, ops, depth, seen);
|
||||
}
|
||||
ops.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every sequence of up to three commands (about half a minute; deeper
|
||||
/// with WB_ORDERS_DEPTH, for a review, not every run: four took a minute
|
||||
/// and five five minutes, 2026-10-21, both clean).
|
||||
#[test]
|
||||
fn every_order_of_runs_reversals_disposals_and_closes_keeps_the_books_right() {
|
||||
let depth = std::env::var("WB_ORDERS_DEPTH")
|
||||
.ok()
|
||||
.and_then(|d| d.parse().ok())
|
||||
.unwrap_or(3);
|
||||
let root = test_dir("root");
|
||||
let asset = books(&root.0);
|
||||
let mut seen = 0;
|
||||
explore(&root.0, asset, &mut Vec::new(), depth, &mut seen);
|
||||
assert!(seen > 0);
|
||||
}
|
||||
|
||||
fn arb_op() -> impl Strategy<Value = Op> {
|
||||
let date = (1u32..=6, 1u32..=31)
|
||||
.prop_map(|(m, d)| NaiveDate::from_ymd_opt(2026, m, d).unwrap_or_else(|| month_end(m)));
|
||||
prop_oneof![
|
||||
4 => (1u32..=6).prop_map(Op::Run),
|
||||
4 => (0usize..8, date.clone()).prop_map(|(n, on)| Op::Reverse(n, on)),
|
||||
1 => date.prop_map(Op::Dispose),
|
||||
1 => (1u32..=4).prop_map(Op::Close),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||
|
||||
/// Longer sequences at random, any day of the first half of 2026.
|
||||
#[test]
|
||||
fn prop_any_sequence_keeps_the_books_right(ops in prop::collection::vec(arb_op(), 1..10)) {
|
||||
let root = test_dir("prop");
|
||||
let asset = books(&root.0);
|
||||
{
|
||||
let mut store = Store::open(&root.0).unwrap();
|
||||
for (i, &op) in ops.iter().enumerate() {
|
||||
apply(&mut store, asset, op);
|
||||
check(&store, asset, &ops[..=i], false);
|
||||
}
|
||||
store.verify().unwrap();
|
||||
}
|
||||
check_completion(&root.0, asset, &ops);
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ fn depreciation_run_is_idempotent() {
|
||||
assert_eq!(run3.amount, Decimal::from(1_000));
|
||||
|
||||
// The depreciation entries hit the right accounts.
|
||||
let entry = store.ledger().get_entry(run3.entry_id).unwrap();
|
||||
let entry = store.ledger().get_entry(run3.entry_id.unwrap()).unwrap();
|
||||
assert!(entry.lines().iter().any(|l| l.account == expense
|
||||
&& l.side == Side::Debit
|
||||
&& l.amount.amount == Decimal::from(1_000)));
|
||||
@@ -125,13 +125,26 @@ fn depreciation_rejects_period_outside_life() {
|
||||
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||
// Before purchase.
|
||||
assert!(store.run_depreciation(id, "2025-12").is_err());
|
||||
// After the 60-month life ends (2031-01).
|
||||
assert!(store.run_depreciation(id, "2031-06").is_err());
|
||||
// Malformed period.
|
||||
assert!(store.run_depreciation(id, "2026-13").is_err());
|
||||
assert!(store.run_depreciation(id, "january").is_err());
|
||||
// Unknown asset.
|
||||
assert!(store.run_depreciation(999, "2026-01").is_err());
|
||||
// After the 60-month life ends (2031-01): the run catches up every
|
||||
// month skipped, here all of them (plan/served.md P3, settled in
|
||||
// design: a month skipped is caught up in the month the run is made).
|
||||
let late = store.run_depreciation(id, "2031-06").unwrap();
|
||||
assert_eq!(late.amount, Decimal::from(60_000));
|
||||
assert_eq!(late.scheduled, Decimal::ZERO);
|
||||
assert!(late.entry_id.is_some());
|
||||
// Then nothing is left: a run after the life posts nothing, and
|
||||
// journals nothing.
|
||||
let head = store.head();
|
||||
let after = store.run_depreciation(id, "2031-07").unwrap();
|
||||
assert_eq!((after.amount, after.entry_id), (Decimal::ZERO, None));
|
||||
assert!(after.nothing_due.unwrap().contains("life is over"));
|
||||
assert_eq!(store.head(), head);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -233,3 +246,833 @@ fn rejected_asset_registration_journals_nothing() {
|
||||
let id = van(&mut store, 1_000, cost, accum, expense);
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------- P3: defect 22, catch-up, R2, R3
|
||||
|
||||
/// Asset X of the P3 depreciation brief: 12,000.00, no salvage, 12 months
|
||||
/// from 2026-01-01, so 1,000.00 a month and December its final month.
|
||||
fn asset_x(store: &mut Store, cost: u64, accum: u64, expense: u64) -> u64 {
|
||||
store
|
||||
.register_asset(NewAsset {
|
||||
description: "Asset X".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd("12000.00"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// An account's balance in the books (debit positive) on a day.
|
||||
fn balance(store: &Store, account: u64, on: NaiveDate) -> Decimal {
|
||||
let code = &store.ledger().chart().get(account).unwrap().code;
|
||||
ledger_core::reports::trial_balance_as_of(store.ledger(), on)
|
||||
.lines
|
||||
.iter()
|
||||
.find(|l| &l.code == code)
|
||||
.map_or(Decimal::ZERO, |l| l.debit - l.credit)
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
/// Defect 22, reproduction C (wrong posted figures before P3): January
|
||||
/// through November run (11,000.00), then a disposal on 2026-12-10 for
|
||||
/// 1,000.00. Hand check: December holds 10 of its 31 days, 1,000.00 ×
|
||||
/// 10/31 = 322.58; accumulated 11,322.58, book value 677.42, gain 1,000.00
|
||||
/// − 677.42 = 322.58. Before P3: 1,000.00 of depreciation, book value 0.00,
|
||||
/// gain 1,000.00 (expense and gain each 677.42 too high).
|
||||
#[test]
|
||||
fn defect22_a_disposal_in_the_final_month_posts_depreciation_by_days() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
for m in 1..=11 {
|
||||
store.run_depreciation(x, &format!("2026-{m:02}")).unwrap();
|
||||
}
|
||||
let p = store
|
||||
.dispose_preview(x, date(2026, 12, 10), usd("1000.00"))
|
||||
.unwrap();
|
||||
assert_eq!(p.final_depreciation, dec("322.58"));
|
||||
assert_eq!(p.accumulated, dec("11322.58"));
|
||||
assert_eq!(p.book_value, dec("677.42"));
|
||||
assert_eq!(p.gain_loss, dec("322.58"));
|
||||
assert_eq!(p.excess_depreciation, Decimal::ZERO);
|
||||
store
|
||||
.dispose_asset(x, date(2026, 12, 10), usd("1000.00"), bank, gain)
|
||||
.unwrap();
|
||||
let on = date(2026, 12, 31);
|
||||
assert_eq!(balance(&store, expense, on), dec("11322.58"));
|
||||
assert_eq!(balance(&store, gain, on), dec("-322.58"));
|
||||
assert_eq!(balance(&store, accum, on), Decimal::ZERO);
|
||||
assert_eq!(balance(&store, cost, on), dec("-12000.00"));
|
||||
// The books' accumulated equals the disposed schedule's.
|
||||
let a = store.ledger().get_asset(x).unwrap();
|
||||
assert_eq!(
|
||||
ledger_core::accumulated_depreciation(a, NaiveDate::MAX).unwrap(),
|
||||
dec("11322.58")
|
||||
);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 depreciation review R2: a disposal dated inside a month already run
|
||||
/// takes back the days after it. Hand check: January through June run
|
||||
/// (June's 1,000.00 dated Jun 30), then a disposal on 2026-06-15 for
|
||||
/// 7,000.00. Held through Jun 15: 5,500.00 of depreciation, book value
|
||||
/// 6,500.00, gain 500.00. The entry: Dr accumulated depreciation 6,000.00
|
||||
/// (all the runs posted), Dr bank 7,000.00 / Cr equipment 12,000.00, Cr
|
||||
/// depreciation expense 500.00, Cr gain 500.00: 13,000.00 a side. Before
|
||||
/// P3: a book value of 6,000.00 and a gain of 1,000.00.
|
||||
#[test]
|
||||
fn defect22_a_disposal_inside_a_posted_month_takes_back_the_extra_days() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
for m in 1..=6 {
|
||||
store.run_depreciation(x, &format!("2026-{m:02}")).unwrap();
|
||||
}
|
||||
let p = store
|
||||
.dispose_preview(x, date(2026, 6, 15), usd("7000.00"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
p.final_depreciation,
|
||||
p.excess_depreciation,
|
||||
p.catch_up_depreciation
|
||||
),
|
||||
(Decimal::ZERO, dec("500.00"), Decimal::ZERO)
|
||||
);
|
||||
assert_eq!(p.accumulated, dec("6000.00"));
|
||||
assert_eq!(p.book_value, dec("6500.00"));
|
||||
assert_eq!(p.gain_loss, dec("500.00"));
|
||||
let a = store.ledger().get_asset(x).unwrap().clone();
|
||||
let lines = Store::disposal_lines(&a, &p, dec("7000.00"), bank, gain);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
(accum, Side::Debit, dec("6000.00")),
|
||||
(bank, Side::Debit, dec("7000.00")),
|
||||
(cost, Side::Credit, dec("12000.00")),
|
||||
(expense, Side::Credit, dec("500.00")),
|
||||
(gain, Side::Credit, dec("500.00")),
|
||||
]
|
||||
);
|
||||
store
|
||||
.dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain)
|
||||
.unwrap();
|
||||
let on = date(2026, 6, 30);
|
||||
assert_eq!(balance(&store, expense, on), dec("5500.00"));
|
||||
assert_eq!(balance(&store, gain, on), dec("-500.00"));
|
||||
assert_eq!(balance(&store, accum, on), Decimal::ZERO);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// The catch-up rule (plan/served.md P3, settled in design: a month of
|
||||
/// depreciation skipped is caught up in the month the run is made): run
|
||||
/// January, skip February, run March: March posts 2,000.00 (caught up
|
||||
/// 1,000.00); a February run after it posts nothing and journals nothing.
|
||||
#[test]
|
||||
fn a_skipped_month_is_caught_up_in_the_month_run() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
store.run_depreciation(x, "2026-01").unwrap();
|
||||
let mar = store.run_depreciation(x, "2026-03").unwrap();
|
||||
assert_eq!(
|
||||
(mar.amount, mar.scheduled),
|
||||
(dec("2000.00"), dec("1000.00"))
|
||||
);
|
||||
let head = store.head();
|
||||
let feb = store.run_depreciation(x, "2026-02").unwrap();
|
||||
assert_eq!((feb.amount, feb.entry_id), (Decimal::ZERO, None));
|
||||
assert!(feb.nothing_due.unwrap().contains("later run"));
|
||||
assert_eq!(store.head(), head);
|
||||
assert_eq!(balance(&store, accum, date(2026, 3, 31)), dec("-3000.00"));
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 depreciation review R3: a run reversed within its month is
|
||||
/// un-posted, so its month is due again; running it again declares journal
|
||||
/// feature month_reposting first (an older version would ignore the second
|
||||
/// line for the month).
|
||||
#[test]
|
||||
fn a_reversed_depreciation_month_can_be_run_again() {
|
||||
let (dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let jan = store.run_depreciation(x, "2026-01").unwrap();
|
||||
let jan_entry = jan.entry_id.unwrap();
|
||||
store
|
||||
.reverse_entry(jan_entry, date(2026, 1, 31), "posted to the wrong asset")
|
||||
.unwrap();
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
let again = store.run_depreciation(x, "2026-01").unwrap();
|
||||
assert!(!again.already_posted);
|
||||
assert_eq!(again.amount, dec("1000.00"));
|
||||
assert_ne!(again.entry_id, Some(jan_entry));
|
||||
assert_eq!(
|
||||
store.journal_format().features,
|
||||
vec![ledger_store::format::MONTH_REPOSTING.to_string()]
|
||||
);
|
||||
// Idempotent again: the new run is the month's.
|
||||
let twice = store.run_depreciation(x, "2026-01").unwrap();
|
||||
assert!(twice.already_posted);
|
||||
assert_eq!(twice.entry_id, again.entry_id);
|
||||
// January's end carries January once: the first run, its reversal and
|
||||
// the run again are all dated Jan 31.
|
||||
assert_eq!(balance(&store, accum, date(2026, 1, 31)), dec("-1000.00"));
|
||||
assert_eq!(balance(&store, accum, date(2026, 2, 28)), dec("-1000.00"));
|
||||
store.verify().unwrap();
|
||||
// Reopened (the projection rebuilt from the journal, then caught up),
|
||||
// the same.
|
||||
drop(store);
|
||||
std::fs::remove_file(dir.path().join(ledger_store::DB_FILE)).unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
store.verify().unwrap();
|
||||
let mut store = store;
|
||||
assert!(store.run_depreciation(x, "2026-01").unwrap().already_posted);
|
||||
}
|
||||
|
||||
/// R3's example: January through May run, May reversed, then a disposal on
|
||||
/// Jun 15 for 7,000.00. The runs in effect carry 4,000.00; the schedule
|
||||
/// through Jun 15 is 5,500.00, so the final depreciation is 1,500.00 (May
|
||||
/// again, and half of June), and the disposal clears 5,500.00: the
|
||||
/// accumulated depreciation account ends at 0.00, never a 1,000.00 debit.
|
||||
#[test]
|
||||
fn a_reversed_run_before_a_disposal_leaves_nothing_behind() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let mut may = 0;
|
||||
for m in 1..=5 {
|
||||
may = store
|
||||
.run_depreciation(x, &format!("2026-{m:02}"))
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
}
|
||||
store
|
||||
.reverse_entry(may, date(2026, 6, 1), "reversed")
|
||||
.unwrap();
|
||||
let p = store
|
||||
.dispose_preview(x, date(2026, 6, 15), usd("7000.00"))
|
||||
.unwrap();
|
||||
assert_eq!(p.final_depreciation, dec("1500.00"));
|
||||
assert_eq!(p.accumulated, dec("5500.00"));
|
||||
assert_eq!(p.gain_loss, dec("500.00"));
|
||||
store
|
||||
.dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain)
|
||||
.unwrap();
|
||||
let on = date(2026, 6, 30);
|
||||
assert_eq!(balance(&store, accum, on), Decimal::ZERO);
|
||||
assert_eq!(balance(&store, expense, on), dec("5500.00"));
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// R3: a disposal can't be reversed (the register would still say
|
||||
/// disposed), nor a run its disposal took as final; nor can a reversal be
|
||||
/// reversed when that would post a month a second time. The ledger and
|
||||
/// journal are untouched by each refusal.
|
||||
#[test]
|
||||
fn a_disposal_entry_cannot_be_reversed() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let jan = store
|
||||
.run_depreciation(x, "2026-01")
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
// Re-instating a run whose month ran again would post it twice.
|
||||
let rev = store.reverse_entry(jan, date(2026, 1, 31), "oops").unwrap();
|
||||
let again = store
|
||||
.run_depreciation(x, "2026-01")
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
let head = store.head();
|
||||
match store.reverse_entry(rev, date(2026, 2, 2), "re-instate") {
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::AssetEntryIrreversible(e, why),
|
||||
)) => {
|
||||
assert_eq!(e, rev);
|
||||
assert!(why.contains(&format!("entry {again}")), "{why}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(store.head(), head);
|
||||
let disposal = store
|
||||
.dispose_asset(x, date(2026, 2, 15), usd("11000.00"), bank, gain)
|
||||
.unwrap();
|
||||
let head = store.head();
|
||||
for (entry, what) in [(disposal, "disposal"), (again, "disposed")] {
|
||||
match store.reverse_entry(entry, date(2026, 3, 1), "undo") {
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::AssetEntryIrreversible(_, why),
|
||||
)) => assert!(why.contains(what), "{why}"),
|
||||
other => panic!("{entry}: {other:?}"),
|
||||
}
|
||||
assert!(store.check_reverse_entry(entry, date(2026, 3, 1)).is_err());
|
||||
}
|
||||
assert_eq!(store.head(), head);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 depreciation review R10: a month whose depreciation rounds to 0.00
|
||||
/// posts nothing and journals nothing (an entry can't carry a zero line);
|
||||
/// the cent posts in the month it rounds into. Hand check: 0.01 over 2
|
||||
/// months from 2026-01-01: half a cent after January rounds up, so
|
||||
/// January posts 0.01 and February nothing. 0.01 over 3 months: January
|
||||
/// nothing, February 0.01, March nothing.
|
||||
#[test]
|
||||
fn run_depreciation_posts_nothing_for_a_month_that_rounds_to_nothing() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let tiny = store
|
||||
.register_asset(NewAsset {
|
||||
description: "Cable".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd("0.01"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 3,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let head = store.head();
|
||||
let jan = store.run_depreciation(tiny, "2026-01").unwrap();
|
||||
assert_eq!((jan.amount, jan.entry_id), (Decimal::ZERO, None));
|
||||
assert!(jan.nothing_due.unwrap().contains("rounds to nothing"));
|
||||
assert_eq!(store.head(), head);
|
||||
let feb = store.run_depreciation(tiny, "2026-02").unwrap();
|
||||
assert_eq!(feb.amount, dec("0.01"));
|
||||
let mar = store.run_depreciation(tiny, "2026-03").unwrap();
|
||||
assert_eq!((mar.amount, mar.entry_id), (Decimal::ZERO, None));
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// R1: in yen, each run posts whole yen (¥100,000 over 12 months: ¥8,333,
|
||||
/// ¥8,334, ¥8,333, ...), so every month can post.
|
||||
#[test]
|
||||
fn jpy_depreciation_runs_post_whole_yen() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut store = Store::create(dir.path(), Currency::JPY).unwrap();
|
||||
let add = |s: &mut Store, code: &str, t| s.add_account(code, code, t, None, false).unwrap();
|
||||
let cost = add(&mut store, "1500", AccountType::Asset);
|
||||
let accum = add(&mut store, "1501", AccountType::Asset);
|
||||
let expense = add(&mut store, "6100", AccountType::Expense);
|
||||
let yen = |a: &str| Money::new(dec(a), Currency::JPY);
|
||||
let id = store
|
||||
.register_asset(NewAsset {
|
||||
description: "Lathe".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: yen("100000"),
|
||||
salvage_value: yen("0"),
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let got: Vec<Decimal> = (1..=3)
|
||||
.map(|m| {
|
||||
store
|
||||
.run_depreciation(id, &format!("2026-{m:02}"))
|
||||
.unwrap()
|
||||
.amount
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got, [dec("8333"), dec("8334"), dec("8333")]);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 review of the catch-up rule: a run reversed after its month ended
|
||||
/// still carried that month at its end, so running the month again would
|
||||
/// carry it twice there; the run of the reversal's month catches it up
|
||||
/// instead. Hand check: January run (1,000.00, Jan 31), reversed Feb 2.
|
||||
/// January again: nothing (Jan 31 carries 1,000.00, the schedule). February:
|
||||
/// 2,000.00 (the schedule through Feb 28, 2,000.00, less the 0.00 carried
|
||||
/// after the reversal). Jan 31 carries 1,000.00, Feb 28 2,000.00: each the
|
||||
/// schedule, never 2,000.00 on Jan 31 (before this review it was).
|
||||
#[test]
|
||||
fn a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let jan = store
|
||||
.run_depreciation(x, "2026-01")
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
store
|
||||
.reverse_entry(jan, date(2026, 2, 2), "posted twice by hand")
|
||||
.unwrap();
|
||||
let head = store.head();
|
||||
let again = store.run_depreciation(x, "2026-01").unwrap();
|
||||
assert_eq!((again.amount, again.entry_id), (Decimal::ZERO, None));
|
||||
assert!(
|
||||
again
|
||||
.nothing_due
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("reversed after"),
|
||||
"{again:?}"
|
||||
);
|
||||
assert_eq!(store.head(), head, "nothing journaled");
|
||||
let feb = store.run_depreciation(x, "2026-02").unwrap();
|
||||
assert_eq!(
|
||||
(feb.amount, feb.scheduled),
|
||||
(dec("2000.00"), dec("1000.00"))
|
||||
);
|
||||
assert_eq!(balance(&store, accum, date(2026, 1, 31)), dec("-1000.00"));
|
||||
assert_eq!(balance(&store, accum, date(2026, 2, 28)), dec("-2000.00"));
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 review: re-instating a reversed run (reversing its reversal) after a
|
||||
/// later run caught its month up would carry the books past the schedule,
|
||||
/// here past cost less salvage. Hand check: January through November run
|
||||
/// (11,000.00); November reversed on Nov 30; December's run posts 2,000.00
|
||||
/// (the whole 12,000.00 less the 10,000.00 carried). Re-instating November
|
||||
/// on Dec 31 would carry 13,000.00 of a 12,000.00 asset: refused, journaling
|
||||
/// nothing. A run re-instated with nothing run since (January, reversed and
|
||||
/// re-instated on Jan 31, room 1,000.00) is taken.
|
||||
#[test]
|
||||
fn reinstating_a_run_a_later_run_caught_up_is_refused() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let mut nov = 0;
|
||||
for m in 1..=11 {
|
||||
nov = store
|
||||
.run_depreciation(x, &format!("2026-{m:02}"))
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
}
|
||||
let rev = store
|
||||
.reverse_entry(nov, date(2026, 11, 30), "check the count")
|
||||
.unwrap();
|
||||
let dec_run = store.run_depreciation(x, "2026-12").unwrap();
|
||||
assert_eq!(dec_run.amount, dec("2000.00"));
|
||||
let head = store.head();
|
||||
match store.reverse_entry(rev, date(2026, 12, 31), "it was right") {
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::AssetEntryIrreversible(e, why),
|
||||
)) => {
|
||||
assert_eq!(e, rev);
|
||||
assert!(why.contains("more than its schedule"), "{why}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(store.head(), head);
|
||||
assert_eq!(balance(&store, accum, date(2026, 12, 31)), dec("-12000.00"));
|
||||
store.verify().unwrap();
|
||||
|
||||
// Before a later run, re-instating is room the schedule has: taken.
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let jan = store
|
||||
.run_depreciation(x, "2026-01")
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
let rev = store.reverse_entry(jan, date(2026, 1, 31), "oops").unwrap();
|
||||
store
|
||||
.reverse_entry(rev, date(2026, 1, 31), "it was right")
|
||||
.unwrap();
|
||||
assert_eq!(balance(&store, accum, date(2026, 1, 31)), dec("-1000.00"));
|
||||
assert!(store.run_depreciation(x, "2026-01").unwrap().already_posted);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 review: a disposal dated before a later month's run would credit
|
||||
/// that run back at the disposal day, leaving the disposal month's
|
||||
/// accumulated depreciation a debit and its expense short at its end; it is
|
||||
/// refused, naming the run to reverse first. Hand check: January through
|
||||
/// July run (7,000.00); a disposal on Jun 15 is refused. July reversed on
|
||||
/// Jul 31: the disposal takes 5,500.00 (June's last 15 days, 500.00,
|
||||
/// credited back), and on Jun 30 the books carry accumulated depreciation
|
||||
/// 0.00 and expense 5,500.00; before this review June ended with a
|
||||
/// 1,000.00 debit in accumulated depreciation and 4,500.00 of expense.
|
||||
#[test]
|
||||
fn a_disposal_before_a_later_months_run_is_refused_until_it_is_reversed() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let mut jul = 0;
|
||||
for m in 1..=7 {
|
||||
jul = store
|
||||
.run_depreciation(x, &format!("2026-{m:02}"))
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
}
|
||||
let head = store.head();
|
||||
for refused in [
|
||||
store
|
||||
.dispose_preview(x, date(2026, 6, 15), usd("7000.00"))
|
||||
.map(|_| ()),
|
||||
store
|
||||
.dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain)
|
||||
.map(|_| ()),
|
||||
] {
|
||||
match refused {
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::DepreciationPastDisposal(why),
|
||||
)) => assert!(why.contains(&format!("2026-07 (entry {jul})")), "{why}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(store.head(), head);
|
||||
store
|
||||
.reverse_entry(jul, date(2026, 7, 31), "sold in June")
|
||||
.unwrap();
|
||||
let p = store
|
||||
.dispose_preview(x, date(2026, 6, 15), usd("7000.00"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(p.excess_depreciation, p.book_value, p.gain_loss),
|
||||
(dec("500.00"), dec("6500.00"), dec("500.00"))
|
||||
);
|
||||
store
|
||||
.dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain)
|
||||
.unwrap();
|
||||
for on in [date(2026, 6, 30), date(2026, 7, 31)] {
|
||||
assert_eq!(balance(&store, accum, on), Decimal::ZERO, "{on}");
|
||||
assert_eq!(balance(&store, expense, on), dec("5500.00"), "{on}");
|
||||
}
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 review: a run of the disposal's month or before reversed after the
|
||||
/// disposal month's end would be charged again at the disposal and taken
|
||||
/// away only later: refused until that change is undone within its month.
|
||||
/// Hand check: January through May run; May reversed on Aug 1; a disposal
|
||||
/// on Jun 15 would charge 1,500.00 (May again, and half of June) and so
|
||||
/// carry 6,500.00 on Jun 30. Refused; the reversal reversed on Aug 3 (May
|
||||
/// stands again, what it carries the same at every month end from June),
|
||||
/// the disposal charges 500.00 and June ends at 0.00 and 5,500.00.
|
||||
#[test]
|
||||
fn a_disposal_before_a_later_reversal_of_an_earlier_run_is_refused() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let mut may = 0;
|
||||
for m in 1..=5 {
|
||||
may = store
|
||||
.run_depreciation(x, &format!("2026-{m:02}"))
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
}
|
||||
let rev = store
|
||||
.reverse_entry(may, date(2026, 8, 1), "doubted")
|
||||
.unwrap();
|
||||
match store.dispose_preview(x, date(2026, 6, 15), usd("7000.00")) {
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::DepreciationPastDisposal(why),
|
||||
)) => assert!(
|
||||
why.contains(&format!("by entry {rev} on 2026-08-01")),
|
||||
"{why}"
|
||||
),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
store
|
||||
.reverse_entry(rev, date(2026, 8, 3), "it was right")
|
||||
.unwrap();
|
||||
let p = store
|
||||
.dispose_preview(x, date(2026, 6, 15), usd("7000.00"))
|
||||
.unwrap();
|
||||
assert_eq!(p.final_depreciation, dec("500.00"));
|
||||
store
|
||||
.dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain)
|
||||
.unwrap();
|
||||
let on = date(2026, 6, 30);
|
||||
assert_eq!(balance(&store, accum, on), Decimal::ZERO);
|
||||
assert_eq!(balance(&store, expense, on), dec("5500.00"));
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 review round 2: a run re-instated after its month was run again and
|
||||
/// that second run reversed still counts. Hand check: January run (A, Jan
|
||||
/// 31), reversed on Jan 31 (B); January run again (A2), reversed on Jan 31
|
||||
/// (B2); then B reversed on Jan 31, re-instating A: the room is the whole
|
||||
/// 1,000.00 (nothing else carries January). The books carry A, so January
|
||||
/// is posted (A's run), and a disposal on Feb 15 for 11,000.00 charges
|
||||
/// only February's 15 of 28 days, 535.71, clearing 1,535.71 (book value
|
||||
/// 10,464.29, gain 535.71): the accumulated depreciation account ends at
|
||||
/// 0.00 and the expense at 1,535.71. Before this review the projection's
|
||||
/// January row still named A2, so A was left out: January ran as nothing
|
||||
/// due, and the disposal charged January again (1,535.71 final), leaving
|
||||
/// 1,000.00 in accumulated depreciation.
|
||||
#[test]
|
||||
fn a_run_reinstated_after_its_rerun_was_reversed_still_counts() {
|
||||
let (dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let jan31 = date(2026, 1, 31);
|
||||
let a = store
|
||||
.run_depreciation(x, "2026-01")
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
let b = store.reverse_entry(a, jan31, "first doubt").unwrap();
|
||||
let a2 = store
|
||||
.run_depreciation(x, "2026-01")
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
store.reverse_entry(a2, jan31, "second doubt").unwrap();
|
||||
store
|
||||
.reverse_entry(b, jan31, "the first was right")
|
||||
.unwrap();
|
||||
assert_eq!(balance(&store, accum, jan31), dec("-1000.00"));
|
||||
let again = store.run_depreciation(x, "2026-01").unwrap();
|
||||
assert!(again.already_posted, "{again:?}");
|
||||
assert_eq!((again.entry_id, again.amount), (Some(a), dec("1000.00")));
|
||||
let p = store
|
||||
.dispose_preview(x, date(2026, 2, 15), usd("11000.00"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(p.final_depreciation, p.accumulated, p.gain_loss),
|
||||
(dec("535.71"), dec("1535.71"), dec("535.71"))
|
||||
);
|
||||
store
|
||||
.dispose_asset(x, date(2026, 2, 15), usd("11000.00"), bank, gain)
|
||||
.unwrap();
|
||||
let on = date(2026, 2, 28);
|
||||
assert_eq!(balance(&store, accum, on), Decimal::ZERO);
|
||||
assert_eq!(balance(&store, expense, on), dec("1535.71"));
|
||||
store.verify().unwrap();
|
||||
// The same after the projection is rebuilt from the journal.
|
||||
drop(store);
|
||||
std::fs::remove_file(dir.path().join(ledger_store::DB_FILE)).unwrap();
|
||||
Store::open(dir.path()).unwrap().verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 review round 2: a correction comes after what it corrects (B-9). A
|
||||
/// reversal of a depreciation run dated before the run (March's run, dated
|
||||
/// Mar 31, reversed on Feb 10) would leave January and February with
|
||||
/// negative depreciation, and a disposal on Feb 15 clearing what February
|
||||
/// doesn't carry (before this review it was taken: February ended with
|
||||
/// accumulated depreciation a 1,000.00 debit and 535.71 of expense, not
|
||||
/// 1,535.71). So is a re-instatement dated before the reversal it reverses,
|
||||
/// and a schedule month's reversal dated before its posting. A reversal on
|
||||
/// the entry's own day is taken. Each refusal journals nothing.
|
||||
#[test]
|
||||
fn a_reversal_dated_before_the_entry_it_reverses_is_refused() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let mut mar = 0;
|
||||
for m in 1..=3 {
|
||||
mar = store
|
||||
.run_depreciation(x, &format!("2026-{m:02}"))
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
}
|
||||
let head = store.head();
|
||||
for on in [date(2026, 2, 10), date(2026, 3, 30)] {
|
||||
match store.reverse_entry(mar, on, "backdated") {
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::AssetEntryIrreversible(e, why),
|
||||
)) => {
|
||||
assert_eq!(e, mar);
|
||||
assert!(why.contains("can't be dated before it"), "{why}");
|
||||
}
|
||||
other => panic!("{on}: {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(store.head(), head);
|
||||
let rev = store
|
||||
.reverse_entry(mar, date(2026, 3, 31), "doubted")
|
||||
.unwrap();
|
||||
let head = store.head();
|
||||
assert!(matches!(
|
||||
store.reverse_entry(rev, date(2026, 3, 30), "it was right"),
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::AssetEntryIrreversible(..)
|
||||
))
|
||||
));
|
||||
assert_eq!(store.head(), head);
|
||||
store
|
||||
.reverse_entry(rev, date(2026, 3, 31), "it was right")
|
||||
.unwrap();
|
||||
assert_eq!(balance(&store, accum, date(2026, 2, 28)), dec("-2000.00"));
|
||||
assert_eq!(balance(&store, accum, date(2026, 3, 31)), dec("-3000.00"));
|
||||
|
||||
// A schedule month's posting, the same.
|
||||
let prepaid = store
|
||||
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let insurance = store
|
||||
.add_account("6300", "Insurance", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let s = store
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ledger_core::ScheduleKind::PrepaidExpense,
|
||||
total: usd("1200.00"),
|
||||
start: date(2026, 1, 1),
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: insurance,
|
||||
convention: ledger_core::ScheduleConvention::MonthlyEven,
|
||||
})
|
||||
.unwrap();
|
||||
let feb = store.post_schedule_month(s, date(2026, 2, 28)).unwrap();
|
||||
let head = store.head();
|
||||
assert!(matches!(
|
||||
store.reverse_entry(feb, date(2026, 1, 31), "backdated"),
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::InvalidSchedule(_)
|
||||
))
|
||||
));
|
||||
assert_eq!(store.head(), head);
|
||||
store
|
||||
.reverse_entry(feb, date(2026, 2, 28), "doubted")
|
||||
.unwrap();
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 review round 2: a disposal waits only until what the runs carry at
|
||||
/// the end of its month is what they carry now, so a late change can be
|
||||
/// undone on any open day. Hand check: January through May run (5,000.00);
|
||||
/// May reversed on Aug 1, and August closed. A disposal on Jun 15 for
|
||||
/// 7,000.00 is refused (it would charge May again: June would end carrying
|
||||
/// 6,500.00). May re-instated on Sep 2 (August is closed, so it can't be
|
||||
/// undone in August): the disposal is taken, charging June's 15 days,
|
||||
/// 500.00, and clearing 5,500.00. June ends at accumulated depreciation
|
||||
/// 0.00 and expense 5,500.00, gain 500.00; August's end carries the change
|
||||
/// made in August (May's 1,000.00 taken away, a 1,000.00 debit) and
|
||||
/// September's end its correction (0.00), as without the disposal. Before
|
||||
/// this review the disposal was refused for ever: round 1 required what May
|
||||
/// carries at every month end after June to be the same.
|
||||
#[test]
|
||||
fn a_disposal_is_taken_once_a_late_change_is_undone_after_its_month_closed() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let mut may = 0;
|
||||
for m in 1..=5 {
|
||||
may = store
|
||||
.run_depreciation(x, &format!("2026-{m:02}"))
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.unwrap();
|
||||
}
|
||||
let rev = store
|
||||
.reverse_entry(may, date(2026, 8, 1), "doubted")
|
||||
.unwrap();
|
||||
let aug = store
|
||||
.create_period("2026-08", date(2026, 8, 1), date(2026, 8, 31))
|
||||
.unwrap();
|
||||
store.close_period(aug).unwrap();
|
||||
assert!(matches!(
|
||||
store.dispose_preview(x, date(2026, 6, 15), usd("7000.00")),
|
||||
Err(ledger_store::StoreError::Ledger(
|
||||
ledger_core::LedgerError::DepreciationPastDisposal(_)
|
||||
))
|
||||
));
|
||||
store
|
||||
.reverse_entry(rev, date(2026, 9, 2), "it was right")
|
||||
.unwrap();
|
||||
let p = store
|
||||
.dispose_preview(x, date(2026, 6, 15), usd("7000.00"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(p.final_depreciation, p.accumulated, p.gain_loss),
|
||||
(dec("500.00"), dec("5500.00"), dec("500.00"))
|
||||
);
|
||||
store
|
||||
.dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain)
|
||||
.unwrap();
|
||||
let jun = date(2026, 6, 30);
|
||||
assert_eq!(balance(&store, accum, jun), Decimal::ZERO);
|
||||
assert_eq!(balance(&store, expense, jun), dec("5500.00"));
|
||||
assert_eq!(balance(&store, gain, jun), dec("-500.00"));
|
||||
assert_eq!(balance(&store, accum, date(2026, 8, 31)), dec("1000.00"));
|
||||
assert_eq!(balance(&store, accum, date(2026, 9, 30)), Decimal::ZERO);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3 review round 2 (no store test held it): depreciation still due when
|
||||
/// the disposal's month already has a run is charged in the disposal entry
|
||||
/// (catch_up_depreciation), never as a second run of that month. Hand
|
||||
/// check: January through June run (June's 1,000.00 dated Jun 30); March's
|
||||
/// run reversed on Jun 20 (5,000.00 carried at June's end, and now). A
|
||||
/// disposal on Jun 15 for 7,000.00 takes 5,500.00 (held through Jun 15):
|
||||
/// 500.00 still due, debited to depreciation expense in the disposal entry
|
||||
/// (March's 1,000.00 again, less June's last 15 days already in June's
|
||||
/// run). The entry: Dr accumulated depreciation 5,000.00, Dr bank
|
||||
/// 7,000.00, Dr depreciation expense 500.00 / Cr equipment 12,000.00, Cr
|
||||
/// gain 500.00: 12,500.00 a side. No final depreciation entry, June keeps
|
||||
/// one run, no journal feature; June ends at 0.00 and 5,500.00.
|
||||
#[test]
|
||||
fn a_disposal_whose_month_has_run_charges_what_is_still_due_in_its_entry() {
|
||||
let (dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let x = asset_x(&mut store, cost, accum, expense);
|
||||
let mut mar = 0;
|
||||
for m in 1..=6 {
|
||||
let r = store.run_depreciation(x, &format!("2026-{m:02}")).unwrap();
|
||||
if m == 3 {
|
||||
mar = r.entry_id.unwrap();
|
||||
}
|
||||
}
|
||||
store
|
||||
.reverse_entry(mar, date(2026, 6, 20), "doubted")
|
||||
.unwrap();
|
||||
let p = store
|
||||
.dispose_preview(x, date(2026, 6, 15), usd("7000.00"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
p.final_depreciation,
|
||||
p.catch_up_depreciation,
|
||||
p.excess_depreciation
|
||||
),
|
||||
(Decimal::ZERO, dec("500.00"), Decimal::ZERO)
|
||||
);
|
||||
assert_eq!(
|
||||
(p.accumulated, p.book_value, p.gain_loss),
|
||||
(dec("5000.00"), dec("6500.00"), dec("500.00"))
|
||||
);
|
||||
let a = store.ledger().get_asset(x).unwrap().clone();
|
||||
assert_eq!(
|
||||
Store::disposal_lines(&a, &p, dec("7000.00"), bank, gain),
|
||||
vec![
|
||||
(accum, Side::Debit, dec("5000.00")),
|
||||
(bank, Side::Debit, dec("7000.00")),
|
||||
(expense, Side::Debit, dec("500.00")),
|
||||
(cost, Side::Credit, dec("12000.00")),
|
||||
(gain, Side::Credit, dec("500.00")),
|
||||
]
|
||||
);
|
||||
let next = store.ledger().next_entry_id();
|
||||
let disposal = store
|
||||
.dispose_asset(x, date(2026, 6, 15), usd("7000.00"), bank, gain)
|
||||
.unwrap();
|
||||
assert_eq!(disposal, next, "the disposal entry alone");
|
||||
let journal = std::fs::read_to_string(dir.path().join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let junes = journal
|
||||
.lines()
|
||||
.filter(|l| l.contains("asset.depreciation_posted") && l.contains("\"2026-06\""))
|
||||
.count();
|
||||
assert_eq!(junes, 1, "June keeps one run");
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
let on = date(2026, 6, 30);
|
||||
assert_eq!(balance(&store, accum, on), Decimal::ZERO);
|
||||
assert_eq!(balance(&store, expense, on), dec("5500.00"));
|
||||
assert_eq!(balance(&store, gain, on), dec("-500.00"));
|
||||
store.verify().unwrap();
|
||||
}
|
||||
@@ -366,6 +366,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec<Subject> {
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: fx.expense,
|
||||
convention: ledger_core::ScheduleConvention::MonthlyEven,
|
||||
})
|
||||
.unwrap();
|
||||
clear_jan(s, fx);
|
||||
|
||||
@@ -989,3 +989,367 @@ fn an_unknown_feature_before_a_rename_is_newer_at_the_declaration() {
|
||||
other => panic!("expected NewerJournal, got {:?}", other.map(|_| ())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Books with a prepaid account and an expense to schedule against;
|
||||
/// returns (prepaid, insurance).
|
||||
fn schedule_books(dir: &Path) -> (u64, u64) {
|
||||
books(dir);
|
||||
let mut store = Store::open(dir).unwrap();
|
||||
let prepaid = store
|
||||
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let insurance = store
|
||||
.add_account("6300", "Insurance", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
(prepaid, insurance)
|
||||
}
|
||||
|
||||
fn insurance(
|
||||
accounts: (u64, u64),
|
||||
start: NaiveDate,
|
||||
convention: ledger_core::ScheduleConvention,
|
||||
) -> ledger_core::NewSchedule {
|
||||
ledger_core::NewSchedule {
|
||||
description: "Liability insurance".into(),
|
||||
kind: ledger_core::ScheduleKind::PrepaidExpense,
|
||||
total: Money::new(Decimal::from(1200), Currency::USD),
|
||||
start,
|
||||
months: 12,
|
||||
balance_account: accounts.0,
|
||||
pl_account: accounts.1,
|
||||
convention,
|
||||
}
|
||||
}
|
||||
|
||||
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
/// The journal's lines, parsed.
|
||||
fn lines(dir: &Path) -> Vec<serde_json::Value> {
|
||||
std::fs::read_to_string(dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str(l).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// P3 (defect 9; rules.md B-27): the first daily schedule declares journal
|
||||
/// feature schedule_convention, on the line before its schedule.registered,
|
||||
/// which carries convention "daily". A version before P3 knew no features,
|
||||
/// so it stops at that declaration (NewerJournal; format.rs's
|
||||
/// an_older_reader_stops_at_p3s_features), never reading the schedule as
|
||||
/// equal months. This version reads it back, posts by the day, and
|
||||
/// verifies; a second daily schedule declares nothing more.
|
||||
#[test]
|
||||
fn the_first_daily_schedule_declares_its_feature_and_an_older_reader_stops_there() {
|
||||
let dir = test_dir("daily-feature");
|
||||
let accounts = schedule_books(&dir);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
let id = store
|
||||
.register_schedule(insurance(
|
||||
accounts,
|
||||
day(2026, 3, 15),
|
||||
ledger_core::ScheduleConvention::Daily,
|
||||
))
|
||||
.unwrap();
|
||||
let journal = lines(&dir);
|
||||
let n = journal.len();
|
||||
assert_eq!(journal[n - 1]["type"], "schedule.registered");
|
||||
assert_eq!(journal[n - 1]["payload"]["convention"], "daily");
|
||||
assert_eq!(journal[n - 2]["type"], FORMAT_SET);
|
||||
assert_eq!(
|
||||
journal[n - 2]["payload"],
|
||||
json!({"format": 2, "features": ["schedule_convention"]})
|
||||
);
|
||||
store.run_schedules(day(2026, 5, 31)).unwrap();
|
||||
store
|
||||
.register_schedule(insurance(
|
||||
accounts,
|
||||
day(2026, 4, 2),
|
||||
ledger_core::ScheduleConvention::Daily,
|
||||
))
|
||||
.unwrap();
|
||||
let declared = lines(&dir)
|
||||
.iter()
|
||||
.filter(|l| l["type"] == FORMAT_SET)
|
||||
.count();
|
||||
assert_eq!(declared, 1, "declared once");
|
||||
store.verify().unwrap();
|
||||
let posted: Vec<NaiveDate> = store
|
||||
.ledger()
|
||||
.get_schedule(id)
|
||||
.unwrap()
|
||||
.posted
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
posted,
|
||||
[day(2026, 3, 31), day(2026, 4, 30), day(2026, 5, 31)]
|
||||
);
|
||||
drop(store);
|
||||
// Reopened, rebuilt, and through the viewer: the same plan, posted.
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
store.rebuild().unwrap();
|
||||
store.verify().unwrap();
|
||||
let s = store.ledger().get_schedule(id).unwrap();
|
||||
assert_eq!(s.convention, ledger_core::ScheduleConvention::Daily);
|
||||
assert_eq!(s.plan().unwrap()[0].1, Decimal::new(5484, 2));
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert_eq!(
|
||||
snap.ledger().differs_from(store.ledger()),
|
||||
Vec::<&str>::new()
|
||||
);
|
||||
}
|
||||
|
||||
/// P3: an equal-months schedule (journals before P3 hold only these)
|
||||
/// declares no feature: books that never use a daily schedule stay
|
||||
/// readable by versions before it. Its line names its convention.
|
||||
#[test]
|
||||
fn a_monthly_even_schedule_declares_no_feature() {
|
||||
let dir = test_dir("monthly-even-no-feature");
|
||||
let accounts = schedule_books(&dir);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
store
|
||||
.register_schedule(insurance(
|
||||
accounts,
|
||||
day(2026, 3, 1),
|
||||
ledger_core::ScheduleConvention::MonthlyEven,
|
||||
))
|
||||
.unwrap();
|
||||
let journal = lines(&dir);
|
||||
assert!(journal.iter().all(|l| l["type"] != FORMAT_SET));
|
||||
assert_eq!(
|
||||
journal.last().unwrap()["payload"]["convention"],
|
||||
"monthly_even"
|
||||
);
|
||||
assert_eq!(store.journal_format(), &JournalFormat::default());
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
/// P3: a schedule convention this version doesn't know (one a newer
|
||||
/// version adds) is newer, never corrupt (rules.md B-27); one that isn't a
|
||||
/// string is damage.
|
||||
#[test]
|
||||
fn an_unknown_schedule_convention_is_newer_not_corrupt() {
|
||||
let dir = test_dir("unknown-convention");
|
||||
let accounts = schedule_books(&dir);
|
||||
let registered = |convention: serde_json::Value| {
|
||||
json!({
|
||||
"id": 1,
|
||||
"description": "Insurance",
|
||||
"kind": "prepaid_expense",
|
||||
"total": {"amount": "1200", "currency": "USD"},
|
||||
"start": "2026-03-15",
|
||||
"months": 12,
|
||||
"balance_account": accounts.0,
|
||||
"pl_account": accounts.1,
|
||||
"convention": convention,
|
||||
})
|
||||
};
|
||||
let seq = append(
|
||||
&dir,
|
||||
&[("schedule.registered", registered(json!("weekly")))],
|
||||
)[0];
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
for got in [Store::open(&dir).err(), Snapshot::load(&dir).err()] {
|
||||
match got {
|
||||
Some(StoreError::NewerJournal { seq: s, need, .. }) => {
|
||||
assert_eq!(s, seq);
|
||||
assert_eq!(need, "the schedule convention weekly");
|
||||
}
|
||||
other => panic!("expected NewerJournal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
let dir = test_dir("bad-convention");
|
||||
let accounts2 = schedule_books(&dir);
|
||||
assert_eq!(accounts2, accounts);
|
||||
append(&dir, &[("schedule.registered", registered(json!(7)))]);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
assert_corrupt(Store::open(&dir));
|
||||
}
|
||||
|
||||
/// P3 (R3; rules.md B-27): a schedule month reversed within its month is
|
||||
/// posted again only after journal feature month_reposting is declared
|
||||
/// (an older version would refuse the second schedule.posted as posted
|
||||
/// twice), and the books replay to the same ledger. A month reversed after
|
||||
/// it ended is left by the run, listed with why (it would count twice until
|
||||
/// the reversal), and declares nothing. Hand check: equal months of 100.00
|
||||
/// from 2026-03-01; March posted, reversed on Mar 31, run again: one
|
||||
/// March, 100.00; April posted, reversed on May 2: April held.
|
||||
#[test]
|
||||
fn a_schedule_month_posted_again_declares_month_reposting_and_replays() {
|
||||
let dir = test_dir("schedule-repost");
|
||||
let accounts = schedule_books(&dir);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
let id = store
|
||||
.register_schedule(insurance(
|
||||
accounts,
|
||||
day(2026, 3, 1),
|
||||
ledger_core::ScheduleConvention::MonthlyEven,
|
||||
))
|
||||
.unwrap();
|
||||
let mar = store.post_schedule_month(id, day(2026, 3, 31)).unwrap();
|
||||
store
|
||||
.reverse_entry(mar, day(2026, 3, 31), "posted early")
|
||||
.unwrap();
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
let posted = store.run_schedules(day(2026, 3, 31)).unwrap();
|
||||
assert_eq!(posted.len(), 1);
|
||||
assert_eq!(
|
||||
store.journal_format().features,
|
||||
vec![ledger_store::format::MONTH_REPOSTING.to_string()]
|
||||
);
|
||||
let apr = store.post_schedule_month(id, day(2026, 4, 30)).unwrap();
|
||||
store
|
||||
.reverse_entry(apr, day(2026, 5, 2), "wrong policy")
|
||||
.unwrap();
|
||||
let head = store.head();
|
||||
let posted = store.run_schedules(day(2026, 4, 30)).unwrap();
|
||||
assert!(posted.is_empty(), "{posted:?}");
|
||||
assert_eq!(store.head(), head);
|
||||
let held = store.schedule_months_held(day(2026, 4, 30)).unwrap();
|
||||
assert_eq!(held.len(), 1);
|
||||
assert_eq!((held[0].0, held[0].1), (id, day(2026, 4, 30)));
|
||||
assert!(held[0].2.contains("2026-05-02"), "{}", held[0].2);
|
||||
store.verify().unwrap();
|
||||
drop(store);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
store.rebuild().unwrap();
|
||||
store.verify().unwrap();
|
||||
let s = store.ledger().get_schedule(id).unwrap();
|
||||
assert!(s.posted.contains(&day(2026, 3, 31)));
|
||||
assert!(!s.posted.contains(&day(2026, 4, 30)));
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert_eq!(
|
||||
snap.ledger().differs_from(store.ledger()),
|
||||
Vec::<&str>::new()
|
||||
);
|
||||
}
|
||||
|
||||
/// P3 review round 2 (the format-upgrade gate refuses a feature until the
|
||||
/// owner consents): every command that needs a journal feature decides it,
|
||||
/// and is refused, before its first journaled line, so a refused feature
|
||||
/// journals nothing: never a depreciation entry without the
|
||||
/// `asset.depreciation_posted` that makes it the period's run, nor a
|
||||
/// disposal's final depreciation without its line. Each command: a period
|
||||
/// run again after its run was reversed (month_reposting), a disposal whose
|
||||
/// month's run was reversed (its final depreciation, month_reposting), a
|
||||
/// daily schedule (schedule_convention), and a schedule month posted again
|
||||
/// (month_reposting). Taken again, each journals and the books verify.
|
||||
#[test]
|
||||
fn a_refused_journal_feature_journals_nothing() {
|
||||
let dir = test_dir("feature-refused");
|
||||
let accounts = schedule_books(&dir);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
let add = |s: &mut Store, code: &str, t| s.add_account(code, code, t, None, false).unwrap();
|
||||
let cost = add(&mut store, "1500", AccountType::Asset);
|
||||
let accum = add(&mut store, "1501", AccountType::Asset);
|
||||
let expense = add(&mut store, "6100", AccountType::Expense);
|
||||
let gain = add(&mut store, "4900", AccountType::Revenue);
|
||||
let cash = store.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
let usd = |a: i64| Money::new(Decimal::new(a, 2), Currency::USD);
|
||||
let lathe = |s: &mut Store, what: &str| {
|
||||
s.register_asset(ledger_core::NewAsset {
|
||||
description: what.into(),
|
||||
purchase_date: day(2026, 1, 1),
|
||||
cost: usd(1_200_000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
// One asset's January run, reversed within January: due again.
|
||||
let asset = lathe(&mut store, "Lathe");
|
||||
let run = store.run_depreciation(asset, "2026-01").unwrap();
|
||||
store
|
||||
.reverse_entry(run.entry_id.unwrap(), day(2026, 1, 31), "doubted")
|
||||
.unwrap();
|
||||
// Another's January to March run, March reversed within March: a
|
||||
// disposal on Mar 15 posts March's final depreciation again.
|
||||
let sold = lathe(&mut store, "Old lathe");
|
||||
for m in ["2026-01", "2026-02"] {
|
||||
store.run_depreciation(sold, m).unwrap();
|
||||
}
|
||||
let mar = store.run_depreciation(sold, "2026-03").unwrap();
|
||||
store
|
||||
.reverse_entry(mar.entry_id.unwrap(), day(2026, 3, 31), "sold")
|
||||
.unwrap();
|
||||
let monthly = store
|
||||
.register_schedule(insurance(
|
||||
accounts,
|
||||
day(2026, 1, 1),
|
||||
ledger_core::ScheduleConvention::MonthlyEven,
|
||||
))
|
||||
.unwrap();
|
||||
let jan = store
|
||||
.post_schedule_month(monthly, day(2026, 1, 31))
|
||||
.unwrap();
|
||||
store
|
||||
.reverse_entry(jan, day(2026, 1, 31), "doubted")
|
||||
.unwrap();
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
|
||||
store.refuse_new_features(true);
|
||||
let head = store.head();
|
||||
let refused = |r: Result<(), StoreError>, what: &str| match r {
|
||||
Err(StoreError::FormatRefused(why)) => assert!(why.contains("feature"), "{what}: {why}"),
|
||||
other => panic!("{what}: {other:?}"),
|
||||
};
|
||||
refused(
|
||||
store.run_depreciation(asset, "2026-01").map(|_| ()),
|
||||
"a period run again",
|
||||
);
|
||||
refused(
|
||||
store
|
||||
.dispose_asset(sold, day(2026, 3, 15), usd(900_000), cash, gain)
|
||||
.map(|_| ()),
|
||||
"a disposal's final depreciation",
|
||||
);
|
||||
refused(
|
||||
store
|
||||
.register_schedule(insurance(
|
||||
accounts,
|
||||
day(2026, 3, 15),
|
||||
ledger_core::ScheduleConvention::Daily,
|
||||
))
|
||||
.map(|_| ()),
|
||||
"a daily schedule",
|
||||
);
|
||||
refused(
|
||||
store
|
||||
.post_schedule_month(monthly, day(2026, 1, 31))
|
||||
.map(|_| ()),
|
||||
"a schedule month posted again",
|
||||
);
|
||||
assert_eq!(store.head(), head, "nothing journaled");
|
||||
assert_eq!(store.ledger().next_entry_id(), {
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
snap.ledger().next_entry_id()
|
||||
});
|
||||
store.verify().unwrap();
|
||||
|
||||
store.refuse_new_features(false);
|
||||
assert!(
|
||||
store
|
||||
.run_depreciation(asset, "2026-01")
|
||||
.unwrap()
|
||||
.entry_id
|
||||
.is_some()
|
||||
);
|
||||
store
|
||||
.post_schedule_month(monthly, day(2026, 1, 31))
|
||||
.unwrap();
|
||||
store
|
||||
.dispose_asset(sold, day(2026, 3, 15), usd(900_000), cash, gain)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.journal_format().features,
|
||||
vec![ledger_store::format::MONTH_REPOSTING.to_string()]
|
||||
);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
@@ -664,6 +664,7 @@ fn schedules_and_recurring_entries_replay_exactly() {
|
||||
months: 3,
|
||||
balance_account: prepaid,
|
||||
pl_account: ins,
|
||||
convention: ledger_core::ScheduleConvention::MonthlyEven,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
@@ -1033,7 +1034,7 @@ mod refusals {
|
||||
DisposeAsset(u64, u32, i64, u64, u64),
|
||||
RunPayroll(u32, i64, i64, u64),
|
||||
Remit(u32, u64),
|
||||
RegisterSchedule(bool, i64, u32, u32, u64, u64),
|
||||
RegisterSchedule(bool, i64, u32, u32, u64, u64, bool),
|
||||
PostScheduleMonth(u64, u32),
|
||||
RegisterRecurring(u64, u64, i64, u32),
|
||||
RunRecurring(u32),
|
||||
@@ -1045,7 +1046,7 @@ mod refusals {
|
||||
// that need a chain of earlier steps get exercised too.
|
||||
Book(u32, u64, u64, i64),
|
||||
GoodAsset(i64, u32),
|
||||
GoodSchedule(bool, i64, u32),
|
||||
GoodSchedule(bool, i64, u32, bool),
|
||||
GoodInvoice(u32, i64),
|
||||
GoodBill(u32, i64),
|
||||
GoodClose(u32),
|
||||
@@ -1055,6 +1056,16 @@ mod refusals {
|
||||
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n % 120))
|
||||
}
|
||||
|
||||
/// A schedule's convention: by the day, or equal months (refused
|
||||
/// unless it starts on the 1st, D-37).
|
||||
fn convention(daily: bool) -> ledger_core::ScheduleConvention {
|
||||
if daily {
|
||||
ledger_core::ScheduleConvention::Daily
|
||||
} else {
|
||||
ledger_core::ScheduleConvention::MonthlyEven
|
||||
}
|
||||
}
|
||||
|
||||
/// Cents, biased toward the edges: zero, negative, tiny, huge.
|
||||
fn arb_cents() -> impl Strategy<Value = i64> {
|
||||
prop_oneof![
|
||||
@@ -1135,9 +1146,12 @@ mod refusals {
|
||||
0u32..120,
|
||||
0u32..30,
|
||||
0u64..10,
|
||||
0u64..10
|
||||
0u64..10,
|
||||
any::<bool>()
|
||||
)
|
||||
.prop_map(|(k, t, st, m, b, p)| Op::RegisterSchedule(k, t, st, m, b, p)),
|
||||
.prop_map(|(k, t, st, m, b, p, daily)| Op::RegisterSchedule(
|
||||
k, t, st, m, b, p, daily
|
||||
)),
|
||||
(0u64..4, 0u32..12).prop_map(|(s, m)| Op::PostScheduleMonth(s, m)),
|
||||
(0u64..10, 0u64..10, arb_cents(), 0u32..120)
|
||||
.prop_map(|(a, b, c, d)| Op::RegisterRecurring(a, b, c, d)),
|
||||
@@ -1159,8 +1173,8 @@ mod refusals {
|
||||
(0u32..120, 1u64..10, 1u64..10, 1i64..500_000)
|
||||
.prop_map(|(d, a, b, c)| Op::Book(d, a, b, c)),
|
||||
(1i64..5_000_000, 1u32..60).prop_map(|(c, m)| Op::GoodAsset(c, m)),
|
||||
(any::<bool>(), 1i64..1_000_000, 1u32..24)
|
||||
.prop_map(|(k, t, m)| Op::GoodSchedule(k, t, m)),
|
||||
(any::<bool>(), 1i64..1_000_000, 1u32..24, any::<bool>())
|
||||
.prop_map(|(k, t, m, daily)| Op::GoodSchedule(k, t, m, daily)),
|
||||
(0u32..120, 1i64..1_000_000).prop_map(|(d, c)| Op::GoodInvoice(d, c)),
|
||||
(0u32..120, 1i64..1_000_000).prop_map(|(d, c)| Op::GoodBill(d, c)),
|
||||
(1u32..5).prop_map(Op::GoodClose),
|
||||
@@ -1384,7 +1398,7 @@ mod refusals {
|
||||
)
|
||||
.map(drop),
|
||||
Op::Remit(d, c) => store.remit_payroll_taxes(day(d), c).map(drop),
|
||||
Op::RegisterSchedule(k, t, st, m, b, pl) => store
|
||||
Op::RegisterSchedule(k, t, st, m, b, pl, daily) => store
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: "sched".into(),
|
||||
kind: if k {
|
||||
@@ -1397,6 +1411,7 @@ mod refusals {
|
||||
months: m,
|
||||
balance_account: b,
|
||||
pl_account: pl,
|
||||
convention: convention(daily),
|
||||
})
|
||||
.map(drop),
|
||||
Op::PostScheduleMonth(id, m) => {
|
||||
@@ -1440,7 +1455,7 @@ mod refusals {
|
||||
expense_account: 9,
|
||||
})
|
||||
.map(drop),
|
||||
Op::GoodSchedule(k, t, m) => store
|
||||
Op::GoodSchedule(k, t, m, daily) => store
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: "insurance".into(),
|
||||
kind: if k {
|
||||
@@ -1453,6 +1468,7 @@ mod refusals {
|
||||
months: m,
|
||||
balance_account: if k { 1 } else { 6 },
|
||||
pl_account: if k { 8 } else { 7 },
|
||||
convention: convention(daily),
|
||||
})
|
||||
.map(drop),
|
||||
Op::GoodInvoice(d, c) => {
|
||||
@@ -1976,8 +1992,8 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
|
||||
tally(s.due(d).map(|_| ()));
|
||||
}
|
||||
for a in l.registered_assets() {
|
||||
let _ = ledger_core::depreciation_schedule(a, d);
|
||||
let _ = ledger_core::book_value(a, d);
|
||||
tally(ledger_core::depreciation_schedule(a, d).map(|_| ()));
|
||||
tally(ledger_core::book_value(a, d).map(|_| ()));
|
||||
tally(
|
||||
ledger_core::disposal_amounts(
|
||||
a,
|
||||
@@ -2863,6 +2879,7 @@ fn replay_refuses_a_schedule_posting_it_does_not_rederive() {
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: insurance,
|
||||
convention: ledger_core::ScheduleConvention::MonthlyEven,
|
||||
})
|
||||
.unwrap();
|
||||
store.post_schedule_month(id, day(1, 31)).unwrap();
|
||||
|
||||
Reference in new issue
Block a user