check / check (push) Canceled after 0s
An independent recompute of the real books found no numeric disagreements; review found 15 defects, all fixed with spec-first regression tests: - k1: a prepaid credited straight to capital is property; a member paying a vendor through 2100 is cash. Docs and B-13 match. - parties: a reversal of a reversal counts on its root's side; liability-to-liability payments (member pays the card) count as paid. - viewer: party money uses the server's cash accounts and full calendar year; per-party "What for" scales multi-party entries. - reconciliations: refuse a withdrawn statement before journaling; new books.void_statement; reconciled lines can't be un-cleared; one shared reconcilable-accounts rule for digest and viewer. - CPA package includes receipts linked to entries; a file can't be both filed on and linked to one entry. - parties = "require" covers payroll, tax remittance (agency) and disposal (buyer); no-party marks follow reversal chains and clear when a vendor is tagged. - derived member shares are frozen to exact amounts when posted, so a settings change never re-splits history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
1463 lines
53 KiB
Rust
1463 lines
53 KiB
Rust
//! Spec-blind tests for the attachment-link, statement, and reconciliation
|
||
//! tools of `ledger-mcp`, driven over stdio against the real binary.
|
||
//!
|
||
//! Expected behavior comes only from the tool descriptions and parameter /
|
||
//! result doc comments in `src/server.rs` and `src/types.rs` (the declared
|
||
//! interface, as `tools/list` serves it), `docs/mcp-interface.md` ("Tools —
|
||
//! attachments", "Tools — bank reconciliation"), and
|
||
//! `docs/parties-and-attachments.md` ("Attachments", "Reconciliations").
|
||
//! The harness is `tests/spec_parties.rs`'s: every success validates
|
||
//! against the tool's `outputSchema`, and every refusal leaves
|
||
//! `journal.jsonl` byte-identical (server invariant 2).
|
||
|
||
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>,
|
||
/// Every response line the server wrote, with the tool it answered.
|
||
transcript: Vec<(String, String)>,
|
||
}
|
||
|
||
impl Drop for Mcp {
|
||
fn drop(&mut self) {
|
||
let _ = self.child.kill();
|
||
let _ = self.child.wait();
|
||
}
|
||
}
|
||
|
||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||
|
||
fn test_dir(name: &str) -> PathBuf {
|
||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||
let dir = std::env::temp_dir().join(format!(
|
||
"ledger-mcp-spec-attachments-{}-{name}-{n}",
|
||
std::process::id()
|
||
));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
dir
|
||
}
|
||
|
||
/// A directory beside the books dir `dir`, outside it: the vault key,
|
||
/// files the tests hand the server, files it writes, its stderr.
|
||
fn outside(dir: &Path) -> PathBuf {
|
||
let d = PathBuf::from(format!("{}-outside", dir.display()));
|
||
std::fs::create_dir_all(&d).unwrap();
|
||
d
|
||
}
|
||
|
||
/// `wanderbooks.toml` with a company name, the receipt threshold, and
|
||
/// extra `[books]` TOML; the vault key file is outside the books dir.
|
||
fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf {
|
||
let path = dir.join("wanderbooks.toml");
|
||
std::fs::write(
|
||
&path,
|
||
format!(
|
||
"[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\
|
||
[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n\
|
||
[vault]\nkey_file = \"{}\"\n",
|
||
outside(dir).join("vault.key").display()
|
||
),
|
||
)
|
||
.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)
|
||
// Never the user's real vault key (the default key file is
|
||
// under XDG_CONFIG_HOME); the tests name theirs anyway.
|
||
.env("XDG_CONFIG_HOME", outside(dir).join("config"))
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::from(
|
||
std::fs::File::create(outside(dir).join("stderr.log")).unwrap(),
|
||
))
|
||
.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 {
|
||
stdin,
|
||
rx,
|
||
child,
|
||
next_id: 1,
|
||
data: dir.join("books-data"),
|
||
validators: HashMap::new(),
|
||
transcript: Vec::new(),
|
||
};
|
||
let init = mcp.rpc(
|
||
"initialize",
|
||
json!({
|
||
"protocolVersion": "2025-06-18",
|
||
"capabilities": {},
|
||
"clientInfo": {"name": "spec-attachments", "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}");
|
||
let what = params["name"].as_str().unwrap_or(method).to_string();
|
||
self.transcript.push((what, resp.to_string()));
|
||
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()
|
||
}
|
||
}
|
||
|
||
fn b64(bytes: &[u8]) -> String {
|
||
use base64::Engine as _;
|
||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||
}
|
||
|
||
const CHART: &[(&str, &str, &str)] = &[
|
||
("1000", "Bank", "asset"),
|
||
("1300", "Prepaid Expenses", "asset"),
|
||
("1500", "Equipment", "asset"),
|
||
("1510", "Accumulated Depreciation", "asset"),
|
||
("2050", "Card", "liability"),
|
||
("2100", "Due to Members", "liability"),
|
||
("3000", "Capital", "equity"),
|
||
("4000", "Revenue", "revenue"),
|
||
("6000", "Contract Labor", "expense"),
|
||
("6500", "Depreciation", "expense"),
|
||
("6999", "General Expenses", "expense"),
|
||
];
|
||
|
||
/// Books with the chart and a receipt threshold of `threshold`.
|
||
fn books_at(name: &str, threshold: &str, extra: &str) -> Mcp {
|
||
let dir = test_dir(name);
|
||
let toml = books_toml(&dir, threshold, extra);
|
||
let mut m = Mcp::start(&dir, &toml);
|
||
m.accounts(CHART);
|
||
m
|
||
}
|
||
|
||
/// Books where ordinary entries need no receipt.
|
||
fn books(name: &str) -> Mcp {
|
||
books_at(name, "1000000.00", "")
|
||
}
|
||
|
||
impl Mcp {
|
||
/// File a document; its id.
|
||
fn doc(&mut self, category: &str, bytes: &[u8], link_to: Value) -> u64 {
|
||
self.ok(
|
||
"books.file_document",
|
||
json!({"category": category, "file_name": format!("{category}.pdf"),
|
||
"mime_type": "application/pdf", "content_base64": b64(bytes),
|
||
"link_to": link_to}),
|
||
)["attachment_id"]
|
||
.as_u64()
|
||
.unwrap()
|
||
}
|
||
|
||
/// Attach a receipt (to `entry`, or unlinked); its id.
|
||
fn receipt(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
|
||
let mut args = json!({"file_name": "r.pdf", "mime_type": "application/pdf",
|
||
"content_base64": b64(bytes)});
|
||
if let Some(e) = entry {
|
||
args["entry_id"] = json!(e);
|
||
}
|
||
self.ok("books.attach_receipt", args)["attachment_id"]
|
||
.as_u64()
|
||
.unwrap()
|
||
}
|
||
|
||
fn link(&mut self, attachment: u64, subject: Value) -> Value {
|
||
self.ok(
|
||
"books.link_attachment",
|
||
json!({"attachment_id": attachment, "subject": subject}),
|
||
)
|
||
}
|
||
|
||
/// The files on a subject (read-only).
|
||
fn files_on(&mut self, subject: Value) -> Vec<u64> {
|
||
self.read_only("books.attachments", json!({"subject": subject}))["attachments"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|a| a["attachment_id"].as_u64().unwrap())
|
||
.collect()
|
||
}
|
||
|
||
/// Record a statement for `account`.
|
||
fn statement(&mut self, account: &str, start: &str, end: &str, close: &str) -> u64 {
|
||
let id = self.doc(
|
||
"statement",
|
||
format!("{account} {end}").as_bytes(),
|
||
json!([]),
|
||
);
|
||
self.ok(
|
||
"books.record_statement",
|
||
json!({"attachment_id": id, "account": account, "period_start": start,
|
||
"period_end": end, "opening_balance": "0.00", "closing_balance": close}),
|
||
);
|
||
id
|
||
}
|
||
|
||
/// Mark every line of `entry` on any account cleared.
|
||
fn clear(&mut self, entry: u64, line: usize) {
|
||
self.ok(
|
||
"books.mark_cleared",
|
||
json!({"entry_id": entry, "line_index": line, "cleared": true}),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The (kind, label) pairs of an output's `links`.
|
||
fn links(v: &Value) -> Vec<(String, String)> {
|
||
v["links"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|l| {
|
||
(
|
||
l["kind"].as_str().unwrap().to_string(),
|
||
l["label"].as_str().unwrap().to_string(),
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn kinds(v: &Value) -> Vec<String> {
|
||
links(v).into_iter().map(|(k, _)| k).collect()
|
||
}
|
||
|
||
// ------------------------------------------------- books.link_attachment
|
||
|
||
/// Every subject kind links, each named the way the other tools name it
|
||
/// (account by code, asset by tag, party by name), each journaled as its
|
||
/// own attachment.linked, and every listing shows the links.
|
||
#[test]
|
||
fn a_file_links_to_every_kind_of_subject() {
|
||
let mut m = books("link-kinds");
|
||
let e = m.post(
|
||
"2026-01-05",
|
||
json!({}),
|
||
&[("6999", "debit", "10.00"), ("1000", "credit", "10.00")],
|
||
);
|
||
let asset = m.ok(
|
||
"books.register_asset",
|
||
json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "3000.00",
|
||
"salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500",
|
||
"accum_depr_account": "1510", "expense_account": "6500", "tag": "LAP-1",
|
||
"tax_basis_rule": "cost"}),
|
||
);
|
||
assert!(asset.get("asset_id").is_some(), "{asset}");
|
||
m.add_party("Acme Design", &["contractor"]);
|
||
let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"].clone();
|
||
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"].clone();
|
||
let bill = m.ok(
|
||
"books.create_bill",
|
||
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
|
||
"lines": [{"description": "Rent", "amount": "300.00"}]}),
|
||
)["id"]
|
||
.clone();
|
||
let invoice = m.ok(
|
||
"books.create_invoice",
|
||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||
"lines": [{"description": "Work", "amount": "500.00"}]}),
|
||
)["id"]
|
||
.clone();
|
||
let period = m.ok(
|
||
"books.create_period",
|
||
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
|
||
)["period_id"]
|
||
.clone();
|
||
let schedule = m.ok(
|
||
"books.create_schedule",
|
||
json!({"kind": "prepaid_expense", "description": "Insurance", "total": "1200.00",
|
||
"start": "2026-01-01", "months": 12, "balance_account": "1300", "pl_account": "6999"}),
|
||
)["schedule_id"]
|
||
.clone();
|
||
m.add_party("Pat Doe", &["employee"]);
|
||
let run = m.ok(
|
||
"books.run_payroll",
|
||
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [{
|
||
"employee": "Pat Doe", "gross_pay": "1000.00", "federal_withholding": "100.00",
|
||
"state_withholding": "50.00", "ss_employee": "62.00", "medicare_employee": "14.50",
|
||
"ss_employer": "62.00", "medicare_employer": "14.50", "other_deductions": "0.00"}]}),
|
||
)["run_id"]
|
||
.clone();
|
||
m.clear(e, 1);
|
||
let st = m.statement("1000", "2026-01-01", "2026-01-31", "-10.00");
|
||
let rec = m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "confirmed": true}),
|
||
)["reconciliation"]["reconciliation_id"]
|
||
.clone();
|
||
|
||
let f = m.doc("other", b"one file, many subjects", json!([]));
|
||
let before = m.count("attachment.linked");
|
||
let subjects = [
|
||
json!({"kind": "entry", "id": e}),
|
||
json!({"kind": "account", "id": "1000"}),
|
||
json!({"kind": "asset", "id": "LAP-1"}),
|
||
json!({"kind": "party", "id": " acme DESIGN "}),
|
||
json!({"kind": "invoice", "id": invoice}),
|
||
json!({"kind": "bill", "id": bill}),
|
||
json!({"kind": "period", "id": period}),
|
||
json!({"kind": "payroll_run", "id": run}),
|
||
json!({"kind": "schedule", "id": schedule}),
|
||
json!({"kind": "reconciliation", "id": rec}),
|
||
json!({"kind": "company"}),
|
||
];
|
||
for s in &subjects {
|
||
m.link(f, s.clone());
|
||
}
|
||
assert_eq!(m.count("attachment.linked"), before + subjects.len());
|
||
let got = m.read_only("books.get_receipt", json!({"attachment_id": f}));
|
||
let mut ks = kinds(&got);
|
||
ks.sort();
|
||
let mut want: Vec<String> = subjects
|
||
.iter()
|
||
.map(|s| s["kind"].as_str().unwrap().to_string())
|
||
.collect();
|
||
want.sort();
|
||
assert_eq!(ks, want);
|
||
let labels = links(&got);
|
||
assert!(
|
||
labels
|
||
.iter()
|
||
.any(|(k, l)| k == "account" && l.contains("1000") && l.contains("Bank")),
|
||
"{labels:?}"
|
||
);
|
||
assert!(
|
||
labels
|
||
.iter()
|
||
.any(|(k, l)| k == "asset" && l.contains("LAP-1")),
|
||
"{labels:?}"
|
||
);
|
||
assert!(
|
||
labels
|
||
.iter()
|
||
.any(|(k, l)| k == "party" && l.contains("Acme Design")),
|
||
"{labels:?}"
|
||
);
|
||
let company = got["links"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|l| l["kind"] == "company")
|
||
.unwrap()
|
||
.clone();
|
||
assert!(company["id"].is_null(), "the company has no id: {company}");
|
||
// The listing shows the same links.
|
||
let listed = m.read_only("books.list_receipts", json!({}))["receipts"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|r| r["attachment_id"] == json!(f))
|
||
.unwrap()
|
||
.clone();
|
||
assert_eq!(links(&listed), links(&got));
|
||
// Each subject lists the file.
|
||
for s in subjects {
|
||
assert!(m.files_on(s.clone()).contains(&f), "{s} lists it");
|
||
}
|
||
// An account named by id works too (its id is not a code here).
|
||
let chart = m.read_only("books.chart", json!({}));
|
||
let card_id = chart["accounts"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|a| a["code"] == "2050")
|
||
.unwrap()["id"]
|
||
.clone();
|
||
m.link(f, json!({"kind": "account", "id": card_id}));
|
||
assert!(
|
||
m.files_on(json!({"kind": "account", "id": "2050"}))
|
||
.contains(&f)
|
||
);
|
||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||
}
|
||
|
||
#[test]
|
||
fn link_refusals_change_nothing() {
|
||
let mut m = books("link-refusals");
|
||
let e = m.draft("2026-01-05", json!({}), &[]);
|
||
let r = m.receipt(Some(e), b"filed on e");
|
||
let f = m.doc("other", b"a document", json!([]));
|
||
let gone = m.doc("other", b"withdrawn", json!([]));
|
||
m.ok(
|
||
"books.withdraw_attachment",
|
||
json!({"attachment_id": gone, "reason": "not ours"}),
|
||
);
|
||
m.link(f, json!({"kind": "account", "id": "1000"}));
|
||
for (att, subject) in [
|
||
(999, json!({"kind": "company"})), // unknown attachment
|
||
(gone, json!({"kind": "company"})), // withdrawn
|
||
(f, json!({"kind": "planet", "id": 1})), // unknown kind
|
||
(f, json!({"kind": "entry"})), // no id
|
||
(f, json!({"kind": "company", "id": 1})), // the company takes none
|
||
(f, json!({"kind": "entry", "id": 999})), // no such entry
|
||
(f, json!({"kind": "entry", "id": "five"})), // not a number
|
||
(f, json!({"kind": "account", "id": "9999"})), // no such account
|
||
(f, json!({"kind": "asset", "id": "NOPE"})), // no such asset
|
||
(f, json!({"kind": "party", "id": "Nobody"})), // no such party
|
||
(f, json!({"kind": "reconciliation", "id": 1})), // none recorded
|
||
(f, json!({"kind": "account", "id": "1000"})), // linked already
|
||
(r, json!({"kind": "entry", "id": e})), // filed on it already
|
||
] {
|
||
m.refuse(
|
||
"books.link_attachment",
|
||
json!({"attachment_id": att, "subject": subject}),
|
||
);
|
||
}
|
||
assert_eq!(m.count("attachment.linked"), 1);
|
||
}
|
||
|
||
// ------------------------------------------------- receipts by link
|
||
|
||
/// A file linked to an entry is one of its receipts: the policy counts it
|
||
/// when posting, and won't let the last one come off a posted entry.
|
||
#[test]
|
||
fn a_linked_file_is_a_receipt_the_policy_counts() {
|
||
let mut m = books_at("link-receipt", "0.00", "");
|
||
let a = m.draft(
|
||
"2026-01-05",
|
||
json!({}),
|
||
&[("6999", "debit", "10.00"), ("1000", "credit", "10.00")],
|
||
);
|
||
let b = m.draft(
|
||
"2026-01-05",
|
||
json!({}),
|
||
&[("6000", "debit", "20.00"), ("1000", "credit", "20.00")],
|
||
);
|
||
// One invoice covering both entries: filed on a, linked to b.
|
||
let inv = m.receipt(Some(a), b"one invoice, two entries");
|
||
m.refuse(
|
||
"books.post_entry",
|
||
json!({"entry_id": b, "confirmed": true}),
|
||
);
|
||
m.link(inv, json!({"kind": "entry", "id": b}));
|
||
m.ok(
|
||
"books.post_entry",
|
||
json!({"entry_id": a, "confirmed": true}),
|
||
);
|
||
let preview = m.ok("books.post_entry", json!({"entry_id": b}));
|
||
assert_eq!(preview["receipt_missing"], false, "{preview}");
|
||
m.ok(
|
||
"books.post_entry",
|
||
json!({"entry_id": b, "confirmed": true}),
|
||
);
|
||
assert_eq!(m.files_on(json!({"kind": "entry", "id": b})), [inv]);
|
||
let listed = m.read_only("books.list_receipts", json!({"entry_id": b}))["receipts"]
|
||
.as_array()
|
||
.unwrap()
|
||
.len();
|
||
assert_eq!(listed, 1, "list_receipts by entry counts links");
|
||
// Its last receipt can't come off.
|
||
let why = m.refuse(
|
||
"books.unlink_attachment",
|
||
json!({"attachment_id": inv, "subject": {"kind": "entry", "id": b}, "reason": "wrong entry"}),
|
||
);
|
||
assert!(why.contains("receipt_required"), "{why}");
|
||
// A filed receipt comes off with unlink_receipt, not here.
|
||
let why = m.refuse(
|
||
"books.unlink_attachment",
|
||
json!({"attachment_id": inv, "subject": {"kind": "entry", "id": a}, "reason": "x"}),
|
||
);
|
||
assert!(why.contains("books.unlink_receipt"), "{why}");
|
||
// With a replacement on b, the link comes off.
|
||
m.receipt(Some(b), b"b's own receipt");
|
||
let out = m.ok(
|
||
"books.unlink_attachment",
|
||
json!({"attachment_id": inv, "subject": {"kind": "entry", "id": b}, "reason": "b has its own"}),
|
||
);
|
||
assert_eq!(kinds(&out), ["entry"], "still filed on a: {out}");
|
||
assert_eq!(m.count("attachment.unlinked"), 1);
|
||
}
|
||
|
||
// ------------------------------------------------- books.unlink_attachment
|
||
|
||
#[test]
|
||
fn unlink_refusals_change_nothing() {
|
||
let mut m = books("unlink-refusals");
|
||
let f = m.doc("contract", b"lease", json!([{"kind": "company"}]));
|
||
for args in [
|
||
json!({"attachment_id": f, "subject": {"kind": "company"}, "reason": " "}),
|
||
json!({"attachment_id": f, "subject": {"kind": "account", "id": "1000"}, "reason": "not there"}),
|
||
json!({"attachment_id": 999, "subject": {"kind": "company"}, "reason": "x"}),
|
||
json!({"attachment_id": f, "subject": {"kind": "planet"}, "reason": "x"}),
|
||
] {
|
||
m.refuse("books.unlink_attachment", args);
|
||
}
|
||
let out = m.ok(
|
||
"books.unlink_attachment",
|
||
json!({"attachment_id": f, "subject": {"kind": "company"}, "reason": "a lease, not formation"}),
|
||
);
|
||
assert!(kinds(&out).is_empty(), "{out}");
|
||
assert!(m.files_on(json!({"kind": "company"})).is_empty());
|
||
// The file stays filed and listed.
|
||
let got = m.read_only("books.get_receipt", json!({"attachment_id": f}));
|
||
assert_eq!(got["category"], "contract");
|
||
// The journal keeps both the link and the unlink, with the reason.
|
||
let journal = String::from_utf8(m.journal()).unwrap();
|
||
assert!(journal.contains("a lease, not formation"));
|
||
assert_eq!(m.count("attachment.linked"), 1);
|
||
assert_eq!(m.count("attachment.unlinked"), 1);
|
||
}
|
||
|
||
// ------------------------------------------------- link_to on filing
|
||
|
||
/// `link_to` links in the same call, each link its own event; one bad
|
||
/// subject files nothing.
|
||
#[test]
|
||
fn filing_links_in_the_same_call_all_or_nothing() {
|
||
let mut m = books("link-to");
|
||
let e = m.draft("2026-01-05", json!({}), &[]);
|
||
let other = m.draft("2026-01-06", json!({}), &[]);
|
||
m.add_party("Acme Design", &["contractor"]);
|
||
let count = |m: &mut Mcp| {
|
||
m.read_only("books.list_receipts", json!({"include_withdrawn": true}))["receipts"]
|
||
.as_array()
|
||
.unwrap()
|
||
.len()
|
||
};
|
||
let n = count(&mut m);
|
||
for link_to in [
|
||
json!([{"kind": "party", "id": "Acme Design"}, {"kind": "entry", "id": 999}]),
|
||
json!([{"kind": "company"}, {"kind": "company"}]),
|
||
json!([{"kind": "entry", "id": e}]),
|
||
json!([{"kind": "nope"}]),
|
||
] {
|
||
m.refuse(
|
||
"books.attach_receipt",
|
||
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||
"content_base64": b64(b"r"), "link_to": link_to}),
|
||
);
|
||
}
|
||
m.refuse(
|
||
"books.file_document",
|
||
json!({"category": "contract", "file_name": "c.pdf", "mime_type": "application/pdf",
|
||
"content_base64": b64(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}),
|
||
);
|
||
// An unknown entry_id is refused before anything is filed.
|
||
m.refuse(
|
||
"books.attach_receipt",
|
||
json!({"entry_id": 999, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||
"content_base64": b64(b"r"), "link_to": [{"kind": "company"}]}),
|
||
);
|
||
assert_eq!(count(&mut m), n, "nothing filed");
|
||
let out = m.ok(
|
||
"books.attach_receipt",
|
||
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||
"content_base64": b64(b"r"),
|
||
"link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}),
|
||
);
|
||
assert_eq!(
|
||
kinds(&out),
|
||
["entry", "entry", "party"],
|
||
"filed entry first: {out}"
|
||
);
|
||
assert_eq!(out["links"][0]["id"], json!(e));
|
||
assert_eq!(m.count("receipt.attached"), 1);
|
||
assert_eq!(m.count("attachment.linked"), 2);
|
||
let doc = m.ok(
|
||
"books.file_document",
|
||
json!({"category": "formation", "file_name": "cert.pdf", "mime_type": "application/pdf",
|
||
"content_base64": b64(b"certificate"), "link_to": [{"kind": "company"}]}),
|
||
);
|
||
assert_eq!(kinds(&doc), ["company"]);
|
||
assert_eq!(m.count("document.filed"), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn documents_file_under_the_attachment_kinds() {
|
||
let mut m = books("categories");
|
||
for c in [
|
||
"formation",
|
||
"legal",
|
||
"tax",
|
||
"accounting",
|
||
"statement",
|
||
"contract",
|
||
"photo",
|
||
"warranty",
|
||
"import_source",
|
||
"other",
|
||
] {
|
||
let id = m.doc(c, c.as_bytes(), json!([]));
|
||
assert_eq!(
|
||
m.read_only("books.get_receipt", json!({"attachment_id": id}))["category"],
|
||
c
|
||
);
|
||
}
|
||
m.refuse(
|
||
"books.file_document",
|
||
json!({"category": "receipt-ish", "file_name": "x", "mime_type": "text/plain",
|
||
"content_base64": b64(b"x")}),
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------- books.attachments
|
||
|
||
/// An entry's files are those filed on it and those linked to it, without
|
||
/// the withdrawn; each comes with all its links.
|
||
#[test]
|
||
fn a_subjects_files_are_filed_and_linked_not_withdrawn() {
|
||
let mut m = books("attachments");
|
||
let e = m.draft("2026-01-05", json!({}), &[]);
|
||
let filed = m.receipt(Some(e), b"filed");
|
||
let linked = m.doc(
|
||
"other",
|
||
b"linked",
|
||
json!([{"kind": "entry", "id": e}, {"kind": "company"}]),
|
||
);
|
||
let gone = m.receipt(None, b"to withdraw");
|
||
m.link(gone, json!({"kind": "company"}));
|
||
m.ok(
|
||
"books.unlink_attachment",
|
||
json!({"attachment_id": gone, "subject": {"kind": "company"}, "reason": "wrong"}),
|
||
);
|
||
m.ok(
|
||
"books.withdraw_attachment",
|
||
json!({"attachment_id": gone, "reason": "a duplicate"}),
|
||
);
|
||
assert_eq!(
|
||
m.files_on(json!({"kind": "entry", "id": e})),
|
||
[filed, linked]
|
||
);
|
||
assert_eq!(m.files_on(json!({"kind": "company"})), [linked]);
|
||
let out = m.read_only(
|
||
"books.attachments",
|
||
json!({"subject": {"kind": "entry", "id": e}}),
|
||
);
|
||
assert_eq!(out["subject"]["kind"], "entry");
|
||
assert_eq!(out["subject"]["id"], json!(e));
|
||
// Its links: the filing entry first (none here), then by kind.
|
||
assert_eq!(kinds(&out["attachments"][1]), ["company", "entry"]);
|
||
m.refuse(
|
||
"books.attachments",
|
||
json!({"subject": {"kind": "entry", "id": 999}}),
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------- statements
|
||
|
||
#[test]
|
||
fn record_statement_links_it_to_its_account() {
|
||
let mut m = books("statement");
|
||
let f = m.doc("statement", b"jan", json!([]));
|
||
let st = m.ok(
|
||
"books.record_statement",
|
||
json!({"attachment_id": f, "account": "1000", "period_start": "2026-01-01",
|
||
"period_end": "2026-01-31", "opening_balance": "0", "closing_balance": "1250.5"}),
|
||
);
|
||
assert_eq!(st["account_code"], "1000");
|
||
assert_eq!(st["account_name"], "Bank");
|
||
assert_eq!(st["period_end"], "2026-01-31");
|
||
assert_eq!(st["opening_balance"], "0.00");
|
||
assert_eq!(st["closing_balance"], "1250.50");
|
||
assert_eq!(st["file_name"], "statement.pdf");
|
||
assert!(st["reconciliation_id"].is_null());
|
||
assert_eq!(m.count("statement.recorded"), 1);
|
||
assert!(
|
||
m.files_on(json!({"kind": "account", "id": "1000"}))
|
||
.contains(&f)
|
||
);
|
||
// A card statement too (a liability).
|
||
let card = m.statement("2050", "2026-01-01", "2026-01-31", "80.00");
|
||
let all = m.read_only("books.statements", json!({}))["statements"]
|
||
.as_array()
|
||
.unwrap()
|
||
.len();
|
||
assert_eq!(all, 2);
|
||
let one = m.read_only("books.statements", json!({"account": "2050"}));
|
||
assert_eq!(one["statements"].as_array().unwrap().len(), 1);
|
||
assert_eq!(one["statements"][0]["attachment_id"], json!(card));
|
||
}
|
||
|
||
#[test]
|
||
fn record_statement_refusals_change_nothing() {
|
||
let mut m = books("statement-refusals");
|
||
let f = m.doc("statement", b"jan", json!([]));
|
||
let gone = m.doc("statement", b"gone", json!([]));
|
||
m.ok(
|
||
"books.withdraw_attachment",
|
||
json!({"attachment_id": gone, "reason": "duplicate"}),
|
||
);
|
||
let base = json!({"attachment_id": f, "account": "1000", "period_start": "2026-01-01",
|
||
"period_end": "2026-01-31", "opening_balance": "0.00",
|
||
"closing_balance": "10.00"});
|
||
let with = |k: &str, v: Value| {
|
||
let mut a = base.clone();
|
||
a[k] = v;
|
||
a
|
||
};
|
||
for args in [
|
||
with("attachment_id", json!(999)),
|
||
with("attachment_id", json!(gone)),
|
||
with("account", json!("6999")), // an expense account
|
||
with("account", json!("4000")), // revenue
|
||
with("account", json!("9999")), // unknown
|
||
with("period_end", json!("2025-12-31")), // backwards
|
||
with("period_end", json!("2026-02-30")), // not a date
|
||
with("closing_balance", json!("10.001")), // past cents
|
||
with("closing_balance", json!("1e1")), // not a decimal string
|
||
with("opening_balance", json!(10.0)), // a JSON number
|
||
] {
|
||
m.refuse("books.record_statement", args);
|
||
}
|
||
m.ok("books.record_statement", base.clone());
|
||
m.refuse("books.record_statement", base);
|
||
assert_eq!(m.count("statement.recorded"), 1);
|
||
}
|
||
|
||
// ------------------------------------------------- books.reconcile
|
||
|
||
/// Books with a January: a deposit, a payment, and one more payment after
|
||
/// the statement date. Returns (deposit, payment) entry ids.
|
||
fn january(m: &mut Mcp) -> (u64, u64) {
|
||
let dep = m.post(
|
||
"2026-01-02",
|
||
json!({}),
|
||
&[("1000", "debit", "1000.00"), ("3000", "credit", "1000.00")],
|
||
);
|
||
let pay = m.post(
|
||
"2026-01-20",
|
||
json!({}),
|
||
&[("6000", "debit", "200.00"), ("1000", "credit", "200.00")],
|
||
);
|
||
m.post(
|
||
"2026-02-03",
|
||
json!({}),
|
||
&[("6999", "debit", "5.00"), ("1000", "credit", "5.00")],
|
||
);
|
||
(dep, pay)
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_previews_without_recording() {
|
||
let mut m = books("reconcile-preview");
|
||
let (dep, pay) = january(&mut m);
|
||
// A quick check against any balance: a preview, never recorded.
|
||
let p = m.read_only(
|
||
"books.reconcile",
|
||
json!({"account": "1000", "statement_date": "2026-01-31", "statement_balance": "800.00"}),
|
||
);
|
||
assert_eq!(p["status"], "preview");
|
||
assert_eq!(p["book_balance"], "795.00");
|
||
assert_eq!(p["cleared_balance"], "0.00");
|
||
assert_eq!(p["difference"], "-800.00");
|
||
assert_eq!(p["uncleared_lines"].as_array().unwrap().len(), 2);
|
||
assert!(p["cleared_lines"].as_array().unwrap().is_empty());
|
||
assert!(p["reconciliation"].is_null());
|
||
assert!(!p["problems"].as_array().unwrap().is_empty());
|
||
// Confirming without a recorded statement is refused.
|
||
m.refuse(
|
||
"books.reconcile",
|
||
json!({"account": "1000", "statement_date": "2026-01-31", "statement_balance": "800.00",
|
||
"confirmed": true}),
|
||
);
|
||
// Neither a statement nor all three facts.
|
||
m.refuse("books.reconcile", json!({"account": "1000"}));
|
||
// Against a recorded statement, with the lines cleared: no problems.
|
||
let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00");
|
||
m.clear(dep, 0);
|
||
m.clear(pay, 1);
|
||
let p = m.read_only("books.reconcile", json!({"statement_attachment": st}));
|
||
assert_eq!(p["status"], "preview");
|
||
assert_eq!(p["statement_attachment"], json!(st));
|
||
assert_eq!(p["statement_date"], "2026-01-31");
|
||
assert_eq!(p["cleared_balance"], "800.00");
|
||
assert_eq!(p["difference"], "0.00");
|
||
assert!(p["problems"].as_array().unwrap().is_empty(), "{p}");
|
||
let cleared: Vec<(u64, u64)> = p["cleared_lines"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|l| {
|
||
(
|
||
l["entry_id"].as_u64().unwrap(),
|
||
l["line_index"].as_u64().unwrap(),
|
||
)
|
||
})
|
||
.collect();
|
||
assert_eq!(cleared, [(dep, 0), (pay, 1)]);
|
||
assert_eq!(m.count("reconciliation.completed"), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn reconcile_confirmed_records_against_the_statement() {
|
||
let mut m = books("reconcile-confirm");
|
||
let (dep, pay) = january(&mut m);
|
||
let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00");
|
||
m.clear(dep, 0);
|
||
// A difference refuses, naming it.
|
||
let why = m.refuse(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "confirmed": true}),
|
||
);
|
||
assert!(why.contains("200.00"), "{why}");
|
||
m.clear(pay, 1);
|
||
// Facts given beside the statement must be the statement's.
|
||
for (k, v) in [
|
||
("account", json!("2050")),
|
||
("statement_date", json!("2026-01-30")),
|
||
("statement_balance", json!("800.01")),
|
||
] {
|
||
let mut args = json!({"statement_attachment": st, "confirmed": true});
|
||
args[k] = v;
|
||
m.refuse("books.reconcile", args);
|
||
}
|
||
// A file that records no statement.
|
||
let not = m.doc("other", b"not a statement", json!([]));
|
||
m.refuse(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": not, "confirmed": true}),
|
||
);
|
||
let out = m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "account": "1000", "confirmed": true}),
|
||
);
|
||
assert_eq!(out["status"], "reconciled");
|
||
let r = &out["reconciliation"];
|
||
assert_eq!(r["account_code"], "1000");
|
||
assert_eq!(r["statement_date"], "2026-01-31");
|
||
assert_eq!(r["statement_balance"], "800.00");
|
||
assert_eq!(r["statement_attachment"], json!(st));
|
||
assert_eq!(r["voided"], false);
|
||
assert_eq!(
|
||
r["cleared"],
|
||
json!([{"entry_id": dep, "line_index": 0}, {"entry_id": pay, "line_index": 1}])
|
||
);
|
||
assert_eq!(m.count("reconciliation.completed"), 1);
|
||
let rid = r["reconciliation_id"].as_u64().unwrap();
|
||
// The statement is linked to the reconciliation and lists it.
|
||
assert!(
|
||
m.files_on(json!({"kind": "reconciliation", "id": rid}))
|
||
.contains(&st)
|
||
);
|
||
let sts = m.read_only("books.statements", json!({"account": "1000"}));
|
||
assert_eq!(sts["statements"][0]["reconciliation_id"], json!(rid));
|
||
// The same date again is refused; the preview says why.
|
||
let p = m.read_only("books.reconcile", json!({"statement_attachment": st}));
|
||
assert!(
|
||
p["problems"].as_array().unwrap().iter().any(|x| x
|
||
.as_str()
|
||
.unwrap()
|
||
.contains("already reconciled through 2026-01-31")),
|
||
"{p}"
|
||
);
|
||
m.refuse(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "confirmed": true}),
|
||
);
|
||
// The statement can't come off a reconciliation that stands.
|
||
m.refuse(
|
||
"books.unlink_attachment",
|
||
json!({"attachment_id": st, "subject": {"kind": "reconciliation", "id": rid}, "reason": "x"}),
|
||
);
|
||
// February, forward: fine.
|
||
let feb_pay = m.read_only("books.search_entries", json!({"text": "2026-02-03"}));
|
||
let feb = feb_pay["entries"][0]["entry_id"].as_u64().unwrap();
|
||
m.clear(feb, 1);
|
||
let st2 = m.statement("1000", "2026-02-01", "2026-02-28", "795.00");
|
||
m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st2, "confirmed": true}),
|
||
);
|
||
let all = m.read_only("books.reconciliations", json!({"account": "1000"}));
|
||
assert_eq!(all["reconciliations"].as_array().unwrap().len(), 2);
|
||
assert!(
|
||
m.read_only("books.reconciliations", json!({"account": "2050"}))["reconciliations"]
|
||
.as_array()
|
||
.unwrap()
|
||
.is_empty()
|
||
);
|
||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||
}
|
||
|
||
#[test]
|
||
fn void_reconciliation_lets_it_be_redone() {
|
||
let mut m = books("void");
|
||
let (dep, pay) = january(&mut m);
|
||
m.clear(dep, 0);
|
||
m.clear(pay, 1);
|
||
let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00");
|
||
let rid = m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "confirmed": true}),
|
||
)["reconciliation"]["reconciliation_id"]
|
||
.as_u64()
|
||
.unwrap();
|
||
for args in [
|
||
json!({"reconciliation_id": rid, "reason": " "}),
|
||
json!({"reconciliation_id": 999, "reason": "no such"}),
|
||
] {
|
||
m.refuse("books.void_reconciliation", args);
|
||
}
|
||
let v = m.ok(
|
||
"books.void_reconciliation",
|
||
json!({"reconciliation_id": rid, "reason": "cleared the wrong deposit"}),
|
||
);
|
||
assert_eq!(v["voided"], true);
|
||
assert_eq!(v["voided_reason"], "cleared the wrong deposit");
|
||
m.refuse(
|
||
"books.void_reconciliation",
|
||
json!({"reconciliation_id": rid, "reason": "again"}),
|
||
);
|
||
assert_eq!(m.count("reconciliation.voided"), 1);
|
||
// Voided: the statement can come off it, and the month can be redone.
|
||
let listed = m.read_only("books.reconciliations", json!({}));
|
||
assert_eq!(listed["reconciliations"][0]["voided"], true);
|
||
m.ok(
|
||
"books.unlink_attachment",
|
||
json!({"attachment_id": st, "subject": {"kind": "reconciliation", "id": rid},
|
||
"reason": "voided"}),
|
||
);
|
||
let redo = m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "confirmed": true}),
|
||
);
|
||
assert_ne!(redo["reconciliation"]["reconciliation_id"], json!(rid));
|
||
}
|
||
|
||
/// books.reconcile SCENARIO 1: a statement whose file was withdrawn is
|
||
/// retired; the preview and the confirmation are refused, and nothing is
|
||
/// journaled (it used to return an error after journaling
|
||
/// reconciliation.completed). books.statements no longer lists it.
|
||
#[test]
|
||
fn a_withdrawn_statement_is_retired_and_reconciles_nothing() {
|
||
let mut m = books("withdrawn-statement");
|
||
let (dep, pay) = january(&mut m);
|
||
m.clear(dep, 0);
|
||
m.clear(pay, 1);
|
||
let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00");
|
||
m.ok(
|
||
"books.unlink_attachment",
|
||
json!({"attachment_id": st, "subject": {"kind": "account", "id": 1}, "reason": "wrong file"}),
|
||
);
|
||
m.ok(
|
||
"books.withdraw_attachment",
|
||
json!({"attachment_id": st, "reason": "wrong file"}),
|
||
);
|
||
let why = m.refuse("books.reconcile", json!({"statement_attachment": st}));
|
||
assert!(why.contains("withdrawn"), "{why}");
|
||
m.refuse(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "confirmed": true}),
|
||
);
|
||
assert_eq!(m.count("reconciliation.completed"), 0);
|
||
assert!(
|
||
m.read_only("books.reconciliations", json!({}))["reconciliations"]
|
||
.as_array()
|
||
.unwrap()
|
||
.is_empty()
|
||
);
|
||
assert!(
|
||
m.read_only("books.statements", json!({}))["statements"]
|
||
.as_array()
|
||
.unwrap()
|
||
.is_empty()
|
||
);
|
||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||
}
|
||
|
||
/// books.void_statement SCENARIOS 1–2: a statement recorded with the wrong
|
||
/// closing balance is voided and recorded again on the same file, then
|
||
/// reconciles; voiding it while that reconciliation stands is refused.
|
||
#[test]
|
||
fn a_mis_recorded_statement_is_voided_and_recorded_again() {
|
||
let mut m = books("void-statement");
|
||
let (dep, pay) = january(&mut m);
|
||
m.clear(dep, 0);
|
||
m.clear(pay, 1);
|
||
let st = m.statement("1000", "2026-01-01", "2026-01-31", "880.00");
|
||
m.refuse(
|
||
"books.void_statement",
|
||
json!({"attachment_id": st, "reason": " "}),
|
||
);
|
||
let v = m.ok(
|
||
"books.void_statement",
|
||
json!({"attachment_id": st, "reason": "closing balance mistyped"}),
|
||
);
|
||
assert_eq!(v["closing_balance"], "880.00");
|
||
assert_eq!(m.count("statement.voided"), 1);
|
||
m.refuse(
|
||
"books.void_statement",
|
||
json!({"attachment_id": st, "reason": "again"}),
|
||
);
|
||
assert!(
|
||
m.read_only("books.statements", json!({}))["statements"]
|
||
.as_array()
|
||
.unwrap()
|
||
.is_empty()
|
||
);
|
||
m.ok(
|
||
"books.record_statement",
|
||
json!({"attachment_id": st, "account": "1000", "period_start": "2026-01-01",
|
||
"period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "800.00"}),
|
||
);
|
||
m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "confirmed": true}),
|
||
);
|
||
m.refuse(
|
||
"books.void_statement",
|
||
json!({"attachment_id": st, "reason": "why"}),
|
||
);
|
||
// A line the reconciliation cleared stays cleared while it stands.
|
||
m.refuse(
|
||
"books.mark_cleared",
|
||
json!({"entry_id": pay, "line_index": 1, "cleared": false}),
|
||
);
|
||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||
}
|
||
|
||
/// books.export_cpa_package `include_receipts`: receipts/ holds every
|
||
/// current file behind each posted entry, filed on it or linked to it (a
|
||
/// linked file counts as the entry's receipt, so the receipt policy let
|
||
/// the entry post), one copy per entry with an index row; documents/ holds
|
||
/// the files behind no entry. A linked-only receipt used to be left out
|
||
/// of the package entirely.
|
||
#[test]
|
||
fn the_cpa_package_carries_receipts_linked_to_entries() {
|
||
use base64::Engine as _;
|
||
let mut m = books_at("cpa-linked", "0.00", "");
|
||
let receipt = m.receipt(None, b"%PDF linked receipt");
|
||
let e = m.draft(
|
||
"2026-03-01",
|
||
json!({}),
|
||
&[("6999", "debit", "40.00"), ("1000", "credit", "40.00")],
|
||
);
|
||
m.link(receipt, json!({"kind": "entry", "id": e}));
|
||
// A contract linked to the same entry is evidence for it too.
|
||
m.doc(
|
||
"contract",
|
||
b"%PDF contract",
|
||
json!([{"kind": "entry", "id": e}]),
|
||
);
|
||
m.ok(
|
||
"books.post_entry",
|
||
json!({"entry_id": e, "confirmed": true}),
|
||
);
|
||
let pkg = m.ok(
|
||
"books.export_cpa_package",
|
||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||
);
|
||
let bytes = base64::engine::general_purpose::STANDARD
|
||
.decode(pkg["content_base64"].as_str().unwrap())
|
||
.unwrap();
|
||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||
let names: Vec<String> = (0..zip.len())
|
||
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
|
||
.collect();
|
||
let mut index = String::new();
|
||
std::io::Read::read_to_string(&mut zip.by_name("receipts/index.csv").unwrap(), &mut index)
|
||
.unwrap();
|
||
let rows: Vec<&str> = index.lines().skip(1).collect();
|
||
let for_e: Vec<&&str> = rows
|
||
.iter()
|
||
.filter(|r| r.starts_with(&format!("{e},")))
|
||
.collect();
|
||
assert_eq!(for_e.len(), 2, "the linked receipt and contract: {index}");
|
||
for r in for_e {
|
||
let file = r.split(',').nth(4).unwrap();
|
||
assert!(names.iter().any(|n| n == file), "{file} in {names:?}");
|
||
}
|
||
assert!(
|
||
!names.iter().any(|n| n.starts_with("documents/contract")),
|
||
"the contract is behind an entry, filed with it: {names:?}"
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------- import sources
|
||
|
||
#[test]
|
||
fn a_bank_import_links_its_source_to_the_account_not_the_drafts() {
|
||
let mut m = books("import-source");
|
||
let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n2026-03-06,Refund,10.00\n";
|
||
let src = m.doc("import_source", csv.as_bytes(), json!([]));
|
||
let gone = m.doc("import_source", b"old", json!([]));
|
||
m.ok(
|
||
"books.withdraw_attachment",
|
||
json!({"attachment_id": gone, "reason": "superseded"}),
|
||
);
|
||
let args = |source: u64| {
|
||
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999",
|
||
"source_attachment_id": source})
|
||
};
|
||
m.refuse("books.import_bank_csv", args(999));
|
||
m.refuse("books.import_bank_csv", args(gone));
|
||
let out = m.ok("books.import_bank_csv", args(src));
|
||
assert_eq!(out["count"], 2);
|
||
assert_eq!(out["source_attachment_id"], json!(src));
|
||
assert_eq!(out["source_linked"], true);
|
||
assert!(
|
||
m.files_on(json!({"kind": "account", "id": "1000"}))
|
||
.contains(&src)
|
||
);
|
||
for t in out["imported"].as_array().unwrap() {
|
||
let e = t["entry_id"].clone();
|
||
assert!(
|
||
m.files_on(json!({"kind": "entry", "id": e})).is_empty(),
|
||
"a bank export is not a receipt"
|
||
);
|
||
}
|
||
// Linked already: imported again, not linked twice.
|
||
let again = m.ok("books.import_bank_csv", args(src));
|
||
assert_eq!(again["source_linked"], false);
|
||
assert_eq!(m.count("attachment.linked"), 1);
|
||
// Without a source, as before.
|
||
let plain = m.ok(
|
||
"books.import_bank_csv",
|
||
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999"}),
|
||
);
|
||
assert!(plain["source_attachment_id"].is_null());
|
||
assert_eq!(plain["source_linked"], false);
|
||
}
|
||
|
||
// ------------------------------------------------- the viewer
|
||
|
||
#[test]
|
||
fn show_opens_a_reconciliation() {
|
||
let mut m = books("show");
|
||
let (dep, pay) = january(&mut m);
|
||
m.clear(dep, 0);
|
||
m.clear(pay, 1);
|
||
let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00");
|
||
let rid = m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "confirmed": true}),
|
||
)["reconciliation"]["reconciliation_id"]
|
||
.as_u64()
|
||
.unwrap();
|
||
let out = m.read_only("books.show", json!({"reconciliation": rid}));
|
||
assert!(out["shown"].as_str().unwrap().contains("1000"), "{out}");
|
||
let run = m.data.parent().unwrap().join("run/wanderbooks");
|
||
let cmd: Value = std::fs::read_dir(&run)
|
||
.unwrap()
|
||
.filter_map(|e| std::fs::read(e.unwrap().path()).ok())
|
||
.find_map(|b| serde_json::from_slice(&b).ok())
|
||
.expect("a viewer request");
|
||
assert_eq!(cmd["target"], json!({"show": "reconciliation", "id": rid}));
|
||
m.refuse("books.show", json!({"reconciliation": 999}));
|
||
m.refuse(
|
||
"books.show",
|
||
json!({"reconciliation": rid, "entry_id": dep}),
|
||
);
|
||
}
|
||
|
||
// ------------------------------------------------- books.digest
|
||
|
||
/// The end of last month, from today.
|
||
fn last_month_end() -> chrono::NaiveDate {
|
||
use chrono::Datelike;
|
||
let today = chrono::Local::now().date_naive();
|
||
today.with_day(1).unwrap().pred_opt().unwrap()
|
||
}
|
||
|
||
fn unreconciled(m: &mut Mcp) -> Vec<String> {
|
||
m.read_only("books.digest", json!({"since": "2020-01-01"}))["unreconciled_accounts"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|a| a["code"].as_str().unwrap().to_string())
|
||
.collect()
|
||
}
|
||
|
||
/// Cash accounts (`[books] cash_accounts`), card accounts (liabilities
|
||
/// named "card", before their first statement, so the first
|
||
/// reconciliation is asked for), and any other liability once it has a
|
||
/// statement are listed until reconciled through the end of last month;
|
||
/// accounts without activity by then aren't (ledger_store::reconcilable,
|
||
/// the viewer's rule too).
|
||
#[test]
|
||
fn the_digest_lists_accounts_not_reconciled_through_last_month() {
|
||
let mut m = books("digest");
|
||
assert!(unreconciled(&mut m).is_empty(), "no activity, nothing due");
|
||
let due = last_month_end();
|
||
let day = due.to_string();
|
||
let start = due.with_day0(0).unwrap().to_string();
|
||
let dep = m.post(
|
||
&day,
|
||
json!({}),
|
||
&[("1000", "debit", "100.00"), ("3000", "credit", "100.00")],
|
||
);
|
||
let card = m.post(
|
||
&day,
|
||
json!({}),
|
||
&[("6999", "debit", "40.00"), ("2050", "credit", "40.00")],
|
||
);
|
||
let owed = m.post(
|
||
&day,
|
||
json!({}),
|
||
&[("6999", "debit", "15.00"), ("2100", "credit", "15.00")],
|
||
);
|
||
assert_eq!(
|
||
unreconciled(&mut m),
|
||
["1000", "2050"],
|
||
"the card is due before its first statement; 2100 isn't a card"
|
||
);
|
||
let d = m.read_only("books.digest", json!({"since": "2020-01-01"}));
|
||
let row = &d["unreconciled_accounts"][0];
|
||
assert_eq!(row["due_through"], json!(day));
|
||
assert!(row["reconciled_through"].is_null());
|
||
assert!(
|
||
row["next_step"]
|
||
.as_str()
|
||
.unwrap()
|
||
.contains("books.record_statement")
|
||
);
|
||
let card_st = m.statement("2050", &start, &day, "40.00");
|
||
m.clear(card, 1);
|
||
m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": card_st, "confirmed": true}),
|
||
);
|
||
assert_eq!(unreconciled(&mut m), ["1000"]);
|
||
// A liability not named for a card joins with its first statement.
|
||
let owed_st = m.statement("2100", &start, &day, "15.00");
|
||
assert_eq!(unreconciled(&mut m), ["1000", "2100"]);
|
||
m.clear(owed, 1);
|
||
m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": owed_st, "confirmed": true}),
|
||
);
|
||
m.clear(dep, 0);
|
||
let st = m.statement("1000", &start, &day, "100.00");
|
||
m.ok(
|
||
"books.reconcile",
|
||
json!({"statement_attachment": st, "confirmed": true}),
|
||
);
|
||
assert!(unreconciled(&mut m).is_empty());
|
||
}
|
||
|
||
use chrono::Datelike as _;
|