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

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:
swaitsandClaude Opus 5.5 committed 2026-10-14 15:58:48 -06:00
1 parent 47d301bcfe
commit 2fa84a37ff
2 files changed
+391 -12

No files matched your search

+242 -12
View File
@@ -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());
+149
View File
@@ -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"
);
}