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:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:11 -06:00
1 parent 1304b9c0ad
commit b0a9fd8ad4
69 files changed
+10529 -841

No files matched your search

+108 -2
View File
@@ -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}");
}
+37 -15
View File
@@ -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
+187
View File
@@ -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}");
}