Files
wanderbooks/crates/ledger-mcp/tests/spec_assets.rs
T
swaitsandClaude Opus 5.5 0495726001
check / check (push) Canceled after 0s
P3 review: the surface, docs and proofs
Split kernel::entry_flows into entry_movements and flows_of so the
reversal-mirror proof fits CI; prove runs the fast harnesses, prove-slow
the 8 slow ones (own job in scheduled CI), prove-all both. Laws in
verification.md renumbered 1-31 with a table of all 55 harnesses.

MCP docs checked against the 115 tools (about 60 fixes) with a test that
every tool is documented; rules.md cross-references fixed; bookspec
statuses and loader tightened; viewer notes wrap and accounts sort by
code; STATUS.md says P3 is built, cutover pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 02:13:08 -06:00

1821 lines
68 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Spec-blind tests for the fixed-asset tag 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 —
//! fixed assets"), and `docs/spec/rules.md` B-24. The harness is the
//! minimal subset of `tests/spec_mcp.rs`: every success validates against
//! the tool's `outputSchema`, and every refusal leaves `journal.jsonl`
//! byte-identical (server invariant 2: a refused call changes nothing).
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-assets-{}-{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-assets", "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}),
);
}
}
/// Post an entry (high receipt threshold: none needed).
fn post(&mut self, date: &str, lines: &[(&str, &str, &str)]) {
let id = self.ok(
"books.draft_entry",
json!({"date": date, "description": "test entry"}),
)["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}),
);
}
self.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
/// Register a $1,200, 12-month laptop from 2026-01-01 with `extra` args
/// (tag, serial, description).
fn register(&mut self, extra: Value) -> Out {
let mut args = json!({"description": "Laptop", "in_service_date": "2026-01-01",
"cost": "1200.00", "salvage_value": "0.00", "useful_life_months": 12,
"cost_account": "1500", "accum_depr_account": "1510", "expense_account": "6500"});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
self.tool("books.register_asset", args)
}
fn register_ok(&mut self, extra: Value) -> (u64, String) {
match self.register(extra.clone()) {
Out::Ok(v) => (
v["asset_id"].as_u64().unwrap(),
v["tag"].as_str().unwrap().to_string(),
),
other => panic!("register {extra}: {other:?}"),
}
}
fn assets(&mut self) -> Vec<Value> {
self.ok("books.list_assets", json!({}))["assets"]
.as_array()
.unwrap()
.clone()
}
fn asset(&mut self, id: u64) -> Value {
self.assets()
.into_iter()
.find(|a| a["asset_id"] == json!(id))
.unwrap_or_else(|| panic!("asset {id} not listed"))
}
fn file_bill_of_sale(&mut self) -> u64 {
self.ok(
"books.file_document",
json!({"category": "legal", "file_name": "bill-of-sale.txt", "mime_type": "text/plain",
"content_base64": b64(b"bill of sale"),
"sha256": ledger_store::journal::sha256_hex(b"bill of sale"),
"description": "Bill of sale"}),
)["attachment_id"]
.as_u64()
.unwrap()
}
}
const CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
("1500", "Equipment", "asset"),
("1510", "Accumulated Depreciation", "asset"),
("3000", "Capital", "equity"),
("6500", "Depreciation Expense", "expense"),
("7100", "Gain or Loss on Disposal", "revenue"),
];
/// Books with the chart, generated tags prefixed "FL".
fn books(name: &str) -> Mcp {
let dir = test_dir(name);
let toml = books_toml(&dir, "Spec Co", "asset_tag_prefix = \"FL\"\n");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.books_dir = Some(dir);
m
}
fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= chunk.len() {
out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
fn unb64(s: &str) -> Vec<u8> {
let val = |c: u8| -> u32 {
match c {
b'A'..=b'Z' => u32::from(c - b'A'),
b'a'..=b'z' => u32::from(c - b'a') + 26,
b'0'..=b'9' => u32::from(c - b'0') + 52,
b'+' => 62,
b'/' => 63,
_ => panic!("not base64: {c}"),
}
};
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
let mut out = Vec::new();
for chunk in clean.chunks(4) {
let pad = chunk.iter().filter(|&&b| b == b'=').count();
let mut n = 0u32;
for (i, &b) in chunk.iter().enumerate() {
n |= if b == b'=' { 0 } else { val(b) } << (18 - 6 * i);
}
let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
out.extend_from_slice(&bytes[..3 - pad]);
}
out
}
/// Decimal strings compared by value.
fn canon(s: &str) -> String {
let (neg, s) = match s.strip_prefix('-') {
Some(r) => (true, r),
None => (false, s),
};
let (int, frac) = s.split_once('.').unwrap_or((s, ""));
let int = int.trim_start_matches('0');
let frac = frac.trim_end_matches('0');
let int = if int.is_empty() { "0" } else { int };
let body = if frac.is_empty() {
int.to_string()
} else {
format!("{int}.{frac}")
};
if neg && body != "0" {
format!("-{body}")
} else {
body
}
}
/// Every value under `key` anywhere in `v`.
fn find_key<'a>(v: &'a Value, key: &str, out: &mut Vec<&'a Value>) {
match v {
Value::Object(m) => {
for (k, child) in m {
if k == key {
out.push(child);
}
find_key(child, key, out);
}
}
Value::Array(a) => a.iter().for_each(|c| find_key(c, key, out)),
_ => {}
}
}
fn the_value(v: &Value, key: &str) -> String {
let mut found = Vec::new();
find_key(v, key, &mut found);
assert_eq!(found.len(), 1, "{key} in {v}");
match found[0] {
Value::String(s) => canon(s),
other => panic!("{key} is not a decimal string: {other}"),
}
}
// ------------------------------------------------- books.register_asset
/// "Without `tag`, the next free `PREFIX-0001` ([books] asset_tag_prefix)";
/// result `{ asset_id, tag }` (the tag given or generated).
#[test]
fn register_generates_the_next_tag_from_the_prefix() {
let mut m = books("gen");
let (a, ta) = m.register_ok(json!({}));
assert_eq!(ta, "FL-0001");
let (_, tb) = m.register_ok(json!({"description": "Monitor"}));
assert_eq!(tb, "FL-0002");
let (c, tc) = m.register_ok(json!({"tag": "DESK-9", "serial": "SN 9"}));
assert_eq!(tc, "DESK-9");
let (_, td) = m.register_ok(json!({}));
assert_eq!(td, "FL-0003");
// list_assets carries tag and serial.
let la = m.asset(a);
assert_eq!(la["tag"], "FL-0001");
assert_eq!(la["serial"], Value::Null);
let lc = m.asset(c);
assert_eq!(lc["tag"], "DESK-9");
assert_eq!(lc["serial"], "SN 9");
}
/// "else the company name's initials without LLC/Inc".
#[test]
fn register_default_prefix_is_the_company_initials() {
let dir = test_dir("initials");
let toml = books_toml(&dir, "Fernhill Labs LLC", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "FL-0001");
}
/// "\"FA\" when none."
#[test]
fn register_default_prefix_falls_back_to_fa() {
let dir = test_dir("fa");
let toml = books_toml(&dir, "LLC", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "FA-0001");
}
/// A bad tag or serial registers nothing (the harness checks the journal
/// is unchanged on every refusal), and the next generated tag is unmoved.
#[test]
fn register_with_a_bad_tag_or_serial_registers_nothing() {
let mut m = books("bad-register");
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "FL-0001");
let n = m.assets().len();
for extra in [
json!({"tag": "has space"}),
json!({"tag": ""}),
json!({"tag": "Ü-1"}),
json!({"tag": "A".repeat(33)}),
json!({"tag": "---"}),
json!({"tag": "fl-0001"}), // taken, ignoring case
json!({"serial": " lead"}),
json!({"serial": "trail "}),
json!({"serial": ""}),
json!({"serial": "ctl\u{1}"}),
json!({"serial": "S".repeat(65)}),
] {
assert!(
m.register(extra.clone()).refused(),
"{extra} should be refused"
);
}
assert_eq!(m.assets().len(), n, "nothing registered");
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "FL-0002");
}
/// A generated tag is never one any asset held (B-24).
#[test]
fn register_never_generates_a_released_tag() {
let mut m = books("released");
let (a, _) = m.register_ok(json!({}));
m.ok(
"books.label_asset",
json!({"asset": a.to_string(), "tag": "LAPTOP-A"}),
);
let (_, t) = m.register_ok(json!({}));
assert_eq!(t, "FL-0002");
// And a released tag can't be given to a new asset explicitly.
assert!(m.register(json!({"tag": "FL-0001"})).refused());
}
// ------------------------------------------------- books.label_asset
/// "The asset is its id or current tag"; result `{ asset_id, tag, serial }`;
/// journaled as `asset.tagged`, `asset.serial_set`.
#[test]
fn label_asset_sets_tag_and_serial_by_id_or_tag() {
let mut m = books("label");
let (a, _) = m.register_ok(json!({}));
let before = m.journal_types().len();
let r = m.ok(
"books.label_asset",
json!({"asset": a.to_string(), "serial": "C02XG0FDJGH5"}),
);
assert_eq!(r["asset_id"], json!(a));
assert_eq!(r["tag"], "FL-0001", "omitted tag is kept");
assert_eq!(r["serial"], "C02XG0FDJGH5");
let r = m.ok(
"books.label_asset",
json!({"asset": "fl-0001", "tag": "LAPTOP-1"}),
);
assert_eq!(r["asset_id"], json!(a));
assert_eq!(r["tag"], "LAPTOP-1");
assert_eq!(r["serial"], "C02XG0FDJGH5", "omitted serial is kept");
let types = m.journal_types();
let new: Vec<&str> = types[before..].iter().map(String::as_str).collect();
assert!(new.contains(&"asset.serial_set"), "{new:?}");
assert!(new.contains(&"asset.tagged"), "{new:?}");
// clear_serial.
let r = m.ok(
"books.label_asset",
json!({"asset": "LAPTOP-1", "clear_serial": true}),
);
assert_eq!(r["serial"], Value::Null);
assert_eq!(r["tag"], "LAPTOP-1");
let l = m.asset(a);
assert_eq!(l["tag"], "LAPTOP-1");
assert_eq!(l["serial"], Value::Null);
// The old tag is no longer current: it names nothing.
m.refuse(
"books.label_asset",
json!({"asset": "FL-0001", "serial": "x"}),
);
}
/// Tags are unique ignoring case and never reused by another asset, even
/// after a retag; bad forms and unknown assets are refused.
#[test]
fn label_asset_refusals() {
let mut m = books("label-refuse");
let (a, _) = m.register_ok(json!({}));
let (b, _) = m.register_ok(json!({}));
m.ok(
"books.label_asset",
json!({"asset": a.to_string(), "tag": "LAPTOP-A"}),
);
// b may not take a's current tag (any case) or its released FL-0001.
for t in ["laptop-a", "LAPTOP-A", "FL-0001", "fl-0001"] {
m.refuse(
"books.label_asset",
json!({"asset": b.to_string(), "tag": t}),
);
}
for t in ["bad tag", "", "--", "É"] {
m.refuse(
"books.label_asset",
json!({"asset": b.to_string(), "tag": t}),
);
}
for s in ["", " x", "x ", "a\nb"] {
m.refuse(
"books.label_asset",
json!({"asset": b.to_string(), "serial": s}),
);
}
m.refuse("books.label_asset", json!({"asset": "999", "tag": "NEW-1"}));
m.refuse(
"books.label_asset",
json!({"asset": "NOPE-1", "tag": "NEW-1"}),
);
// a may take back its own old tag.
let r = m.ok(
"books.label_asset",
json!({"asset": "LAPTOP-A", "tag": "FL-0001"}),
);
assert_eq!(r["asset_id"], json!(a));
assert_eq!(m.asset(b)["tag"], "FL-0002");
}
/// resolve_asset_ref: "A tag wins: tags like \"1001\" are allowed".
#[test]
fn an_asset_ref_that_is_a_tag_means_the_tagged_asset() {
let mut m = books("tag-wins");
let (a, _) = m.register_ok(json!({}));
let (b, _) = m.register_ok(json!({"tag": a.to_string()}));
assert_ne!(a, b);
let r = m.ok(
"books.label_asset",
json!({"asset": a.to_string(), "serial": "which"}),
);
assert_eq!(r["asset_id"], json!(b), "the tag {a} wins over the id {a}");
assert_eq!(m.asset(b)["serial"], "which");
assert_eq!(m.asset(a)["serial"], Value::Null);
}
// ------------------------------------------------- tag in place of id
/// `books.asset_schedule { asset_id | tag }`: the same schedule either way.
#[test]
fn asset_schedule_takes_the_tag_in_place_of_the_id() {
let mut m = books("schedule");
let (a, t) = m.register_ok(json!({}));
let by_id = m.read_only("books.asset_schedule", json!({"asset_id": a}));
let by_tag = m.read_only("books.asset_schedule", json!({"tag": t}));
let by_lower = m.read_only(
"books.asset_schedule",
json!({"tag": t.to_ascii_lowercase()}),
);
assert_eq!(by_id, by_tag);
assert_eq!(by_id, by_lower);
assert_eq!(by_tag["asset_id"], json!(a));
// Exactly one of asset_id and tag.
m.refuse("books.asset_schedule", json!({}));
m.refuse("books.asset_schedule", json!({"asset_id": a, "tag": t}));
m.refuse("books.asset_schedule", json!({"tag": "NOPE-1"}));
}
/// `books.dispose_asset { asset_id | tag, ... }`: the same preview either
/// way; confirmed by tag disposes it; its tag stays reserved.
#[test]
fn dispose_asset_takes_the_tag_and_the_tag_stays_reserved() {
let mut m = books("dispose");
m.post(
"2026-01-01",
&[("1000", "debit", "5000.00"), ("3000", "credit", "5000.00")],
);
m.post(
"2026-01-01",
&[("1500", "debit", "1200.00"), ("1000", "credit", "1200.00")],
);
let (a, t) = m.register_ok(json!({"serial": "SN-A"}));
let (b, tb) = m.register_ok(json!({}));
let base = json!({"date": "2026-06-30", "proceeds": "500.00",
"proceeds_account": "1000", "gain_loss_account": "7100"});
let mut by_id = base.clone();
by_id["asset_id"] = json!(a);
let mut by_tag = base.clone();
by_tag["tag"] = json!(t.to_ascii_lowercase());
let p1 = m.ok("books.dispose_asset", by_id.clone());
let p2 = m.ok("books.dispose_asset", by_tag.clone());
assert_eq!(p1, p2, "same preview by id and by tag");
let mut both = by_id.clone();
both["tag"] = json!(t);
m.refuse("books.dispose_asset", both);
m.refuse("books.dispose_asset", base.clone());
let doc = m.file_bill_of_sale();
let mut confirm = by_tag;
confirm["confirmed"] = json!(true);
confirm["receipt_attachment_ids"] = json!([doc]);
m.ok("books.dispose_asset", confirm);
assert_eq!(m.asset(a)["status"], "disposed");
// B-24: a disposed asset's tag is never reused.
m.refuse(
"books.label_asset",
json!({"asset": b.to_string(), "tag": t}),
);
assert!(m.register(json!({"tag": t})).refused());
// Default labels: active tagged assets only.
let labels = m.read_only("books.asset_labels", json!({}));
assert_eq!(labels["tags"], json!([tb]));
}
// ------------------------------------------------- books.asset_labels
#[test]
fn asset_labels_prints_a_pdf_of_the_tags_in_order() {
let mut m = books("labels");
let (_, t1) = m.register_ok(json!({"serial": "SN-1"}));
let (_, t2) = m.register_ok(json!({}));
let (a3, t3) = m.register_ok(json!({"tag": "DESK/7"}));
let r = m.read_only("books.asset_labels", json!({"assets": [t3, t1]}));
assert_eq!(r["tags"], json!([t3, t1]), "the tags printed, in order");
let pdf = unb64(r["content_base64"].as_str().unwrap());
assert!(pdf.starts_with(b"%PDF"), "a PDF");
assert!(!r["file_name"].as_str().unwrap().is_empty());
// By id too.
let r = m.read_only("books.asset_labels", json!({"assets": [a3.to_string()]}));
assert_eq!(r["tags"], json!(["DESK/7"]));
// Default: every active asset with a tag, all three here.
let r = m.read_only("books.asset_labels", json!({}));
let mut tags: Vec<String> = r["tags"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
tags.sort();
let mut want = vec![t1.clone(), t2.clone(), t3.clone()];
want.sort();
assert_eq!(tags, want);
// skip: 0 to 29.
let r = m.read_only("books.asset_labels", json!({"assets": [t1], "skip": 29}));
assert!(unb64(r["content_base64"].as_str().unwrap()).starts_with(b"%PDF"));
m.refuse("books.asset_labels", json!({"assets": [t2], "skip": 30}));
// Unknown asset.
m.refuse("books.asset_labels", json!({"assets": ["NOPE-1"]}));
}
/// "asset {} has no tag: give it one with books.label_asset first": an
/// asset with no tag can't be printed. Assets registered before tags
/// existed have none; here one is made by the store directly.
#[test]
fn asset_labels_refuses_an_asset_without_a_tag() {
let dir = test_dir("untagged");
let toml = books_toml(&dir, "Spec Co", "asset_tag_prefix = \"FL\"\n");
// Books written by the store, outside the server: an untagged asset.
{
use ledger_core::{AccountType, Currency, Money, NewAsset};
let data = dir.join("books-data");
std::fs::create_dir_all(&data).unwrap();
let mut s = ledger_store::Store::create(&data, Currency::USD).unwrap();
let mut add = |c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
let cost = add("1500", "Equipment", AccountType::Asset);
let accum = add("1510", "Accumulated Depreciation", AccountType::Asset);
let exp = add("6500", "Depreciation Expense", AccountType::Expense);
let usd = |a: &str| Money::new(a.parse().unwrap(), Currency::USD);
s.register_asset(NewAsset {
description: "Old laptop".into(),
in_service_date: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
cost: usd("1200.00"),
salvage_value: usd("0.00"),
useful_life_months: 12,
cost_account: cost,
accum_depr_account: accum,
expense_account: exp,
})
.unwrap();
}
let mut m = Mcp::start(&dir, &toml);
let assets = m.assets();
assert_eq!(assets.len(), 1);
assert_eq!(assets[0]["tag"], Value::Null);
let id = assets[0]["asset_id"].as_u64().unwrap();
m.refuse("books.asset_labels", json!({"assets": [id.to_string()]}));
// The default (every active tagged asset) has nothing to print, or
// prints nothing; either way it never prints the untagged one.
if let Out::Ok(r) = m.tool("books.asset_labels", json!({})) {
assert_eq!(r["tags"], json!([]));
}
// Tag it and it prints.
m.ok(
"books.label_asset",
json!({"asset": id.to_string(), "tag": "OLD-1"}),
);
let r = m.read_only("books.asset_labels", json!({"assets": ["OLD-1"]}));
assert_eq!(r["tags"], json!(["OLD-1"]));
}
// ------------------------------------------------- books.show { asset }
#[test]
fn show_opens_an_asset_by_tag_or_id() {
let mut m = books("show");
let (a, t) = m.register_ok(json!({}));
m.read_only("books.show", json!({"asset": t}));
m.read_only("books.show", json!({"asset": a.to_string()}));
m.read_only("books.show", json!({"report": "fixed_asset_register"}));
m.read_only("books.show", json!({"page": "assets"}));
// Exactly one target.
m.refuse("books.show", json!({"asset": t, "page": "assets"}));
}
// ------------------------------------------------- export fixed_asset_register
/// `books.export { report: "fixed_asset_register", format: json | csv,
/// as_of? }`: tag, serial, ..., `unposted_depreciation` and
/// `unregistered_cost` (zero when register and books agree).
#[test]
fn export_fixed_asset_register_reconciles_to_the_books() {
let mut m = books("export");
m.post(
"2026-01-01",
&[("1000", "debit", "5000.00"), ("3000", "credit", "5000.00")],
);
m.post(
"2026-01-01",
&[("1500", "debit", "1200.00"), ("1000", "credit", "1200.00")],
);
// $1,200 over 12 months from Jan 1: $100 a month.
m.register_ok(json!({"tag": "LAPTOP-1", "serial": "C02XG0FDJGH5"}));
m.ok("books.run_depreciation", json!({"period": "2026-01"}));
m.ok("books.run_depreciation", json!({"period": "2026-02"}));
let export = |m: &mut Mcp, format: &str, as_of: &str| -> String {
let r = m.read_only(
"books.export",
json!({"report": "fixed_asset_register", "format": format, "as_of": as_of}),
);
assert_eq!(r["report"], "fixed_asset_register");
assert_eq!(r["format"], format);
r["content"].as_str().unwrap().to_string()
};
// Agreeing: depreciation posted through February.
let j: Value = serde_json::from_str(&export(&mut m, "json", "2026-02-28")).unwrap();
assert_eq!(the_value(&j, "unposted_depreciation"), "0");
assert_eq!(the_value(&j, "unregistered_cost"), "0");
let text = j.to_string();
assert!(
text.contains("LAPTOP-1") && text.contains("C02XG0FDJGH5"),
"{text}"
);
// March due by schedule, not posted.
let j: Value = serde_json::from_str(&export(&mut m, "json", "2026-03-31")).unwrap();
assert_eq!(the_value(&j, "unposted_depreciation"), "100");
assert_eq!(the_value(&j, "unregistered_cost"), "0");
// Equipment in the books that no asset accounts for.
m.post(
"2026-03-15",
&[("1500", "debit", "250.00"), ("1000", "credit", "250.00")],
);
let j: Value = serde_json::from_str(&export(&mut m, "json", "2026-03-31")).unwrap();
assert_eq!(the_value(&j, "unregistered_cost"), "250");
let csv = export(&mut m, "csv", "2026-02-28");
assert!(csv.contains("LAPTOP-1"), "{csv}");
assert!(csv.contains("C02XG0FDJGH5"), "{csv}");
let header = csv.lines().next().unwrap().to_ascii_lowercase();
assert!(header.contains("tag"), "{header}");
assert!(header.contains("serial"), "{header}");
}
/// The register lists assets registered by the date only.
#[test]
fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
let mut m = books("export-later");
m.register_ok(json!({"tag": "EARLY-1"}));
m.register_ok(json!({"tag": "LATE-1", "in_service_date": "2026-06-01"}));
let r = m.read_only(
"books.export",
json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}),
);
let text = r["content"].as_str().unwrap();
assert!(text.contains("EARLY-1"));
assert!(!text.contains("LATE-1"), "{text}");
}
// ------------------------------------------------- tax basis (rules.md B-25)
//
// From the `books.set_asset_tax_basis` and `books.register_asset`
// descriptions, `SetAssetTaxBasisParams` / `AssetBasisOut` /
// `RegisterAssetParams` docs, and the fixed_asset_register export.
// Purchase: the book cost (IRC §1012); carryover_723: the member's
// adjusted basis (IRC §723); personal_use_conversion: §723 basis as for
// carryover, depreciable basis the lesser of fair market value and that
// basis (Treas. Reg. §1.168(i)-4(b)); built_in_gain_704c = value less the
// §723 basis at contribution (Treas. Reg. §1.704-3(a)(3)(ii)).
/// A decimal-string field, compared by value; `None` for null.
fn dec(v: &Value, key: &str) -> Option<String> {
match &v[key] {
Value::Null => None,
Value::String(s) => Some(canon(s)),
other => panic!("{key} is not a decimal string or null: {other}"),
}
}
fn basis_lines(m: &Mcp) -> usize {
m.journal_types()
.iter()
.filter(|t| *t == "asset.basis_set")
.count()
}
/// books.set_asset_tax_basis by tag and by id; the output names each
/// figure for what it is, with its authority; each call journals an
/// asset.basis_set; the old rule names are aliases, noted.
#[test]
fn set_asset_tax_basis_by_tag_or_id_reports_each_figure() {
let mut m = books("tax-set");
let (a, t) = m.register_ok(json!({}));
assert_eq!(m.asset(a)["basis"], Value::Null, "not recorded yet");
// By tag (any case): the member's $900 basis in a $1,200 laptop from
// business use.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t.to_ascii_lowercase(), "rule": "carryover_723",
"contributor_adjusted_basis": "900.00", "fmv_at_contribution": "1200.00",
"sec704c_method": "traditional",
"basis_source": {"decided_by": "Morgan Ellis", "decided_on": "2026-10-20",
"citation": "entry 1"}}),
);
assert_eq!(r["asset_id"], json!(a));
assert!(r.get("notes").is_none(), "{r}");
let b = &r["basis"];
assert_eq!(b["rule"], "carryover_723");
assert_eq!(dec(b, "contributor_adjusted_basis").as_deref(), Some("900"));
assert_eq!(dec(b, "fmv_at_contribution").as_deref(), Some("1200"));
assert_eq!(b["fmv_recorded"], true);
assert_eq!(dec(b, "tax_basis_723").as_deref(), Some("900"));
assert_eq!(dec(b, "depreciable_basis").as_deref(), Some("900"));
assert_eq!(dec(b, "built_in_gain_704c").as_deref(), Some("300"));
assert!(b.get("sec704c_1c_note").is_none(), "a gain has no note");
assert_eq!(b["sec704c_method"], "traditional");
assert_eq!(b["source"]["decided_by"], "Morgan Ellis");
assert_eq!(b["sources"]["tax_basis_723"], "IRC §723");
assert_eq!(b["sources"]["depreciable_basis"], "IRC §723");
assert_eq!(
b["sources"]["built_in_gain_704c"],
"Treas. Reg. §1.704-3(a)(3)(ii)"
);
assert_eq!(basis_lines(&m), 1);
assert_eq!(
m.journal_types()
.iter()
.filter(|t| *t == "journal.format_set")
.count(),
1,
"the feature declared once"
);
// By id: personal use, the member's $2,000 laptop worth $1,200.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": a.to_string(), "rule": "personal_use_conversion",
"contributor_adjusted_basis": "2000.00", "fmv_at_contribution": "1200.00"}),
);
let b = &r["basis"];
assert_eq!(dec(b, "tax_basis_723").as_deref(), Some("2000"));
assert_eq!(
dec(b, "depreciable_basis").as_deref(),
Some("1200"),
"the lesser"
);
assert_eq!(dec(b, "built_in_gain_704c").as_deref(), Some("-800"));
assert!(
b["sec704c_1c_note"]
.as_str()
.unwrap()
.contains("IRC §704(c)(1)(C)"),
"{b}"
);
assert_eq!(
b["sources"]["depreciable_basis"],
"Treas. Reg. §1.168(i)-4(b)"
);
assert_eq!(b["sec704c_method"], Value::Null);
// A purchase: the book cost, nothing contributed.
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "purchase"}),
);
let b = &r["basis"];
assert_eq!(b["rule"], "purchase");
assert_eq!(b["contributor_adjusted_basis"], Value::Null);
assert_eq!(b["tax_basis_723"], Value::Null);
assert_eq!(dec(b, "depreciable_basis").as_deref(), Some("1200"));
assert_eq!(b["built_in_gain_704c"], Value::Null);
assert_eq!(b["sources"]["depreciable_basis"], "IRC §1012");
assert_eq!(basis_lines(&m), 3);
// list_assets shows the latest record.
let basis = m.asset(a)["basis"].clone();
assert_eq!(basis["rule"], "purchase");
}
/// The old names (rule cost, carryover; contributor_basis) are aliases for
/// one release, noted in the result; a name and its alias that disagree
/// are refused.
#[test]
fn basis_rule_legacy_names_are_aliases() {
let mut m = books("tax-alias");
let (_, t) = m.register_ok(json!({}));
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover", "contributor_basis": "900.00",
"fmv_at_contribution": "1200.00"}),
);
assert_eq!(r["basis"]["rule"], "carryover_723");
let notes = r["notes"].to_string();
assert!(
notes.contains("contributor_basis is now contributor_adjusted_basis"),
"{notes}"
);
assert!(
notes.contains("\\\"carryover\\\" is now \\\"carryover_723\\\""),
"{notes}"
);
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "cost"}),
);
assert_eq!(r["basis"]["rule"], "purchase");
let why = m.refuse(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "1.00",
"contributor_basis": "2.00", "fmv_at_contribution": "3.00"}),
);
assert!(why.contains("disagree"), "{why}");
let (_, t2) = m.register_ok(json!({"tax_basis_rule": "carryover",
"contributor_basis": "400.00", "fmv_at_contribution": "1200.00"}));
assert_eq!(m.assets().len(), 2);
let _ = t2;
}
/// A name and its alias that mean the same, however written, agree (P3
/// review, 2026-10-21: they were compared as text, so "100" and "100.00",
/// or carryover and carryover_723, were refused as disagreeing).
#[test]
fn a_name_and_its_alias_that_mean_the_same_agree() {
let mut m = books("alias-same");
let (_, t) = m.register_ok(json!({}));
let r = m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "900",
"contributor_basis": "900.00", "fmv_at_contribution": "1200.00"}),
);
assert_eq!(r["basis"]["contributor_adjusted_basis"], "900.00", "{r}");
m.register_ok(
json!({"basis_rule": "carryover_723", "tax_basis_rule": "carryover",
"contributor_adjusted_basis": "400.00", "contributor_basis": "400",
"fmv_at_contribution": "1200.00"}),
);
m.register_ok(json!({"basis_rule": "purchase", "tax_basis_rule": "cost"}));
assert_eq!(m.assets().len(), 3);
let why = m.refuse(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "900.00",
"contributor_basis": "900.01", "fmv_at_contribution": "1200.00"}),
);
assert!(why.contains("disagree"), "{why}");
}
/// A contribution's fair market value is a fact the human gives, never
/// assumed from the cost (review R11): without it the call is refused.
#[test]
fn fmv_is_required_for_a_contribution() {
let mut m = books("tax-fmv");
let (_, t) = m.register_ok(json!({}));
for rule in ["carryover_723", "personal_use_conversion"] {
let why = m.refuse(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": rule, "contributor_adjusted_basis": "900.00"}),
);
assert!(why.contains("fmv_at_contribution"), "{why}");
let out = m.register(json!({"basis_rule": rule, "contributor_adjusted_basis": "1.00"}));
assert!(out.refused(), "{out:?}");
}
assert_eq!(m.assets().len(), 1, "nothing registered");
assert_eq!(basis_lines(&m), 0);
}
/// Refusals: unknown asset, bad rule or method, a purchase with
/// contribution facts, a contribution without its basis, negative, finer
/// than cents, not a decimal string. Each leaves the journal byte-identical
/// (the harness checks) and the record.
#[test]
fn set_asset_tax_basis_refusals_change_nothing() {
let mut m = books("tax-refuse");
let (a, t) = m.register_ok(json!({}));
m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "500.00",
"fmv_at_contribution": "1200.00"}),
);
let before = m.asset(a)["basis"].clone();
let c = |extra: Value| {
let mut v = json!({"asset": t, "rule": "carryover_723",
"contributor_adjusted_basis": "500.00", "fmv_at_contribution": "1200.00"});
for (k, x) in extra.as_object().unwrap() {
v[k] = x.clone();
}
v
};
for args in [
json!({"asset": "NOPE-1", "rule": "purchase"}),
json!({"asset": "999", "rule": "purchase"}),
json!({"asset": t, "rule": "fmv"}),
json!({"asset": t, "rule": "Purchase"}),
json!({"asset": t, "rule": ""}),
json!({"asset": t, "rule": "purchase", "contributor_adjusted_basis": "100.00"}),
json!({"asset": t, "rule": "purchase", "fmv_at_contribution": "100.00"}),
json!({"asset": t, "rule": "purchase", "sec704c_method": "traditional"}),
json!({"asset": t, "rule": "carryover_723", "fmv_at_contribution": "1.00"}),
c(json!({"contributor_adjusted_basis": "-1.00"})),
c(json!({"contributor_adjusted_basis": "1.001"})),
c(json!({"fmv_at_contribution": "lots"})),
c(json!({"fmv_at_contribution": "1,000.00"})),
c(json!({"sec704c_method": "ceiling"})),
c(json!({"basis_source": {"decided_by": " "}})),
] {
m.refuse("books.set_asset_tax_basis", args);
}
assert_eq!(m.asset(a)["basis"], before);
assert_eq!(basis_lines(&m), 1);
}
/// register_asset with basis_rule and its facts records it at
/// registration; without, list_assets shows basis null.
#[test]
fn register_asset_records_a_basis() {
let mut m = books("tax-register");
let (a, _) = m.register_ok(json!({"basis_rule": "purchase"}));
let (b, _) = m.register_ok(json!({"basis_rule": "personal_use_conversion",
"contributor_adjusted_basis": "1500.00", "fmv_at_contribution": "1200.00"}));
let (c, _) = m.register_ok(json!({"basis_rule": "carryover_723",
"contributor_adjusted_basis": "400.00", "fmv_at_contribution": "1200.00"}));
let (n, _) = m.register_ok(json!({}));
let ta = m.asset(a)["basis"].clone();
assert_eq!(ta["rule"], "purchase");
assert_eq!(dec(&ta, "depreciable_basis").as_deref(), Some("1200"));
let tb = m.asset(b)["basis"].clone();
assert_eq!(dec(&tb, "tax_basis_723").as_deref(), Some("1500"));
assert_eq!(dec(&tb, "depreciable_basis").as_deref(), Some("1200"));
assert_eq!(dec(&tb, "built_in_gain_704c").as_deref(), Some("-300"));
let tc = m.asset(c)["basis"].clone();
assert_eq!(dec(&tc, "depreciable_basis").as_deref(), Some("400"));
assert_eq!(dec(&tc, "built_in_gain_704c").as_deref(), Some("800"));
assert_eq!(m.asset(n)["basis"], Value::Null);
}
/// A basis fact without basis_rule, a bad rule, or a record the engine
/// refuses, given at registration, is a refused call: it registers
/// nothing (server invariant 2).
#[test]
fn register_asset_refuses_a_bad_basis_and_registers_nothing() {
let mut m = books("tax-register-bad");
m.register_ok(json!({}));
for extra in [
json!({"contributor_adjusted_basis": "500.00"}),
json!({"fmv_at_contribution": "500.00"}),
json!({"basis_rule": "market_value"}),
json!({"basis_rule": "carryover_723", "contributor_adjusted_basis": "five",
"fmv_at_contribution": "1.00"}),
json!({"basis_rule": "carryover_723", "fmv_at_contribution": "1.00"}),
json!({"basis_rule": "purchase", "contributor_adjusted_basis": "100.00"}),
json!({"basis_rule": "carryover_723", "contributor_adjusted_basis": "-1.00",
"fmv_at_contribution": "1.00"}),
json!({"basis_rule": "purchase", "tax_basis_rule": "carryover"}),
] {
let out = m.register(extra.clone());
assert!(out.refused(), "{extra}: {out:?}");
}
assert_eq!(m.assets().len(), 1, "nothing registered");
}
// ------------------------------------------- the dates (rules.md B-15)
/// purchase_date is an alias for in_service_date for one release, noted.
#[test]
fn purchase_date_is_an_alias_for_in_service_date() {
let mut m = books("alias-date");
let r = match m.register(json!({"in_service_date": null, "purchase_date": "2026-02-01"})) {
Out::Ok(v) => v,
other => panic!("{other:?}"),
};
assert_eq!(r["in_service_date"], "2026-02-01");
assert_eq!(r["acquired_date"], "2026-02-01");
assert!(
r["notes"]
.to_string()
.contains("purchase_date is now in_service_date"),
"{r}"
);
let a = m.asset(r["asset_id"].as_u64().unwrap());
assert_eq!(a["in_service_date"], "2026-02-01");
assert!(a.get("purchase_date").is_none(), "{a}");
}
/// Both given with different dates: refused, nothing registered; neither:
/// refused.
#[test]
fn purchase_date_and_a_different_in_service_date_are_refused() {
let mut m = books("alias-differ");
let out = m.register(json!({"in_service_date": "2026-01-01", "purchase_date": "2026-02-01"}));
assert!(out.refused(), "{out:?}");
let out = m.register(json!({"in_service_date": null}));
assert!(out.refused(), "{out:?}");
assert!(m.assets().is_empty());
m.register_ok(json!({"in_service_date": "2026-01-01", "purchase_date": "2026-01-01"}));
}
/// The acquired date is the acquisition entry's (an asset bought before it
/// is in service); an entry dated after the in-service date is refused,
/// registering nothing.
#[test]
fn acquired_date_comes_from_the_acquisition_entry() {
let mut m = books("acquired");
m.post(
"2026-01-10",
&[("1500", "debit", "1200.00"), ("1000", "credit", "1200.00")],
);
let entry = 1;
let r = match m.register(json!({"in_service_date": "2026-02-01", "acquisition_entry": entry})) {
Out::Ok(v) => v,
other => panic!("{other:?}"),
};
assert_eq!(r["acquired_date"], "2026-01-10");
assert_eq!(r["in_service_date"], "2026-02-01");
let a = m.asset(r["asset_id"].as_u64().unwrap());
assert_eq!(a["acquired_date"], "2026-01-10");
let why = match m.register(json!({"in_service_date": "2026-01-05", "acquisition_entry": entry}))
{
Out::Ok(v) => panic!("registered {v}"),
other => other.err_text(),
};
assert!(why.contains("after the in-service date"), "{why}");
assert_eq!(m.assets().len(), 1);
// correct_asset: never before the acquisition entry; in_service the
// old name of in_service_date.
let id = r["asset_id"].as_u64().unwrap();
let why = m.refuse(
"books.correct_asset",
json!({"asset": id.to_string(), "in_service_date": "2026-01-09", "reason": "typo"}),
);
assert!(why.contains("before it was acquired"), "{why}");
let c = m.ok(
"books.correct_asset",
json!({"asset": id.to_string(), "in_service": "2026-01-15", "reason": "typo"}),
);
assert_eq!(c["in_service_date"], "2026-01-15");
assert_eq!(c["acquired_date"], "2026-01-10");
assert!(
c["notes"]
.to_string()
.contains("in_service is now in_service_date")
);
}
// ------------------------------- posted and scheduled (rules.md B-15 rule 8)
/// Named regression 1 (the brief's reproduction 1, its figures made up): a
/// $1,440.00 laptop, 36 months, in service 2026-05-15, nothing run. As of
/// 2026-05-20 the schedule has 1,440/36 × 6/31 = 7.74 due, the books carry
/// nothing: posted 0.00, scheduled 7.74, unposted 7.74, book value
/// 1,440.00 (the tool called 1,432.26 book value before P3). May's run is
/// dated 05-31: as of 05-20 still 0.00 posted; as of 05-31 posted =
/// scheduled = 1,440/36 × 17/31 = 21.94.
#[test]
fn list_assets_book_value_is_what_the_books_carry() {
let mut m = books("book-value");
let (id, _) = m.register_ok(json!({"in_service_date": "2026-05-15", "cost": "1440.00",
"useful_life_months": 36}));
let at = |m: &mut Mcp, as_of: &str| -> Value {
let r = m.read_only("books.list_assets", json!({"as_of": as_of}));
assert_eq!(r["as_of"], as_of);
r["assets"]
.as_array()
.unwrap()
.iter()
.find(|a| a["asset_id"] == json!(id))
.unwrap()
.clone()
};
let a = at(&mut m, "2026-05-20");
assert_eq!(a["held"], true);
assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("0"));
assert_eq!(
dec(&a, "accumulated_scheduled_through").as_deref(),
Some("7.74")
);
assert_eq!(dec(&a, "unposted_depreciation").as_deref(), Some("7.74"));
assert_eq!(dec(&a, "book_value").as_deref(), Some("1440"));
assert!(a.get("accumulated").is_none(), "{a}");
m.ok(
"books.run_depreciation",
json!({"period": "2026-05", "asset_id": id}),
);
let a = at(&mut m, "2026-05-20");
assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("0"));
let a = at(&mut m, "2026-05-31");
assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("21.94"));
assert_eq!(
dec(&a, "accumulated_scheduled_through").as_deref(),
Some("21.94")
);
assert_eq!(dec(&a, "unposted_depreciation").as_deref(), Some("0"));
assert_eq!(dec(&a, "book_value").as_deref(), Some("1418.06"));
// Before it was acquired: not held, no figures.
let a = at(&mut m, "2026-05-14");
assert_eq!(a["held"], false);
assert_eq!(a["book_value"], Value::Null);
assert_eq!(a["accumulated_posted"], Value::Null);
}
/// A run reversed is no longer posted (named regression 2, its posted
/// figure): the books' accumulated depreciation and book value go back,
/// the scheduled figure stays, and the difference is due again.
#[test]
fn a_reversed_run_is_not_posted() {
let mut m = books("reversed-run");
let (id, _) = m.register_ok(json!({}));
let run = m.ok(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": id}),
);
let entry = run["runs"][0]["entry_id"].as_u64().unwrap();
let at = |m: &mut Mcp| -> Value {
m.read_only("books.list_assets", json!({"as_of": "2026-01-31"}))["assets"][0].clone()
};
assert_eq!(
dec(&at(&mut m), "accumulated_posted").as_deref(),
Some("100")
);
m.ok(
"books.reverse_entry",
json!({"entry_id": entry, "date": "2026-01-31", "description": "undo", "confirmed": true}),
);
let a = at(&mut m);
assert_eq!(dec(&a, "accumulated_posted").as_deref(), Some("0"));
assert_eq!(
dec(&a, "accumulated_scheduled_through").as_deref(),
Some("100")
);
assert_eq!(dec(&a, "unposted_depreciation").as_deref(), Some("100"));
assert_eq!(dec(&a, "book_value").as_deref(), Some("1200"));
}
/// A disposed asset is not held: every figure 0.00 once it left the books.
#[test]
fn a_disposed_asset_has_left_the_books() {
let mut m = books("disposed-list");
m.post(
"2026-01-01",
&[("1000", "debit", "9000.00"), ("3000", "credit", "9000.00")],
);
let (id, sold) = m.register_ok(json!({}));
let doc = m.file_bill_of_sale();
m.ok(
"books.dispose_asset",
json!({"tag": sold, "date": "2026-03-31", "proceeds": "100.00",
"proceeds_account": "1000", "gain_loss_account": "7100",
"confirmed": true, "receipt_attachment_ids": [doc]}),
);
let a = m.asset(id);
assert_eq!(a["status"], "disposed");
assert_eq!(a["held"], false);
for k in [
"accumulated_posted",
"accumulated_scheduled_through",
"unposted_depreciation",
"book_value",
] {
assert_eq!(dec(&a, k).as_deref(), Some("0"), "{k}");
}
}
/// The fixed_asset_register export carries, per asset, its acquired and
/// in-service dates, accumulated depreciation scheduled and posted, book
/// value (cost less posted), and the basis facts and figures; JSON the
/// totals and the three differences. A disposed asset is out of the
/// totals. By hand, as of 2026-06-30: held with a basis, depreciable
/// BOUGHT-1 1,200.00, CARRY-1 650.25 and CONV-1 1,200.00, together
/// 3,050.25; §723 650.25 and 2,000.00, together 2,650.25; BOUGHT-1 ran
/// January: posted 100.00 of 600.00 scheduled.
#[test]
fn export_fixed_asset_register_carries_posted_scheduled_and_704c() {
let mut m = books("tax-export");
m.post(
"2026-01-01",
&[("1000", "debit", "9000.00"), ("3000", "credit", "9000.00")],
);
let (bought, _) = m.register_ok(json!({"tag": "BOUGHT-1", "basis_rule": "purchase"}));
m.register_ok(json!({"tag": "CARRY-1", "basis_rule": "carryover_723",
"contributor_adjusted_basis": "650.25", "fmv_at_contribution": "1200.00",
"sec704c_method": "traditional"}));
m.register_ok(
json!({"tag": "CONV-1", "basis_rule": "personal_use_conversion",
"contributor_adjusted_basis": "2000.00", "fmv_at_contribution": "1200.00",
"basis_source": {"decided_by": "Tobin Harlow", "decided_on": "2026-10-20",
"citation": "entry 9"}}),
);
m.register_ok(json!({"tag": "NONE-1"}));
let (_, sold) = m.register_ok(json!({"tag": "SOLD-1", "basis_rule": "purchase"}));
m.ok(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": bought}),
);
let doc = m.file_bill_of_sale();
m.ok(
"books.dispose_asset",
json!({"tag": sold, "date": "2026-03-31", "proceeds": "100.00",
"proceeds_account": "1000", "gain_loss_account": "7100",
"confirmed": true, "receipt_attachment_ids": [doc]}),
);
let export = |m: &mut Mcp, format: &str| -> String {
m.read_only(
"books.export",
json!({"report": "fixed_asset_register", "format": format, "as_of": "2026-06-30"}),
)["content"]
.as_str()
.unwrap()
.to_string()
};
let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap();
let rows = j["assets"].as_array().unwrap();
assert_eq!(rows.len(), 5, "{j}");
let row = |tag: &str| rows.iter().find(|r| r["tag"] == tag).unwrap().clone();
let r = row("BOUGHT-1");
assert_eq!(r["basis_rule"], "purchase");
assert_eq!(dec(&r, "accumulated_scheduled").as_deref(), Some("600"));
assert_eq!(dec(&r, "accumulated_posted").as_deref(), Some("100"));
assert_eq!(dec(&r, "book_value").as_deref(), Some("1100"));
assert_eq!(dec(&r, "depreciable_basis").as_deref(), Some("1200"));
assert_eq!(r["acquired_date"], "2026-01-01");
assert_eq!(r["in_service_date"], "2026-01-01");
let r = row("CARRY-1");
assert_eq!(dec(&r, "tax_basis_723").as_deref(), Some("650.25"));
assert_eq!(dec(&r, "built_in_gain_704c").as_deref(), Some("549.75"));
assert_eq!(r["sec704c_method"], "traditional");
assert_eq!(r["fmv_recorded"], true);
let r = row("CONV-1");
assert_eq!(dec(&r, "depreciable_basis").as_deref(), Some("1200"));
assert_eq!(dec(&r, "built_in_gain_704c").as_deref(), Some("-800"));
assert_eq!(r["sec704c_1c"], true);
assert_eq!(r["basis_source"], "Tobin Harlow, 2026-10-20: entry 9");
assert_eq!(row("NONE-1")["basis_rule"], Value::Null);
assert_eq!(the_value(&j, "total_depreciable_basis"), "3050.25");
assert_eq!(the_value(&j, "total_tax_basis_723"), "2650.25");
assert_eq!(the_value(&j, "total_accumulated_posted"), "100");
assert_eq!(the_value(&j, "unattributed_accumulated"), "0");
assert_eq!(j["missing_basis"], json!(1));
assert!(
j["tax_figures_are"]
.as_str()
.unwrap()
.contains("at contribution")
);
// CSV: by header.
let csv = export(&mut m, "csv");
let mut lines = csv.lines();
let header: Vec<String> = lines
.next()
.unwrap()
.split(',')
.map(str::to_string)
.collect();
assert_eq!(
header,
[
"tag",
"description",
"serial",
"acquired",
"in_service",
"cost",
"salvage",
"useful_life_months",
"accumulated_scheduled",
"accumulated_posted",
"book_value",
"status",
"proceeds",
"basis_rule",
"contributor_adjusted_basis",
"fmv_at_contribution",
"fmv_recorded",
"tax_basis_723",
"depreciable_basis",
"built_in_gain_704c",
"sec704c_1c",
"sec704c_method",
"basis_source"
]
);
let col = |name: &str| header.iter().position(|h| h == name).unwrap();
let body: Vec<Vec<String>> = lines
.map(|l| l.split(',').map(str::to_string).collect())
.collect();
let csv_row = |tag: &str| {
body.iter()
.find(|r| r.first().map(String::as_str) == Some(tag))
.unwrap_or_else(|| panic!("{tag} in {csv}"))
.clone()
};
let r = csv_row("CARRY-1");
assert_eq!(r[col("basis_rule")], "carryover_723");
assert_eq!(canon(&r[col("tax_basis_723")]), "650.25");
assert_eq!(canon(&r[col("built_in_gain_704c")]), "549.75");
assert_eq!(r[col("fmv_recorded")], "true");
assert_eq!(r[col("sec704c_1c")], "false", "a built-in gain");
assert_eq!(
csv_row("CONV-1")[col("sec704c_1c")],
"true",
"a built-in loss"
);
let r = csv_row("BOUGHT-1");
assert_eq!(r[col("sec704c_1c")], "", "a purchase");
assert_eq!(canon(&r[col("accumulated_posted")]), "100");
assert_eq!(canon(&r[col("book_value")]), "1100");
assert_eq!(r[col("contributor_adjusted_basis")], "");
let total = csv_row("TOTAL");
assert_eq!(total.len(), header.len());
assert_eq!(canon(&total[col("depreciable_basis")]), "3050.25");
assert_eq!(canon(&total[col("tax_basis_723")]), "2650.25");
assert_eq!(canon(&total[col("accumulated_posted")]), "100");
}
// ------------------------------------- P3: defect 22 and runs, over MCP
/// Books holding asset X of the P3 depreciation brief (12,000.00, no
/// salvage, 12 months from 2026-01-01: 1,000.00 a month), paid for.
fn asset_x(name: &str) -> (Mcp, u64) {
let mut m = books(name);
m.post(
"2026-01-01",
&[
("1000", "debit", "20000.00"),
("3000", "credit", "20000.00"),
],
);
m.post(
"2026-01-01",
&[
("1500", "debit", "12000.00"),
("1000", "credit", "12000.00"),
],
);
let (x, _) = m.register_ok(json!({"cost": "12000.00"}));
(m, x)
}
fn dispose_args(x: u64, date: &str, proceeds: &str) -> Value {
json!({"asset_id": x, "date": date, "proceeds": proceeds,
"proceeds_account": "1000", "gain_loss_account": "7100"})
}
/// Defect 22, reproduction B over MCP: January through May run, then a
/// disposal on 2026-06-15 for 7,000.00. Hand check: June held 15 of 30
/// days, 500.00 (the final depreciation); accumulated 5,500.00, book value
/// 6,500.00, gain 500.00. Afterwards books.asset_schedule ends there, and
/// books.list_assets lists it as gone: not held, every figure 0.00 (it
/// left the books with its disposal; rules.md B-15 rule 8). Before P3 the
/// schedule showed June 7,000.00, accumulated 12,000.00, book value 0.00.
#[test]
fn a_disposed_asset_lists_what_it_depreciated_through_its_disposal() {
let (mut m, x) = asset_x("disposed-figures");
for p in ["2026-01", "2026-02", "2026-03", "2026-04", "2026-05"] {
m.ok(
"books.run_depreciation",
json!({"period": p, "asset_id": x}),
);
}
let preview = m.ok(
"books.dispose_asset",
dispose_args(x, "2026-06-15", "7000.00"),
);
assert_eq!(the_value(&preview, "final_depreciation"), "500");
assert_eq!(the_value(&preview, "excess_depreciation"), "0");
assert_eq!(the_value(&preview, "book_value"), "6500");
assert_eq!(the_value(&preview, "gain_loss"), "500");
let doc = m.file_bill_of_sale();
let mut confirm = dispose_args(x, "2026-06-15", "7000.00");
confirm["confirmed"] = json!(true);
confirm["receipt_attachment_ids"] = json!([doc]);
m.ok("books.dispose_asset", confirm);
let s = m.read_only("books.asset_schedule", json!({"asset_id": x}));
assert_eq!(s["status"], "disposed");
let lines = s["schedule"].as_array().unwrap();
let jun = lines.last().unwrap();
assert_eq!(jun["period"], "2026-06");
assert_eq!(canon(jun["amount"].as_str().unwrap()), "500");
assert_eq!(canon(jun["accumulated"].as_str().unwrap()), "5500");
assert_eq!(canon(jun["book_value"].as_str().unwrap()), "6500");
let listed = m.asset(x);
assert_eq!(listed["held"], false);
for figure in [
"accumulated_posted",
"accumulated_scheduled_through",
"unposted_depreciation",
"book_value",
] {
assert_eq!(canon(listed[figure].as_str().unwrap()), "0", "{figure}");
}
assert_eq!(listed["schedule_error"], Value::Null);
}
/// P3 depreciation review R2 over MCP: with June already run (dated Jun
/// 30), a disposal on Jun 15 for 7,000.00 credits back June's last 15
/// days. Hand check: the runs carry 6,000.00; the schedule through Jun 15
/// is 5,500.00, so 500.00 is excess; book value 6,500.00, gain 500.00. The
/// entry: Dr 1510 6,000.00, Dr 1000 7,000.00 / Cr 1500 12,000.00, Cr 6500
/// 500.00, Cr 7100 500.00.
#[test]
fn a_disposal_inside_a_posted_month_takes_back_the_extra_days() {
let (mut m, x) = asset_x("disposed-inside");
for p in 1..=6 {
m.ok(
"books.run_depreciation",
json!({"period": format!("2026-{p:02}"), "asset_id": x}),
);
}
let preview = m.ok(
"books.dispose_asset",
dispose_args(x, "2026-06-15", "7000.00"),
);
assert_eq!(the_value(&preview, "final_depreciation"), "0");
assert_eq!(the_value(&preview, "excess_depreciation"), "500");
assert_eq!(the_value(&preview, "catch_up_depreciation"), "0");
assert_eq!(the_value(&preview, "accumulated"), "6000");
assert_eq!(the_value(&preview, "book_value"), "6500");
assert_eq!(the_value(&preview, "gain_loss"), "500");
let line = |code: &str| {
preview["lines"]
.as_array()
.unwrap()
.iter()
.find(|l| l["code"] == code)
.unwrap_or_else(|| panic!("no {code} line in {preview}"))
.clone()
};
assert_eq!(line("6500")["side"], "credit");
assert_eq!(canon(line("6500")["amount"].as_str().unwrap()), "500");
assert_eq!(canon(line("1510")["amount"].as_str().unwrap()), "6000");
}
/// P3 depreciation review R10 over MCP: a run-all for a month whose
/// depreciation rounds to 0.00 posts nothing for that asset and lists it
/// under skipped, with why; journaling nothing. Hand check: 0.01 over 3
/// months from 2026-01-01: January's cumulative a third of a cent, 0.00.
#[test]
fn run_depreciation_skips_a_month_that_rounds_to_nothing() {
let mut m = books("zero-month");
let (a, _) = m.register_ok(json!({"cost": "0.01", "useful_life_months": 3}));
let before = m.journal();
let r = m.ok("books.run_depreciation", json!({"period": "2026-01"}));
assert_eq!(r["runs"], json!([]));
let skipped = r["skipped"].as_array().unwrap();
assert_eq!(skipped.len(), 1);
assert_eq!(skipped[0]["asset_id"], json!(a));
assert!(
skipped[0]["reason"]
.as_str()
.unwrap()
.contains("rounds to nothing"),
"{r}"
);
assert_eq!(m.journal(), before, "nothing journaled");
let r = m.ok("books.run_depreciation", json!({"period": "2026-02"}));
assert_eq!(canon(r["runs"][0]["amount"].as_str().unwrap()), "0.01");
}
/// The catch-up rule over MCP (plan/served.md P3, settled in design):
/// January run, February skipped, a March run posts 2,000.00, of which
/// 1,000.00 is caught up; a run reversed within its month is due again
/// (reversed after it, the next run catches it up: the store's
/// a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month), and
/// a disposal entry can't be reversed.
#[test]
fn a_skipped_month_is_caught_up_and_said() {
let (mut m, x) = asset_x("caught-up");
m.ok(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": x}),
);
let r = m.ok(
"books.run_depreciation",
json!({"period": "2026-03", "asset_id": x}),
);
assert_eq!(canon(r["runs"][0]["amount"].as_str().unwrap()), "2000");
assert_eq!(canon(r["runs"][0]["caught_up"].as_str().unwrap()), "1000");
let jan_entry = {
let again = m.ok(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": x}),
);
assert_eq!(again["runs"][0]["already_posted"], json!(true));
again["runs"][0]["entry_id"].as_u64().unwrap()
};
m.ok(
"books.reverse_entry",
json!({"entry_id": jan_entry, "date": "2026-01-31", "description": "undo",
"confirmed": true}),
);
let r = m.ok(
"books.run_depreciation",
json!({"period": "2026-01", "asset_id": x}),
);
assert_eq!(r["runs"][0]["already_posted"], json!(false));
assert_eq!(canon(r["runs"][0]["amount"].as_str().unwrap()), "1000");
let doc = m.file_bill_of_sale();
let mut confirm = dispose_args(x, "2026-03-31", "9000.00");
confirm["confirmed"] = json!(true);
confirm["receipt_attachment_ids"] = json!([doc]);
let disposed = m.ok("books.dispose_asset", confirm);
let entry = disposed["entry_id"].as_u64().unwrap();
let err = m.refuse(
"books.reverse_entry",
json!({"entry_id": entry, "date": "2026-04-02", "description": "undo"}),
);
assert!(err.contains("can't be reversed"), "{err}");
}
/// A basis recorded before P3 has no value at contribution and reads it as
/// the book cost, so correcting the cost moves its built-in gain: the
/// result says so (P3 review, 2026-10-21: the tool text said the built-in
/// gain was "as contributed" whatever the record). By hand: cost 1,200.00,
/// basis 400.00, gain 800.00; cost corrected to 1,100.00, gain 700.00.
#[test]
fn correcting_a_legacy_contributions_cost_says_its_built_in_gain_moved() {
let mut m = books("legacy-correct");
let (id, t) = m.register_ok(json!({}));
let dir = m.books_dir.take().unwrap();
let data = m.data.clone();
drop(m);
let mut w = ledger_store::JournalWriter::open(&data.join("journal.jsonl")).unwrap();
w.append(
"asset.tax_basis_set",
json!({"asset_id": id, "rule": "carryover", "contributor_basis": "400.00"}),
)
.unwrap();
drop(w);
let toml = dir.join("wanderbooks.toml");
let mut m = Mcp::start(&dir, &toml);
m.books_dir = Some(dir);
assert_eq!(m.asset(id)["basis"]["built_in_gain_704c"], "800.00");
let r = m.ok(
"books.correct_asset",
json!({"asset": t, "cost": "1100.00", "reason": "invoice misread"}),
);
assert_eq!(r["basis"]["built_in_gain_704c"], "700.00", "{r}");
let notes = r["notes"].to_string();
assert!(
notes.contains("moved with the cost (800.00 to 700.00)"),
"{notes}"
);
// A recorded value never moves, and says nothing.
m.ok(
"books.set_asset_tax_basis",
json!({"asset": t, "rule": "carryover_723", "contributor_adjusted_basis": "400.00",
"fmv_at_contribution": "1100.00"}),
);
let r = m.ok(
"books.correct_asset",
json!({"asset": t, "cost": "1000.00", "reason": "invoice misread again"}),
);
assert_eq!(r["basis"]["built_in_gain_704c"], "700.00", "{r}");
assert!(
r.get("notes")
.is_none_or(|n| n.as_array().is_some_and(|a| a.is_empty())),
"{r}"
);
}