Books an update could write a newer format for open on their old format and are asked at every open; anything needing a new feature is refused until the owner agrees with the passphrase, after a verified backup that includes the keys. New books start on the current format. Each projection schema has its own database file, so an older version still opens the books until the owner agrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
782 lines
27 KiB
Rust
782 lines
27 KiB
Rust
//! P3 defects 14 and 24 through the MCP, over stdio against the real
|
|
//! binary: each party's part of an entry (`books.get_entry` `resolved`,
|
|
//! `direction`, `cash`, `in_kind`), party totals on a basis
|
|
//! (`books.party`, `books.parties` `basis`), and a party named the
|
|
//! creditor of a liability (`books.update_party` `creditor_of`).
|
|
//!
|
|
//! Expected behavior comes only from the tool descriptions and parameter /
|
|
//! result doc comments in `src/server.rs` and `src/types.rs` (as
|
|
//! `tools/list` serves them) and rules.md B-35. The harness is the minimal
|
|
//! subset of `tests/spec_mcp.rs` (copied from `tests/spec_parties.rs`):
|
|
//! every success validates against the tool's `outputSchema`, and every
|
|
//! refusal leaves `journal.jsonl` byte-identical.
|
|
|
|
use serde_json::{Value, json};
|
|
use std::collections::HashMap;
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
|
use std::time::Duration;
|
|
|
|
const TIMEOUT: Duration = Duration::from_secs(180);
|
|
|
|
// ---------------------------------------------------------------- harness
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Out {
|
|
Ok(Value),
|
|
ToolErr(String),
|
|
RpcErr(Value),
|
|
}
|
|
|
|
impl Out {
|
|
fn refused(&self) -> bool {
|
|
!matches!(self, Out::Ok(_))
|
|
}
|
|
fn err_text(&self) -> String {
|
|
match self {
|
|
Out::Ok(v) => panic!("expected a refusal, got {v}"),
|
|
Out::ToolErr(t) => t.clone(),
|
|
Out::RpcErr(e) => e.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Mcp {
|
|
stdin: ChildStdin,
|
|
rx: Receiver<String>,
|
|
child: Child,
|
|
next_id: u64,
|
|
data: PathBuf,
|
|
validators: HashMap<String, jsonschema::Validator>,
|
|
/// The books dir when the server owns it: removed after the server
|
|
/// stops (fields drop after `Drop::drop`).
|
|
books_dir: Option<TestDir>,
|
|
}
|
|
|
|
impl Drop for Mcp {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
|
|
|
/// A test's dir, removed when the test ends, pass or panic.
|
|
struct TestDir(PathBuf);
|
|
|
|
impl std::ops::Deref for TestDir {
|
|
type Target = std::path::Path;
|
|
fn deref(&self) -> &std::path::Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl AsRef<std::path::Path> for TestDir {
|
|
fn as_ref(&self) -> &std::path::Path {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for TestDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn test_dir(name: &str) -> TestDir {
|
|
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"ledger-mcp-spec-party-totals-{}-{name}-{n}",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
TestDir(dir)
|
|
}
|
|
|
|
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
|
|
/// receipt threshold is high so ordinary test entries need no receipt.
|
|
fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
|
|
let path = dir.join("wanderbooks.toml");
|
|
std::fs::write(
|
|
&path,
|
|
format!(
|
|
"[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\
|
|
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}"
|
|
),
|
|
)
|
|
.unwrap();
|
|
path
|
|
}
|
|
|
|
impl Mcp {
|
|
fn start(dir: &Path, toml: &Path) -> Self {
|
|
let run = dir.join("run");
|
|
std::fs::create_dir_all(&run).unwrap();
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
|
.arg("--data-dir")
|
|
.arg(dir.join("books-data"))
|
|
.arg("--books-toml")
|
|
.arg(toml)
|
|
.env("XDG_RUNTIME_DIR", &run)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn ledger-mcp");
|
|
let stdin = child.stdin.take().unwrap();
|
|
let stdout = BufReader::new(child.stdout.take().unwrap());
|
|
let (tx, rx) = channel();
|
|
std::thread::spawn(move || {
|
|
for line in stdout.lines() {
|
|
let Ok(line) = line else { break };
|
|
if tx.send(line).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
let mut mcp = Self {
|
|
books_dir: None,
|
|
stdin,
|
|
rx,
|
|
child,
|
|
next_id: 1,
|
|
data: dir.join("books-data"),
|
|
validators: HashMap::new(),
|
|
};
|
|
let init = mcp.rpc(
|
|
"initialize",
|
|
json!({
|
|
"protocolVersion": "2025-06-18",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "spec-party-totals", "version": "0.1.0"},
|
|
}),
|
|
);
|
|
assert!(init.get("result").is_some(), "initialize failed: {init}");
|
|
mcp.send_line(
|
|
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(),
|
|
);
|
|
let list = mcp.rpc("tools/list", json!({}));
|
|
for tool in list["result"]["tools"].as_array().expect("tools") {
|
|
let name = tool["name"].as_str().unwrap().to_string();
|
|
let validator = jsonschema::validator_for(&tool["outputSchema"])
|
|
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}"));
|
|
mcp.validators.insert(name, validator);
|
|
}
|
|
mcp
|
|
}
|
|
|
|
fn journal(&self) -> Vec<u8> {
|
|
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
|
|
}
|
|
|
|
/// The journal's line types, in order.
|
|
fn journal_types(&self) -> Vec<String> {
|
|
String::from_utf8(self.journal())
|
|
.unwrap()
|
|
.lines()
|
|
.filter(|l| !l.trim().is_empty())
|
|
.map(|l| {
|
|
let v: Value = serde_json::from_str(l).unwrap();
|
|
v["type"]
|
|
.as_str()
|
|
.or_else(|| v["line_type"].as_str())
|
|
.unwrap_or_default()
|
|
.to_string()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn send_line(&mut self, line: &str) {
|
|
writeln!(self.stdin, "{line}").unwrap();
|
|
self.stdin.flush().unwrap();
|
|
}
|
|
|
|
fn recv(&mut self) -> Option<Value> {
|
|
loop {
|
|
match self.rx.recv_timeout(TIMEOUT) {
|
|
Ok(line) => {
|
|
let v: Value = serde_json::from_str(&line)
|
|
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
|
if v.get("id").is_none() && v.get("method").is_some() {
|
|
continue;
|
|
}
|
|
return Some(v);
|
|
}
|
|
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
|
self.send_line(&msg.to_string());
|
|
let resp = self
|
|
.recv()
|
|
.unwrap_or_else(|| panic!("no response to {method} (id {id})"));
|
|
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
|
|
resp
|
|
}
|
|
|
|
/// Call a tool; a refusal must leave the journal unchanged, a success
|
|
/// must match the outputSchema.
|
|
fn tool(&mut self, name: &str, args: Value) -> Out {
|
|
let before = self.journal();
|
|
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
|
|
let out = if let Some(err) = resp.get("error") {
|
|
Out::RpcErr(err.clone())
|
|
} else {
|
|
let result = &resp["result"];
|
|
if result.get("isError") == Some(&json!(true)) {
|
|
Out::ToolErr(
|
|
result["content"]
|
|
.as_array()
|
|
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
let sc = result["structuredContent"].clone();
|
|
let v = self
|
|
.validators
|
|
.get(name)
|
|
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
|
|
let errors: Vec<String> = v.iter_errors(&sc).map(|e| e.to_string()).collect();
|
|
assert!(
|
|
errors.is_empty(),
|
|
"{name} violates its outputSchema: {errors:?}\n{sc}"
|
|
);
|
|
Out::Ok(sc)
|
|
}
|
|
};
|
|
if out.refused() {
|
|
assert!(
|
|
self.journal() == before,
|
|
"refused {name} {args} changed the journal ({out:?})"
|
|
);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn ok(&mut self, name: &str, args: Value) -> Value {
|
|
let a = args.to_string();
|
|
match self.tool(name, args) {
|
|
Out::Ok(v) => v,
|
|
other => panic!("{name} {a}: expected success, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// `tool`, expecting a refusal that says why.
|
|
fn refuse(&mut self, name: &str, args: Value) -> String {
|
|
let a = args.to_string();
|
|
let out = self.tool(name, args);
|
|
assert!(out.refused(), "{name} {a} should be refused, got {out:?}");
|
|
let why = out.err_text();
|
|
assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why");
|
|
why
|
|
}
|
|
|
|
/// `ok`, and the journal must not change (read-only tools).
|
|
fn read_only(&mut self, name: &str, args: Value) -> Value {
|
|
let before = self.journal();
|
|
let v = self.ok(name, args);
|
|
assert!(
|
|
self.journal() == before,
|
|
"read-only {name} changed the journal"
|
|
);
|
|
v
|
|
}
|
|
|
|
fn accounts(&mut self, accts: &[(&str, &str, &str)]) {
|
|
for (code, name, ty) in accts {
|
|
self.ok(
|
|
"books.add_account",
|
|
json!({"code": code, "name": name, "account_type": ty}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Draft an entry with `args` extra (party, vendor) and lines; the id.
|
|
fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
|
|
let mut args = json!({"date": date, "description": format!("entry {date}")});
|
|
for (k, v) in extra.as_object().unwrap() {
|
|
args[k] = v.clone();
|
|
}
|
|
let id = self.ok("books.draft_entry", args)["entry_id"]
|
|
.as_u64()
|
|
.unwrap();
|
|
for (account, side, amount) in lines {
|
|
self.ok(
|
|
"books.add_line",
|
|
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
|
);
|
|
}
|
|
id
|
|
}
|
|
|
|
/// `draft`, then post it (high receipt threshold: no receipt needed).
|
|
fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
|
|
let id = self.draft(date, extra, lines);
|
|
self.ok(
|
|
"books.post_entry",
|
|
json!({"entry_id": id, "confirmed": true}),
|
|
);
|
|
id
|
|
}
|
|
|
|
fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 {
|
|
self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"]
|
|
.as_u64()
|
|
.unwrap()
|
|
}
|
|
|
|
/// How many journal lines have this type.
|
|
fn count(&self, line_type: &str) -> usize {
|
|
self.journal_types()
|
|
.iter()
|
|
.filter(|t| t.as_str() == line_type)
|
|
.count()
|
|
}
|
|
}
|
|
|
|
const CHART: &[(&str, &str, &str)] = &[
|
|
("1000", "Checking", "asset"),
|
|
("1300", "Prepaid Expenses", "asset"),
|
|
("1500", "Equipment", "asset"),
|
|
("2050", "Business Card", "liability"),
|
|
("2100", "Due to Members", "liability"),
|
|
("3000", "Capital — Tobin Harlow", "equity"),
|
|
("3001", "Capital — Morgan Ellis", "equity"),
|
|
("6050", "Domain & Hosting", "expense"),
|
|
("6400", "Bank Fees", "expense"),
|
|
];
|
|
|
|
/// Fernhill Labs LLC: two members 50/50, cash 1000, the members'
|
|
/// financing 2100; the chart; both members and the card's issuer in the
|
|
/// directory.
|
|
fn books(name: &str) -> Mcp {
|
|
books_in(name, false)
|
|
}
|
|
|
|
/// [`books`], as P2 left them when `p2`: journal format 2 with none of
|
|
/// P3's features, books an older version wrote, waiting for their format
|
|
/// upgrade (rules.md B-27).
|
|
fn books_in(name: &str, p2: bool) -> Mcp {
|
|
let dir = test_dir(name);
|
|
if p2 {
|
|
ledger_store::Store::create_in_format(
|
|
&dir.join("books-data"),
|
|
ledger_core::Currency::USD,
|
|
&ledger_store::JournalFormat {
|
|
format: 2,
|
|
features: Vec::new(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
}
|
|
let toml = books_toml(
|
|
&dir,
|
|
"Fernhill Labs LLC",
|
|
"financing_accounts = [\"2100\"]\n\
|
|
[[company.members]]\nname = \"Tobin Harlow\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
|
[[company.members]]\nname = \"Morgan Ellis\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
|
|
);
|
|
let mut m = Mcp::start(&dir, &toml);
|
|
m.accounts(CHART);
|
|
m.add_party("Tobin Harlow", &["member"]);
|
|
m.add_party("Morgan Ellis", &["member"]);
|
|
m.add_party("Harbor Bank", &["financial_institution"]);
|
|
m.books_dir = Some(dir);
|
|
m
|
|
}
|
|
|
|
impl Mcp {
|
|
/// The owner's go-ahead, as the app gives it (ledger-gui owner.rs,
|
|
/// after the passphrase and a verified backup; no tool can): the
|
|
/// server stopped, the books moved to this version's format, and
|
|
/// served again.
|
|
fn upgraded(mut self) -> Mcp {
|
|
let dir = self.books_dir.take().expect("books_dir");
|
|
drop(self);
|
|
ledger_store::Store::open(&dir.join("books-data"))
|
|
.unwrap()
|
|
.upgrade_format()
|
|
.unwrap()
|
|
.expect("P2's books wait for the upgrade");
|
|
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
|
|
m.books_dir = Some(dir);
|
|
m
|
|
}
|
|
}
|
|
|
|
/// A party's part of an entry as books.get_entry gives it: (resolved,
|
|
/// direction, cash, in_kind).
|
|
fn part(m: &mut Mcp, entry: u64, party: &str) -> (Value, Value, Value, Value) {
|
|
let e = m.read_only("books.get_entry", json!({"entry_id": entry}));
|
|
let p = e["parties"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|p| p["party"] == party)
|
|
.unwrap_or_else(|| panic!("{party} on entry {entry}: {e}"))
|
|
.clone();
|
|
(
|
|
p["resolved"].clone(),
|
|
p["direction"].clone(),
|
|
p["cash"].clone(),
|
|
p["in_kind"].clone(),
|
|
)
|
|
}
|
|
|
|
/// (paid, received, basis) from books.party.
|
|
fn totals(m: &mut Mcp, party: &str, basis: Option<&str>) -> (Value, Value, Value) {
|
|
let mut args = json!({"party": party, "year": 2026});
|
|
if let Some(b) = basis {
|
|
args["basis"] = json!(b);
|
|
}
|
|
let p = m.read_only("books.party", args);
|
|
let t = &p["totals"];
|
|
(t["paid"].clone(), t["received"].clone(), t["basis"].clone())
|
|
}
|
|
|
|
fn reverse(m: &mut Mcp, entry: u64, date: &str) -> u64 {
|
|
m.ok(
|
|
"books.reverse_entry",
|
|
json!({"entry_id": entry, "date": date, "description": "reversed", "confirmed": true}),
|
|
)["reversal_id"]
|
|
.as_u64()
|
|
.unwrap_or_else(|| panic!("a reversal of {entry}"))
|
|
}
|
|
|
|
/// Defect 14 over the MCP (rules.md B-35): `resolved` is signed as a line
|
|
/// on the party's own account, with its direction and its money and
|
|
/// property; a reversal is the exact opposite; party totals take a basis,
|
|
/// cash by default and echoed, all adding property in kind, so that a
|
|
/// member's basis-all received is the statement of members' equity's
|
|
/// contributions; any other basis is refused.
|
|
#[test]
|
|
fn defect14_party_totals_take_a_basis() {
|
|
let mut m = books("defect14");
|
|
let tobin = json!({"parties": [{"party": "Tobin Harlow"}]});
|
|
let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]});
|
|
// Cash in, 400.00 each.
|
|
let cash_in = m.post(
|
|
"2026-10-05",
|
|
tobin.clone(),
|
|
&[("1000", "debit", "400.00"), ("3000", "credit", "400.00")],
|
|
);
|
|
m.post(
|
|
"2026-10-05",
|
|
json!({"parties": [{"party": "Morgan Ellis"}]}),
|
|
&[("1000", "debit", "400.00"), ("3001", "credit", "400.00")],
|
|
);
|
|
// A laptop Tobin contributes, then its reversal.
|
|
let laptop = m.post(
|
|
"2026-10-12",
|
|
tobin.clone(),
|
|
&[("1500", "debit", "1480.00"), ("3000", "credit", "1480.00")],
|
|
);
|
|
let undo = reverse(&mut m, laptop, "2026-10-13");
|
|
// Devices from both, derived from the capital lines; one with its
|
|
// service contract (prepaid: property).
|
|
m.post(
|
|
"2026-10-14",
|
|
both.clone(),
|
|
&[
|
|
("1500", "debit", "1480.00"),
|
|
("3000", "credit", "740.00"),
|
|
("3001", "credit", "740.00"),
|
|
],
|
|
);
|
|
m.post(
|
|
"2026-10-19",
|
|
both.clone(),
|
|
&[
|
|
("1500", "debit", "1010.00"),
|
|
("1300", "debit", "30.00"),
|
|
("3000", "credit", "520.00"),
|
|
("3001", "credit", "520.00"),
|
|
],
|
|
);
|
|
// A reimbursement to both (money, paid).
|
|
let reimburse = m.post(
|
|
"2026-10-20",
|
|
json!({"parties": [{"party": "Tobin Harlow", "amount": "93.25"}, {"party": "Morgan Ellis", "amount": "93.25"}]}),
|
|
&[("2100", "debit", "186.50"), ("1000", "credit", "186.50")],
|
|
);
|
|
assert_eq!(
|
|
part(&mut m, cash_in, "Tobin Harlow"),
|
|
(
|
|
json!("-400.00"),
|
|
json!("received"),
|
|
json!("-400.00"),
|
|
json!("0.00")
|
|
)
|
|
);
|
|
assert_eq!(
|
|
part(&mut m, laptop, "Tobin Harlow"),
|
|
(
|
|
json!("-1480.00"),
|
|
json!("received"),
|
|
json!("0.00"),
|
|
json!("-1480.00")
|
|
)
|
|
);
|
|
assert_eq!(
|
|
part(&mut m, undo, "Tobin Harlow"),
|
|
(
|
|
json!("1480.00"),
|
|
json!("paid"),
|
|
json!("0.00"),
|
|
json!("1480.00")
|
|
)
|
|
);
|
|
assert_eq!(
|
|
part(&mut m, reimburse, "Morgan Ellis"),
|
|
(json!("93.25"), json!("paid"), json!("93.25"), json!("0.00"))
|
|
);
|
|
// Totals: cash by default, labelled; all adds 740.00 + 520.00.
|
|
let cash = (json!("93.25"), json!("400.00"), json!("cash"));
|
|
assert_eq!(totals(&mut m, "Tobin Harlow", None), cash);
|
|
assert_eq!(totals(&mut m, "Tobin Harlow", Some("cash")), cash);
|
|
let all = totals(&mut m, "Tobin Harlow", Some("all"));
|
|
assert_eq!(all, (json!("93.25"), json!("1660.00"), json!("all")));
|
|
// The statement of members' equity, the same dates: contributions
|
|
// 1,660.00 each (the laptop and its reversal cancel), no
|
|
// distributions; basis all's received is exactly that.
|
|
let me = m.read_only(
|
|
"books.export",
|
|
json!({"report": "members_equity", "format": "json", "start": "2026-01-01", "end": "2026-12-31"}),
|
|
);
|
|
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
|
|
for who in ["Tobin Harlow", "Morgan Ellis"] {
|
|
let col = me["members"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|c| c["name"] == who)
|
|
.unwrap_or_else(|| panic!("{who} in {me}"))
|
|
.clone();
|
|
assert_eq!(col["contributions"], totals(&mut m, who, Some("all")).1);
|
|
assert_eq!(col["contributions"], "1660.00");
|
|
assert_eq!(col["distributions"], "0.00");
|
|
}
|
|
// books.parties echoes the basis.
|
|
let list = m.read_only("books.parties", json!({"year": 2026, "basis": "all"}));
|
|
assert_eq!(list["basis"], "all");
|
|
let row = list["parties"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|p| p["name"] == "Morgan Ellis")
|
|
.unwrap()
|
|
.clone();
|
|
assert_eq!(
|
|
(row["paid"].clone(), row["received"].clone()),
|
|
(json!("93.25"), json!("1660.00"))
|
|
);
|
|
assert_eq!(m.read_only("books.parties", json!({}))["basis"], "cash");
|
|
// Any other basis is refused, by both tools.
|
|
for (tool, args) in [
|
|
(
|
|
"books.party",
|
|
json!({"party": "Tobin Harlow", "basis": "accrual"}),
|
|
),
|
|
("books.parties", json!({"basis": "accrual"})),
|
|
] {
|
|
let why = m.refuse(tool, args);
|
|
assert!(why.contains("\"cash\" or \"all\""), "{tool}: {why}");
|
|
}
|
|
}
|
|
|
|
/// Defect 24 over the MCP: `books.update_party` `creditor_of` names the
|
|
/// liabilities the company owes a party itself on (journaled as
|
|
/// party.creditor_set, under the journal feature the books declared when
|
|
/// they were created, so nothing is declared on the way), replaces
|
|
/// the list, refuses what isn't a liability with nothing journaled; and a
|
|
/// fee on the issuer's own card then reads neither, so the issuer is paid
|
|
/// what the card payment paid, once.
|
|
#[test]
|
|
fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
|
let mut m = books("defect24");
|
|
let issuer = json!({"parties": [{"party": "Harbor Bank"}]});
|
|
let fee = m.post(
|
|
"2026-05-01",
|
|
issuer.clone(),
|
|
&[("6400", "debit", "25.00"), ("2050", "credit", "25.00")],
|
|
);
|
|
m.post(
|
|
"2026-05-20",
|
|
issuer.clone(),
|
|
&[("2050", "debit", "125.00"), ("1000", "credit", "125.00")],
|
|
);
|
|
assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("150.00"));
|
|
// Not a liability: refused, nothing journaled (the tool checks).
|
|
let why = m.refuse(
|
|
"books.update_party",
|
|
json!({"party": "Harbor Bank", "creditor_of": ["1000"]}),
|
|
);
|
|
assert!(why.contains("liability"), "{why}");
|
|
assert_eq!(m.count("party.creditor_set"), 0);
|
|
let before = m.count("journal.format_set");
|
|
let p = m.ok(
|
|
"books.update_party",
|
|
json!({"party": "Harbor Bank", "creditor_of": ["2050"]}),
|
|
);
|
|
assert_eq!(p["creditor_of"], json!(["2050"]));
|
|
assert_eq!(m.count("party.creditor_set"), 1);
|
|
assert_eq!(
|
|
m.count("journal.format_set"),
|
|
before,
|
|
"nothing declared on the way"
|
|
);
|
|
// The same list again changes nothing.
|
|
let journal = m.journal();
|
|
m.ok(
|
|
"books.update_party",
|
|
json!({"party": "Harbor Bank", "creditor_of": ["2050"]}),
|
|
);
|
|
assert!(m.journal() == journal);
|
|
assert_eq!(
|
|
part(&mut m, fee, "Harbor Bank"),
|
|
(
|
|
json!("-25.00"),
|
|
json!("neither"),
|
|
json!("0.00"),
|
|
json!("0.00")
|
|
)
|
|
);
|
|
assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("125.00"));
|
|
assert_eq!(
|
|
totals(&mut m, "Harbor Bank", Some("all")).0,
|
|
json!("125.00")
|
|
);
|
|
// Both members are owed what the members advanced; then one isn't.
|
|
for who in ["Tobin Harlow", "Morgan Ellis"] {
|
|
let p = m.ok(
|
|
"books.update_party",
|
|
json!({"party": who, "creditor_of": ["2100"]}),
|
|
);
|
|
assert_eq!(p["creditor_of"], json!(["2100"]));
|
|
}
|
|
let p = m.ok(
|
|
"books.update_party",
|
|
json!({"party": "Morgan Ellis", "creditor_of": []}),
|
|
);
|
|
assert_eq!(p["creditor_of"], json!([]));
|
|
assert_eq!(m.count("party.creditor_set"), 4);
|
|
assert_eq!(m.count("journal.format_set"), before);
|
|
let p = m.read_only("books.party", json!({"party": "Tobin Harlow"}));
|
|
assert_eq!(p["party"]["creditor_of"], json!(["2100"]));
|
|
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
|
}
|
|
|
|
/// rules.md B-27: `books.update_party` asks for the `party_creditors`
|
|
/// feature before the first line it writes, never between them. In books
|
|
/// waiting for their format upgrade (P2's), a name change with the first
|
|
/// creditor is refused whole, journaling nothing (never the notes changed
|
|
/// and the creditor not); once the owner upgrades them, as in books this
|
|
/// version made, it journals the change, then the creditor, and declares
|
|
/// nothing.
|
|
#[test]
|
|
fn update_party_asks_for_the_creditor_feature_before_its_first_line() {
|
|
let change =
|
|
json!({"party": "Harbor Bank", "notes": "the card's issuer", "creditor_of": ["2050"]});
|
|
let mut m = books_in("feature-first", true);
|
|
let why = m.refuse("books.update_party", change.clone());
|
|
assert!(
|
|
why.starts_with("format_upgrade_pending:") && why.contains("party_creditors"),
|
|
"{why}"
|
|
);
|
|
assert_eq!(m.count("party.updated"), 0, "nothing half done");
|
|
for mut m in [m.upgraded(), books("feature-first-new")] {
|
|
let start = m.journal_types().len();
|
|
let p = m.ok("books.update_party", change.clone());
|
|
assert_eq!(p["creditor_of"], json!(["2050"]));
|
|
assert_eq!(
|
|
m.journal_types()[start..].to_vec(),
|
|
["party.updated", "party.creditor_set"],
|
|
);
|
|
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
|
}
|
|
}
|
|
|
|
/// What `books.post_entry` previews is what it posts: each party's
|
|
/// `resolved`, `direction`, `cash` and `in_kind` in the preview are
|
|
/// `books.get_entry`'s once confirmed (a derived split is then journaled as
|
|
/// the amounts it resolved to, the same parts), for a joint contribution
|
|
/// of money and property and for a distribution partly still owed.
|
|
#[test]
|
|
fn the_post_preview_shows_the_parts_it_posts() {
|
|
let mut m = books("preview");
|
|
m.accounts(&[("2150", "Distributions Payable", "liability")]);
|
|
let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]});
|
|
let tobin = json!({"parties": [{"party": "Tobin Harlow"}]});
|
|
let parts = |v: &Value| -> Vec<Value> {
|
|
v["parties"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|p| {
|
|
json!([
|
|
p["party"],
|
|
p["resolved"],
|
|
p["direction"],
|
|
p["cash"],
|
|
p["in_kind"]
|
|
])
|
|
})
|
|
.collect()
|
|
};
|
|
let joint = m.draft(
|
|
"2026-03-02",
|
|
both,
|
|
&[
|
|
("1000", "debit", "610.00"),
|
|
("1500", "debit", "390.00"),
|
|
("3000", "credit", "500.00"),
|
|
("3001", "credit", "500.00"),
|
|
],
|
|
);
|
|
let owed = m.draft(
|
|
"2026-03-03",
|
|
tobin,
|
|
&[
|
|
("3000", "debit", "300.00"),
|
|
("1000", "credit", "120.00"),
|
|
("2150", "credit", "180.00"),
|
|
],
|
|
);
|
|
for (id, want) in [
|
|
(
|
|
joint,
|
|
vec![
|
|
json!(["Tobin Harlow", "-500.00", "received", "-305.00", "-195.00"]),
|
|
json!(["Morgan Ellis", "-500.00", "received", "-305.00", "-195.00"]),
|
|
],
|
|
),
|
|
(
|
|
owed,
|
|
vec![json!(["Tobin Harlow", "300.00", "paid", "120.00", "0.00"])],
|
|
),
|
|
] {
|
|
let preview = m.read_only("books.post_entry", json!({"entry_id": id}));
|
|
assert_eq!(preview["status"], "preview");
|
|
assert_eq!(parts(&preview), want, "entry {id}'s preview");
|
|
m.ok(
|
|
"books.post_entry",
|
|
json!({"entry_id": id, "confirmed": true}),
|
|
);
|
|
let posted = m.read_only("books.get_entry", json!({"entry_id": id}));
|
|
assert_eq!(parts(&posted), want, "entry {id} posted");
|
|
}
|
|
let (paid, received, _) = totals(&mut m, "Tobin Harlow", None);
|
|
assert_eq!((paid, received), (json!("120.00"), json!("305.00")));
|
|
}
|