P3: party totals by what moved the money, once (defects 14, 11, 24)
Each party's part of an entry is signed as its own account would show it, with a direction and its cash and in-kind amounts; a reversal is its original negated. Totals on a cash or all basis: for a member, all equals the members' equity statement and cash the K-1's cash. Money is cash, cards and the financing accounts, and a liability credit never pays its own creditor (creditor_of), so accruals and declared distributions count once. tin_last4 from the TIN or W-9 facts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
52 files changed
+7535
-560
No files matched your search
@@ -1027,14 +1027,31 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() {
|
||||
assert_eq!(t["received"], "2000.00", "a contribution");
|
||||
assert_eq!(t["paid"], "500.00", "a distribution");
|
||||
|
||||
// A prepaid bought with a member's card: an asset, paid by a liability.
|
||||
m.add_party("Insurer", &[]);
|
||||
m.post(
|
||||
"2026-04-01",
|
||||
json!({"party": "Insurer"}),
|
||||
&[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")],
|
||||
// A prepaid bought with a member's card: an asset, paid through what
|
||||
// the members advanced. That is money when it is a financing account
|
||||
// (rules.md B-35 item 5, P3 defect 24); unlisted, Due to Members is a
|
||||
// promise like any other liability, counted when the member is paid
|
||||
// back.
|
||||
let prepaid = |m: &mut Mcp| {
|
||||
m.add_party("Insurer", &[]);
|
||||
m.post(
|
||||
"2026-04-01",
|
||||
json!({"party": "Insurer"}),
|
||||
&[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")],
|
||||
);
|
||||
m.party("Insurer", 2026)["totals"].clone()
|
||||
};
|
||||
let t = prepaid(&mut m);
|
||||
assert_eq!(
|
||||
(t["paid"].clone(), t["received"].clone()),
|
||||
(json!("0.00"), json!("0.00"))
|
||||
);
|
||||
let t = m.party("Insurer", 2026)["totals"].clone();
|
||||
let dir = test_dir("totals-financing");
|
||||
let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n");
|
||||
let mut f = Mcp::start(&dir, &toml);
|
||||
f.accounts(CHART);
|
||||
f.books_dir = Some(dir);
|
||||
let t = prepaid(&mut f);
|
||||
assert_eq!(t["paid"], "1200.00");
|
||||
assert_eq!(t["received"], "0.00");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
//! 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 {
|
||||
let dir = test_dir(name);
|
||||
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
|
||||
}
|
||||
|
||||
/// 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, the journal's feature declared first), 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 + 1,
|
||||
"the feature declared once"
|
||||
);
|
||||
// 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 + 1);
|
||||
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");
|
||||
}
|
||||
|
||||
/// 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")));
|
||||
}
|
||||
@@ -2219,3 +2219,61 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- defect 11 (P3)
|
||||
|
||||
/// Defect 11 (audit F28): `books.parties` showed a member's TIN as
|
||||
/// missing (`tin_last4: null`) because it read W-9 facts only, while
|
||||
/// `books.party` showed the TIN facts' last four. The directory's last
|
||||
/// four is the TIN facts', else the W-9 facts' (they never disagree:
|
||||
/// parties INVARIANT 2a), null with neither, and kept after a purge
|
||||
/// (the facts keep the last four).
|
||||
#[test]
|
||||
fn defect11_the_directory_shows_a_members_tin_last4() {
|
||||
let mut m = books("defect11");
|
||||
m.add_party("Mo", &["member"]);
|
||||
m.add_party("Nobody In Particular", &[]);
|
||||
m.ok(
|
||||
"books.set_tin",
|
||||
json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||
);
|
||||
assert!(
|
||||
!m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin_last4": "6789", "kept_at": "binder"})
|
||||
)
|
||||
.refused()
|
||||
);
|
||||
let row = |m: &mut Mcp, name: &str| -> Value {
|
||||
let list = m.read_only("books.parties", json!({"year": 2026}));
|
||||
list["parties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == name)
|
||||
.unwrap_or_else(|| panic!("{name} in {list}"))
|
||||
.clone()
|
||||
};
|
||||
let mo = row(&mut m, "Mo");
|
||||
assert_eq!(
|
||||
(mo["tin_last4"].clone(), mo["w9_on_file"].clone()),
|
||||
(json!("4915"), json!(false))
|
||||
);
|
||||
assert_eq!(
|
||||
m.party("Mo", 2026)["party"]["tin"]["last4"],
|
||||
mo["tin_last4"]
|
||||
);
|
||||
let acme = row(&mut m, "Acme Design");
|
||||
assert_eq!(
|
||||
(acme["tin_last4"].clone(), acme["w9_on_file"].clone()),
|
||||
(json!("6789"), json!(true))
|
||||
);
|
||||
assert!(row(&mut m, "Nobody In Particular")["tin_last4"].is_null());
|
||||
m.ok(
|
||||
"books.purge_tin",
|
||||
json!({"party": "Mo", "reason": "left the company", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(row(&mut m, "Mo")["tin_last4"], "4915");
|
||||
let list = m.read_only("books.parties", json!({}));
|
||||
assert!(!holds_tin(list.to_string().as_bytes(), MEMBER_SSN_DIGITS));
|
||||
}
|
||||
Reference in new issue
Block a user