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
@@ -1984,19 +1984,22 @@ fn mcp_schedules_and_recurring_over_stdio() {
|
||||
);
|
||||
}
|
||||
|
||||
// 1,000.00 of insurance over 3 months: 333.33, 333.33, 333.34.
|
||||
// 1,000.00 of insurance over 3 equal months: 333.33, 333.33, 333.34
|
||||
// (an equal-months schedule starts on the 1st, D-37).
|
||||
let s = mcp.call(
|
||||
"books.create_schedule",
|
||||
json!({
|
||||
"kind": "prepaid_expense",
|
||||
"description": "Liability insurance",
|
||||
"total": "1000.00",
|
||||
"start": "2026-11-15",
|
||||
"start": "2026-11-01",
|
||||
"months": 3,
|
||||
"balance_account": "1300",
|
||||
"pl_account": "6300",
|
||||
"convention": "monthly_even",
|
||||
}),
|
||||
);
|
||||
assert_eq!(s["convention"], "monthly_even");
|
||||
let amounts: Vec<&str> = s["months"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -3257,3 +3260,106 @@ fn rename_says_what_the_new_name_moves_defect_15() {
|
||||
);
|
||||
assert!(!notes(&out).contains("Convention"), "{out}");
|
||||
}
|
||||
|
||||
/// Defect 9 end to end (plan/served.md D-11, D-37): a schedule created
|
||||
/// without a convention spreads by the day; books.list_schedules says how,
|
||||
/// the first and last day covered, the next month to post, and what
|
||||
/// remains; a run posts by the day; with books.schedule_convention set to
|
||||
/// monthly_even, new schedules are equal months, and one that doesn't
|
||||
/// start on the 1st is refused. Hand check: 1,200.00 from 2026-03-15 for
|
||||
/// 12 months: March 100 × 17/31 = 54.84, then 100.00 a month, the last
|
||||
/// March 45.16; through April 30, 154.84 posted, 1,045.16 left.
|
||||
#[test]
|
||||
fn mcp_daily_schedule_over_stdio() {
|
||||
let dir = test_dir("daily-schedule");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
init(&mut mcp);
|
||||
for (code, name, typ) in [
|
||||
("1300", "Prepaid Expenses", "asset"),
|
||||
("6300", "Insurance", "expense"),
|
||||
] {
|
||||
mcp.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
let create = |start: &str| {
|
||||
json!({
|
||||
"kind": "prepaid_expense",
|
||||
"description": "Liability insurance",
|
||||
"total": "1200.00",
|
||||
"start": start,
|
||||
"months": 12,
|
||||
"balance_account": "1300",
|
||||
"pl_account": "6300",
|
||||
})
|
||||
};
|
||||
let s = mcp.call("books.create_schedule", create("2026-03-15"));
|
||||
assert_eq!(s["convention"], "daily");
|
||||
assert_eq!(s["start"], "2026-03-15");
|
||||
assert_eq!(s["end"], "2027-03-14");
|
||||
assert_eq!(s["next"], "2026-03-31");
|
||||
assert_eq!(s["remaining"], "1200.00");
|
||||
let months = s["months"].as_array().unwrap();
|
||||
assert_eq!(months.len(), 13);
|
||||
assert_eq!(months[0]["amount"], "54.84");
|
||||
assert_eq!(months[1]["amount"], "100.00");
|
||||
assert_eq!(months[12]["date"], "2027-03-31");
|
||||
assert_eq!(months[12]["amount"], "45.16");
|
||||
|
||||
let preview = mcp.call("books.run_schedules", json!({"through": "2026-04-30"}));
|
||||
assert_eq!(preview["status"], "preview");
|
||||
let amounts: Vec<&str> = preview["postings"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["amount"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(amounts, ["54.84", "100.00"]);
|
||||
assert_eq!(preview["skipped"], json!([]));
|
||||
let run = mcp.call(
|
||||
"books.run_schedules",
|
||||
json!({"through": "2026-04-30", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(run["postings"].as_array().unwrap().len(), 2);
|
||||
let list = mcp.call("books.list_schedules", json!({}));
|
||||
let one = &list["schedules"][0];
|
||||
assert_eq!(one["convention"], "daily");
|
||||
assert_eq!(one["next"], "2026-05-31");
|
||||
assert_eq!(one["remaining"], "1045.16");
|
||||
|
||||
// The books take their settings in (there was no file: the defaults),
|
||||
// then new schedules are set to equal months.
|
||||
let done = mcp.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": "", "decided_by": "Owner", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(done["status"], "imported");
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.schedule_convention", "value": "monthly_even"}],
|
||||
"decided_by": "Owner", "decided_on": "2026-10-20"}),
|
||||
);
|
||||
let err = mcp.call_err("books.create_schedule", create("2026-03-15"));
|
||||
assert!(err.contains("2026-03-01"), "{err}");
|
||||
let even = mcp.call("books.create_schedule", create("2026-03-01"));
|
||||
assert_eq!(even["convention"], "monthly_even");
|
||||
assert_eq!(even["months"].as_array().unwrap().len(), 12);
|
||||
assert_eq!(even["end"], "2027-02-28");
|
||||
// A convention asked for wins over the setting; an unknown one is
|
||||
// refused, naming the two.
|
||||
let daily = mcp.call(
|
||||
"books.create_schedule",
|
||||
json!({"kind": "prepaid_expense", "description": "x", "total": "10.00",
|
||||
"start": "2026-05-20", "months": 1, "balance_account": "1300",
|
||||
"pl_account": "6300", "convention": "daily"}),
|
||||
);
|
||||
assert_eq!(daily["convention"], "daily");
|
||||
let err = mcp.call_err(
|
||||
"books.create_schedule",
|
||||
json!({"kind": "prepaid_expense", "description": "x", "total": "10.00",
|
||||
"start": "2026-05-01", "months": 1, "balance_account": "1300",
|
||||
"pl_account": "6300", "convention": "weekly"}),
|
||||
);
|
||||
assert!(err.contains("daily or monthly_even"), "{err}");
|
||||
}
|
||||
@@ -2012,11 +2012,19 @@ fn dispose_asset_previews_equal_the_store_and_gate_receipts_on_debits() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Run-all depreciation skips an asset whose life doesn't touch the period
|
||||
/// and posts the others by their schedule. Kills: run_depreciation's
|
||||
/// period match.
|
||||
/// Run-all depreciation posts each asset what its schedule says is due
|
||||
/// through the month's end (plan/served.md P3, settled in design: a month
|
||||
/// skipped is caught up in the month the run is made), and skips an asset
|
||||
/// not yet in service, or one with nothing due. Hand check: "Short-lived"
|
||||
/// is 600.00 over 2 months from 2025-01-01 (life Jan-Feb 2025), never run;
|
||||
/// "Long-lived" 600.00 over 12 months from 2025-05-01, 50.00 a month, May
|
||||
/// never run. A June run posts the first's whole 600.00 (all caught up)
|
||||
/// and the second's May and June, 100.00 (50.00 caught up). An April run
|
||||
/// then skips both: the second isn't in service yet, and the first's life
|
||||
/// is over with June's run carrying all of it. Kills: run_depreciation's
|
||||
/// catch-up and its skips.
|
||||
#[test]
|
||||
fn run_depreciation_skips_assets_outside_their_life() {
|
||||
fn run_depreciation_catches_up_and_skips_what_has_nothing_due() {
|
||||
let dir = test_dir("depr");
|
||||
let mut s = new_store(&dir);
|
||||
let reg = |s: &mut Store, desc: &str, date: &str, months: u32| {
|
||||
@@ -2034,23 +2042,37 @@ fn run_depreciation_skips_assets_outside_their_life() {
|
||||
};
|
||||
let old = reg(&mut s, "Short-lived", "2025-01-01", 2);
|
||||
let new = reg(&mut s, "Long-lived", "2025-05-01", 12);
|
||||
let sched = depreciation_schedule(s.ledger().get_asset(new).unwrap(), d("2025-06-30"));
|
||||
let sched = depreciation_schedule(s.ledger().get_asset(new).unwrap(), d("2025-06-30")).unwrap();
|
||||
let june = sched.iter().find(|l| l.period == "2025-06").unwrap().amount;
|
||||
assert!(
|
||||
!depreciation_schedule(s.ledger().get_asset(old).unwrap(), d("2025-06-30"))
|
||||
.iter()
|
||||
.any(|l| l.period == "2025-06")
|
||||
);
|
||||
assert_eq!(june, dec("50.00"));
|
||||
drop(s);
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
let out = m.ok("books.run_depreciation", json!({"period": "2025-06"}));
|
||||
let runs = out["runs"].as_array().unwrap();
|
||||
assert_eq!(runs.len(), 1, "{out}");
|
||||
assert_eq!(runs[0]["asset_id"], json!(new));
|
||||
assert_eq!(jamt(&runs[0]["amount"]), june);
|
||||
assert_eq!(runs.len(), 2, "{out}");
|
||||
let run = |id: u64| {
|
||||
runs.iter()
|
||||
.find(|r| r["asset_id"] == json!(id))
|
||||
.unwrap_or_else(|| panic!("{id} in {out}"))
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(jamt(&run(old)["amount"]), dec("600.00"));
|
||||
assert_eq!(jamt(&run(old)["caught_up"]), dec("600.00"));
|
||||
assert_eq!(jamt(&run(new)["amount"]), dec("100.00"));
|
||||
assert_eq!(jamt(&run(new)["caught_up"]), dec("50.00"));
|
||||
assert_eq!(out["skipped"], json!([]));
|
||||
let out = m.ok("books.run_depreciation", json!({"period": "2025-04"}));
|
||||
assert_eq!(out["runs"], json!([]), "{out}");
|
||||
let skipped = out["skipped"].as_array().unwrap();
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert_eq!(skipped[0]["asset_id"], json!(old));
|
||||
assert_eq!(skipped.len(), 2, "{out}");
|
||||
let why = |id: u64| {
|
||||
skipped.iter().find(|r| r["asset_id"] == json!(id)).unwrap()["reason"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
};
|
||||
assert!(why(new).contains("placed in service 2025-05-01"), "{out}");
|
||||
assert!(why(old).contains("life is over"), "{out}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- payroll
|
||||
|
||||
@@ -1246,3 +1246,190 @@ fn export_fixed_asset_register_carries_tax_basis() {
|
||||
let total = csv_row("TOTAL");
|
||||
assert_eq!(canon(&total[c_tax]), "3050.25");
|
||||
}
|
||||
|
||||
// ------------------------------------- P3: defect 22 and runs, over MCP
|
||||
|
||||
/// Books holding asset X of the P3 depreciation brief (12,000.00, no
|
||||
/// salvage, 12 months from 2026-01-01: 1,000.00 a month), paid for.
|
||||
fn asset_x(name: &str) -> (Mcp, u64) {
|
||||
let mut m = books(name);
|
||||
m.post(
|
||||
"2026-01-01",
|
||||
&[
|
||||
("1000", "debit", "20000.00"),
|
||||
("3000", "credit", "20000.00"),
|
||||
],
|
||||
);
|
||||
m.post(
|
||||
"2026-01-01",
|
||||
&[
|
||||
("1500", "debit", "12000.00"),
|
||||
("1000", "credit", "12000.00"),
|
||||
],
|
||||
);
|
||||
let (x, _) = m.register_ok(json!({"cost": "12000.00"}));
|
||||
(m, x)
|
||||
}
|
||||
|
||||
fn dispose_args(x: u64, date: &str, proceeds: &str) -> Value {
|
||||
json!({"asset_id": x, "date": date, "proceeds": proceeds,
|
||||
"proceeds_account": "1000", "gain_loss_account": "7100"})
|
||||
}
|
||||
|
||||
/// Defect 22, reproduction B over MCP: January through May run, then a
|
||||
/// disposal on 2026-06-15 for 7,000.00. Hand check: June held 15 of 30
|
||||
/// days, 500.00 (the final depreciation); accumulated 5,500.00, book value
|
||||
/// 6,500.00, gain 500.00. Afterwards books.asset_schedule ends there and
|
||||
/// books.list_assets says the same. Before P3 the schedule showed June
|
||||
/// 7,000.00, accumulated 12,000.00, book value 0.00.
|
||||
#[test]
|
||||
fn a_disposed_asset_lists_what_it_depreciated_through_its_disposal() {
|
||||
let (mut m, x) = asset_x("disposed-figures");
|
||||
for p in ["2026-01", "2026-02", "2026-03", "2026-04", "2026-05"] {
|
||||
m.ok(
|
||||
"books.run_depreciation",
|
||||
json!({"period": p, "asset_id": x}),
|
||||
);
|
||||
}
|
||||
let preview = m.ok(
|
||||
"books.dispose_asset",
|
||||
dispose_args(x, "2026-06-15", "7000.00"),
|
||||
);
|
||||
assert_eq!(the_value(&preview, "final_depreciation"), "500");
|
||||
assert_eq!(the_value(&preview, "excess_depreciation"), "0");
|
||||
assert_eq!(the_value(&preview, "book_value"), "6500");
|
||||
assert_eq!(the_value(&preview, "gain_loss"), "500");
|
||||
let doc = m.file_bill_of_sale();
|
||||
let mut confirm = dispose_args(x, "2026-06-15", "7000.00");
|
||||
confirm["confirmed"] = json!(true);
|
||||
confirm["receipt_attachment_ids"] = json!([doc]);
|
||||
m.ok("books.dispose_asset", confirm);
|
||||
let s = m.read_only("books.asset_schedule", json!({"asset_id": x}));
|
||||
assert_eq!(s["status"], "disposed");
|
||||
let lines = s["schedule"].as_array().unwrap();
|
||||
let jun = lines.last().unwrap();
|
||||
assert_eq!(jun["period"], "2026-06");
|
||||
assert_eq!(canon(jun["amount"].as_str().unwrap()), "500");
|
||||
assert_eq!(canon(jun["accumulated"].as_str().unwrap()), "5500");
|
||||
assert_eq!(canon(jun["book_value"].as_str().unwrap()), "6500");
|
||||
let listed = m.asset(x);
|
||||
assert_eq!(canon(listed["accumulated"].as_str().unwrap()), "5500");
|
||||
assert_eq!(canon(listed["book_value"].as_str().unwrap()), "6500");
|
||||
assert_eq!(listed["schedule_error"], Value::Null);
|
||||
}
|
||||
|
||||
/// P3 depreciation review R2 over MCP: with June already run (dated Jun
|
||||
/// 30), a disposal on Jun 15 for 7,000.00 credits back June's last 15
|
||||
/// days. Hand check: the runs carry 6,000.00; the schedule through Jun 15
|
||||
/// is 5,500.00, so 500.00 is excess; book value 6,500.00, gain 500.00. The
|
||||
/// entry: Dr 1510 6,000.00, Dr 1000 7,000.00 / Cr 1500 12,000.00, Cr 6500
|
||||
/// 500.00, Cr 7100 500.00.
|
||||
#[test]
|
||||
fn a_disposal_inside_a_posted_month_takes_back_the_extra_days() {
|
||||
let (mut m, x) = asset_x("disposed-inside");
|
||||
for p in 1..=6 {
|
||||
m.ok(
|
||||
"books.run_depreciation",
|
||||
json!({"period": format!("2026-{p:02}"), "asset_id": x}),
|
||||
);
|
||||
}
|
||||
let preview = m.ok(
|
||||
"books.dispose_asset",
|
||||
dispose_args(x, "2026-06-15", "7000.00"),
|
||||
);
|
||||
assert_eq!(the_value(&preview, "final_depreciation"), "0");
|
||||
assert_eq!(the_value(&preview, "excess_depreciation"), "500");
|
||||
assert_eq!(the_value(&preview, "catch_up_depreciation"), "0");
|
||||
assert_eq!(the_value(&preview, "accumulated"), "6000");
|
||||
assert_eq!(the_value(&preview, "book_value"), "6500");
|
||||
assert_eq!(the_value(&preview, "gain_loss"), "500");
|
||||
let line = |code: &str| {
|
||||
preview["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|l| l["code"] == code)
|
||||
.unwrap_or_else(|| panic!("no {code} line in {preview}"))
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(line("6500")["side"], "credit");
|
||||
assert_eq!(canon(line("6500")["amount"].as_str().unwrap()), "500");
|
||||
assert_eq!(canon(line("1510")["amount"].as_str().unwrap()), "6000");
|
||||
}
|
||||
|
||||
/// P3 depreciation review R10 over MCP: a run-all for a month whose
|
||||
/// depreciation rounds to 0.00 posts nothing for that asset and lists it
|
||||
/// under skipped, with why; journaling nothing. Hand check: 0.01 over 3
|
||||
/// months from 2026-01-01: January's cumulative a third of a cent, 0.00.
|
||||
#[test]
|
||||
fn run_depreciation_skips_a_month_that_rounds_to_nothing() {
|
||||
let mut m = books("zero-month");
|
||||
let (a, _) = m.register_ok(json!({"cost": "0.01", "useful_life_months": 3}));
|
||||
let before = m.journal();
|
||||
let r = m.ok("books.run_depreciation", json!({"period": "2026-01"}));
|
||||
assert_eq!(r["runs"], json!([]));
|
||||
let skipped = r["skipped"].as_array().unwrap();
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert_eq!(skipped[0]["asset_id"], json!(a));
|
||||
assert!(
|
||||
skipped[0]["reason"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("rounds to nothing"),
|
||||
"{r}"
|
||||
);
|
||||
assert_eq!(m.journal(), before, "nothing journaled");
|
||||
let r = m.ok("books.run_depreciation", json!({"period": "2026-02"}));
|
||||
assert_eq!(canon(r["runs"][0]["amount"].as_str().unwrap()), "0.01");
|
||||
}
|
||||
|
||||
/// The catch-up rule over MCP (plan/served.md P3, settled in design):
|
||||
/// January run, February skipped, a March run posts 2,000.00, of which
|
||||
/// 1,000.00 is caught up; a run reversed within its month is due again
|
||||
/// (reversed after it, the next run catches it up: the store's
|
||||
/// a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month), and
|
||||
/// a disposal entry can't be reversed.
|
||||
#[test]
|
||||
fn a_skipped_month_is_caught_up_and_said() {
|
||||
let (mut m, x) = asset_x("caught-up");
|
||||
m.ok(
|
||||
"books.run_depreciation",
|
||||
json!({"period": "2026-01", "asset_id": x}),
|
||||
);
|
||||
let r = m.ok(
|
||||
"books.run_depreciation",
|
||||
json!({"period": "2026-03", "asset_id": x}),
|
||||
);
|
||||
assert_eq!(canon(r["runs"][0]["amount"].as_str().unwrap()), "2000");
|
||||
assert_eq!(canon(r["runs"][0]["caught_up"].as_str().unwrap()), "1000");
|
||||
let jan_entry = {
|
||||
let again = m.ok(
|
||||
"books.run_depreciation",
|
||||
json!({"period": "2026-01", "asset_id": x}),
|
||||
);
|
||||
assert_eq!(again["runs"][0]["already_posted"], json!(true));
|
||||
again["runs"][0]["entry_id"].as_u64().unwrap()
|
||||
};
|
||||
m.ok(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": jan_entry, "date": "2026-01-31", "description": "undo",
|
||||
"confirmed": true}),
|
||||
);
|
||||
let r = m.ok(
|
||||
"books.run_depreciation",
|
||||
json!({"period": "2026-01", "asset_id": x}),
|
||||
);
|
||||
assert_eq!(r["runs"][0]["already_posted"], json!(false));
|
||||
assert_eq!(canon(r["runs"][0]["amount"].as_str().unwrap()), "1000");
|
||||
let doc = m.file_bill_of_sale();
|
||||
let mut confirm = dispose_args(x, "2026-03-31", "9000.00");
|
||||
confirm["confirmed"] = json!(true);
|
||||
confirm["receipt_attachment_ids"] = json!([doc]);
|
||||
let disposed = m.ok("books.dispose_asset", confirm);
|
||||
let entry = disposed["entry_id"].as_u64().unwrap();
|
||||
let err = m.refuse(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": entry, "date": "2026-04-02", "description": "undo"}),
|
||||
);
|
||||
assert!(err.contains("can't be reversed"), "{err}");
|
||||
}
|
||||
Reference in new issue
Block a user