mcp: balance sheet's year earnings no longer charge this year with closing last year's; tests for what mutation found untested
check / check (push) Waiting to run
check / check (push) Waiting to run
year_earnings counted every closing entry dated this year against this year's income, but a close takes all unclosed income through its end, earlier years included. A partnership closing January after an unclosed December printed "Net income (loss)" -969 for a year earning +730, and the closed December as "Undistributed earnings, prior years". The unclosed part is now the income after the last close, from no earlier than the fiscal year's start (closes are cumulative). Unit tests: the 25 MiB upload limit, YYYY-MM periods, config_mentions for every setting naming a code, report members, year earnings before and after a close, and which entries may lose their last receipt. tests/startup.rs: --help, the default data dir under HOME, settings read from beside the books, and a held lock refused after the wait instead of waited on forever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
47d301bcfe
commit
2fa84a37ff
2 files changed
+391
-12
No files matched your search
+242
-12
@@ -281,8 +281,8 @@ impl BooksServer {
|
|||||||
|
|
||||||
/// The fiscal year's earnings through `as_of`, for the balance sheet:
|
/// The fiscal year's earnings through `as_of`, for the balance sheet:
|
||||||
/// net income (loss) year to date (closing entries excluded), and the
|
/// net income (loss) year to date (closing entries excluded), and the
|
||||||
/// part of it still unclosed (closing entries included, so months
|
/// part of it not yet closed into equity: the income after the last
|
||||||
/// already closed into equity drop out).
|
/// close (closes are cumulative), from no earlier than the year's start.
|
||||||
fn year_earnings(
|
fn year_earnings(
|
||||||
&self,
|
&self,
|
||||||
ledger: &Ledger,
|
ledger: &Ledger,
|
||||||
@@ -292,14 +292,29 @@ impl BooksServer {
|
|||||||
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
|
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.net_income();
|
.net_income();
|
||||||
let mut unclosed = Decimal::ZERO;
|
// A close takes everything unclosed through its end, earlier years
|
||||||
for a in ledger.chart().all().filter(|a| !a.placeholder) {
|
// included, so this year's unclosed part is the income from the day
|
||||||
match a.account_type {
|
// after the last close (never before the year's start) through
|
||||||
AccountType::Revenue => unclosed += movement_in(ledger, a.id, fy_start, as_of),
|
// as_of. (Counting the closing entries dated this year instead
|
||||||
AccountType::Expense => unclosed -= movement_in(ledger, a.id, fy_start, as_of),
|
// charged this year with closing last year's income: found by
|
||||||
_ => {}
|
// year_earnings_split_closed_from_unclosed, 2026-10-14.)
|
||||||
}
|
let last_close = ledger
|
||||||
}
|
.list_periods()
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.status == PeriodStatus::Closed && p.end <= as_of)
|
||||||
|
.map(|p| p.end)
|
||||||
|
.max();
|
||||||
|
let from = match last_close.and_then(|e| e.succ_opt()) {
|
||||||
|
Some(after) if after > fy_start => after,
|
||||||
|
_ => fy_start,
|
||||||
|
};
|
||||||
|
let unclosed = if from <= as_of {
|
||||||
|
ledger_core::profit_and_loss_for_period(ledger, from, as_of, None)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.net_income()
|
||||||
|
} else {
|
||||||
|
Decimal::ZERO
|
||||||
|
};
|
||||||
Ok(ledger_import::YearEarnings {
|
Ok(ledger_import::YearEarnings {
|
||||||
net_income_ytd,
|
net_income_ytd,
|
||||||
unclosed,
|
unclosed,
|
||||||
@@ -4511,10 +4526,225 @@ impl ServerHandler for BooksServer {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::money;
|
use super::*;
|
||||||
use rust_decimal::Decimal;
|
use ledger_config::Member;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usd(s: &str) -> Money {
|
||||||
|
Money::new(Decimal::from_str(s).unwrap(), Currency::USD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(tag: &str) -> Store {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("ledger-mcp-unit-{}-{tag}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
Store::create(&dir, Currency::USD).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(s: &mut Store, date: NaiveDate, dr: AccountId, cr: AccountId, amount: &str) -> u64 {
|
||||||
|
let e = s.create_entry(date, "x").unwrap();
|
||||||
|
s.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
|
||||||
|
s.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
|
||||||
|
s.post_entry(e).unwrap();
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn members() -> Company {
|
||||||
|
Company {
|
||||||
|
members: vec![
|
||||||
|
Member {
|
||||||
|
name: "Ada".into(),
|
||||||
|
share: Decimal::new(5, 1),
|
||||||
|
capital_account: "3000".into(),
|
||||||
|
distribution_account: Some("3100".into()),
|
||||||
|
},
|
||||||
|
Member {
|
||||||
|
name: "Bo".into(),
|
||||||
|
share: Decimal::new(5, 1),
|
||||||
|
capital_account: "3001".into(),
|
||||||
|
distribution_account: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..Company::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Max 25 MB" (books.attach_receipt): 25 MiB exactly.
|
||||||
|
#[test]
|
||||||
|
fn the_upload_limit_is_25_mib() {
|
||||||
|
assert_eq!(MAX_UPLOAD_BYTES, 25 * 1024 * 1024);
|
||||||
|
assert_eq!(MAX_UPLOAD_BYTES, 26_214_400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A period is "YYYY-MM": year at least 1, month 1-12, nothing more.
|
||||||
|
#[test]
|
||||||
|
fn periods_are_a_year_and_a_month() {
|
||||||
|
assert_eq!(parse_period_mcp("2026-01"), Ok((2026, 1)));
|
||||||
|
assert_eq!(parse_period_mcp("2026-12"), Ok((2026, 12)));
|
||||||
|
for bad in [
|
||||||
|
"2026-13",
|
||||||
|
"2026-00",
|
||||||
|
"2026-01-05",
|
||||||
|
"0-05",
|
||||||
|
"2026",
|
||||||
|
"x-01",
|
||||||
|
"2026-",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(parse_period_mcp(bad).is_err(), "{bad:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings naming a code are found: cash/financing/investing roles,
|
||||||
|
/// close_into, and each member's capital and distributions accounts.
|
||||||
|
#[test]
|
||||||
|
fn config_mentions_finds_every_setting_naming_a_code() {
|
||||||
|
let server = BooksServer::new(store("mentions"), ReceiptPolicy::default())
|
||||||
|
.with_company(members())
|
||||||
|
.with_cash_roles(CashRoles {
|
||||||
|
cash: vec!["1000".into()],
|
||||||
|
financing: vec!["2100".into()],
|
||||||
|
investing: vec!["1400".into()],
|
||||||
|
});
|
||||||
|
assert_eq!(server.config_mentions("1000"), ["[books] cash_accounts"]);
|
||||||
|
assert_eq!(
|
||||||
|
server.config_mentions("2100"),
|
||||||
|
["[books] financing_accounts"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.config_mentions("1400"),
|
||||||
|
["[books] investing_accounts"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.config_mentions("3000"),
|
||||||
|
["[[company.members]] Ada capital_account"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.config_mentions("3001"),
|
||||||
|
["[[company.members]] Bo capital_account"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.config_mentions("3100"),
|
||||||
|
["[[company.members]] Ada distribution_account"]
|
||||||
|
);
|
||||||
|
assert!(server.config_mentions("6000").is_empty());
|
||||||
|
let solo = BooksServer::new(store("mentions-solo"), ReceiptPolicy::default()).with_company(
|
||||||
|
Company {
|
||||||
|
close_into: Some("3000".into()),
|
||||||
|
..Company::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(solo.config_mentions("3000"), ["[company] close_into"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports name each member with their accounts, in order.
|
||||||
|
#[test]
|
||||||
|
fn report_members_carry_names_and_accounts() {
|
||||||
|
let server =
|
||||||
|
BooksServer::new(store("members"), ReceiptPolicy::default()).with_company(members());
|
||||||
|
let m = server.report_members();
|
||||||
|
assert_eq!(m.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
(m[0].name.as_str(), m[0].capital_code.as_str()),
|
||||||
|
("Ada", "3000")
|
||||||
|
);
|
||||||
|
assert_eq!(m[0].distribution_code.as_deref(), Some("3100"));
|
||||||
|
assert_eq!(
|
||||||
|
(m[1].name.as_str(), m[1].capital_code.as_str()),
|
||||||
|
("Bo", "3001")
|
||||||
|
);
|
||||||
|
assert_eq!(m[1].distribution_code, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The balance sheet's year earnings: net income for the fiscal year
|
||||||
|
/// to date, and the part not yet closed into equity (closing entries
|
||||||
|
/// included, so a closed month drops out).
|
||||||
|
#[test]
|
||||||
|
fn year_earnings_split_closed_from_unclosed() {
|
||||||
|
let mut s = store("earnings");
|
||||||
|
let cash = s
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let sales = s
|
||||||
|
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let rent = s
|
||||||
|
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
post(&mut s, d(2025, 12, 15), cash, sales, "999.00"); // last fiscal year
|
||||||
|
post(&mut s, d(2026, 1, 10), cash, sales, "1000.00");
|
||||||
|
post(&mut s, d(2026, 1, 20), rent, cash, "300.00");
|
||||||
|
post(&mut s, d(2026, 2, 5), cash, sales, "50.00");
|
||||||
|
post(&mut s, d(2026, 2, 6), rent, cash, "20.00");
|
||||||
|
let server = BooksServer::new(s, ReceiptPolicy::default());
|
||||||
|
let as_of = d(2026, 2, 28);
|
||||||
|
{
|
||||||
|
let store = server.lock().unwrap();
|
||||||
|
let e = server.year_earnings(store.ledger(), as_of).unwrap();
|
||||||
|
assert_eq!(e.net_income_ytd, Decimal::from_str("730.00").unwrap());
|
||||||
|
assert_eq!(e.unclosed, Decimal::from_str("730.00").unwrap());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut store = server.lock().unwrap();
|
||||||
|
let jan = store
|
||||||
|
.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31))
|
||||||
|
.unwrap();
|
||||||
|
store.close_period(jan).unwrap();
|
||||||
|
}
|
||||||
|
let store = server.lock().unwrap();
|
||||||
|
let e = server.year_earnings(store.ledger(), as_of).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
e.net_income_ytd,
|
||||||
|
Decimal::from_str("730.00").unwrap(),
|
||||||
|
"still the year's income"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
e.unclosed,
|
||||||
|
Decimal::from_str("30.00").unwrap(),
|
||||||
|
"January's 700 is closed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlinking a receipt is refused only from a posted, non-reversal
|
||||||
|
/// entry the policy covers (crate::policy).
|
||||||
|
#[test]
|
||||||
|
fn receipts_are_kept_only_where_the_policy_needs_them() {
|
||||||
|
let mut s = store("unlink");
|
||||||
|
let cash = s
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let rent = s
|
||||||
|
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let draft = s.create_entry(d(2026, 3, 1), "draft").unwrap();
|
||||||
|
s.add_line(draft, rent, Side::Debit, usd("10.00")).unwrap();
|
||||||
|
s.add_line(draft, cash, Side::Credit, usd("10.00")).unwrap();
|
||||||
|
let posted = post(&mut s, d(2026, 3, 2), rent, cash, "10.00");
|
||||||
|
let reversal = s.reverse_entry(posted, d(2026, 3, 3), "undo").unwrap();
|
||||||
|
let every = ReceiptPolicy {
|
||||||
|
require_receipts_above: "0.00".into(),
|
||||||
|
..ReceiptPolicy::default()
|
||||||
|
};
|
||||||
|
let server = BooksServer::new(s, every);
|
||||||
|
let store = server.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
server.ensure_receipt_not_required(&store, draft).is_ok(),
|
||||||
|
"a draft"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
server.ensure_receipt_not_required(&store, reversal).is_ok(),
|
||||||
|
"a reversal"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
server.ensure_receipt_not_required(&store, posted).is_err(),
|
||||||
|
"a posted entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn money_always_has_cents_and_never_rounds() {
|
fn money_always_has_cents_and_never_rounds() {
|
||||||
let m = |s: &str| money(Decimal::from_str(s).unwrap());
|
let m = |s: &str| money(Decimal::from_str(s).unwrap());
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! Starting the server: arguments, where the books and settings are found,
|
||||||
|
//! and what happens when another writer has the books.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const BIN: &str = env!("CARGO_BIN_EXE_ledger-mcp");
|
||||||
|
|
||||||
|
fn temp(tag: &str) -> std::path::PathBuf {
|
||||||
|
let d = std::env::temp_dir().join(format!("ledger-mcp-start-{}-{tag}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
std::fs::create_dir_all(&d).unwrap();
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the server, initialize, call one tool, and return its text
|
||||||
|
/// result (or the error text), then end the session.
|
||||||
|
fn one_call(cmd: &mut Command, tool: &str, args: serde_json::Value) -> String {
|
||||||
|
let mut child = cmd
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let mut stdin = child.stdin.take().unwrap();
|
||||||
|
let mut out = BufReader::new(child.stdout.take().unwrap());
|
||||||
|
let mut send = |v: serde_json::Value| writeln!(stdin, "{v}").unwrap();
|
||||||
|
send(
|
||||||
|
serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||||
|
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}),
|
||||||
|
);
|
||||||
|
let mut line = String::new();
|
||||||
|
out.read_line(&mut line).unwrap();
|
||||||
|
send(serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized","params":{}}));
|
||||||
|
send(
|
||||||
|
serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":args}}),
|
||||||
|
);
|
||||||
|
line.clear();
|
||||||
|
out.read_line(&mut line).unwrap();
|
||||||
|
drop(stdin);
|
||||||
|
let _ = child.wait();
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_names_the_options() {
|
||||||
|
let out = Command::new(BIN).arg("--help").output().unwrap();
|
||||||
|
assert!(out.status.success());
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout) + String::from_utf8_lossy(&out.stderr);
|
||||||
|
for opt in ["--data-dir", "--books-toml", "--config", "--http"] {
|
||||||
|
assert!(text.contains(opt), "--help lacks {opt}: {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With no --data-dir the books live in ~/.local/share/wanderbooks.
|
||||||
|
#[test]
|
||||||
|
fn the_default_data_dir_is_under_home() {
|
||||||
|
let home = temp("home");
|
||||||
|
one_call(
|
||||||
|
Command::new(BIN).env("HOME", &home),
|
||||||
|
"books.chart",
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
home.join(".local/share/wanderbooks/journal.jsonl").exists(),
|
||||||
|
"no books under {}",
|
||||||
|
home.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// wanderbooks.toml beside the data folder is read without --books-toml:
|
||||||
|
/// its receipt threshold (0.00) refuses a 1.00 entry with no receipt,
|
||||||
|
/// which the default ($25.00) would post.
|
||||||
|
#[test]
|
||||||
|
fn the_settings_beside_the_books_are_read() {
|
||||||
|
let dir = temp("beside");
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("wanderbooks.toml"),
|
||||||
|
"[books]\nreceipt_threshold = \"0.00\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let data = dir.join("books-data");
|
||||||
|
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
|
||||||
|
let cash = store
|
||||||
|
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let rent = store
|
||||||
|
.add_account(
|
||||||
|
"6000",
|
||||||
|
"Rent",
|
||||||
|
ledger_core::AccountType::Expense,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let e = store
|
||||||
|
.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "x")
|
||||||
|
.unwrap();
|
||||||
|
let m = ledger_core::Money::new(rust_decimal::Decimal::ONE, ledger_core::Currency::USD);
|
||||||
|
store
|
||||||
|
.add_line(e, rent, ledger_core::Side::Debit, m)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.add_line(e, cash, ledger_core::Side::Credit, m)
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
let reply = one_call(
|
||||||
|
Command::new(BIN).arg("--data-dir").arg(&data),
|
||||||
|
"books.post_entry",
|
||||||
|
serde_json::json!({"entry_id": e, "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
reply.contains("receipt_required"),
|
||||||
|
"posted without a receipt: {reply}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A writer that keeps the books is a conflict: the server gives up with
|
||||||
|
/// an error (after its ten-second wait), never hangs.
|
||||||
|
#[test]
|
||||||
|
fn a_held_lock_is_refused_not_waited_on_forever() {
|
||||||
|
let dir = temp("locked");
|
||||||
|
let _holder = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut child = Command::new(BIN)
|
||||||
|
.arg("--data-dir")
|
||||||
|
.arg(&dir)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let status = loop {
|
||||||
|
if let Some(s) = child.try_wait().unwrap() {
|
||||||
|
break s;
|
||||||
|
}
|
||||||
|
if start.elapsed() > Duration::from_secs(60) {
|
||||||
|
let _ = child.kill();
|
||||||
|
panic!("the server waited on a held lock for a minute");
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
};
|
||||||
|
assert!(!status.success());
|
||||||
|
assert!(
|
||||||
|
start.elapsed() >= Duration::from_secs(9),
|
||||||
|
"gave up before waiting"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user