feat(drafts): the drafts tools, the preview digest on every confirm, the digest tool (P4)
Accounting reason: a confirm must act on the thing that was previewed. Every tool with confirmed (post_entry, reverse_entry, run_schedules, dispose_asset, reconcile, close_period, create_invoice, create_bill, write_off_invoice, purge_tin, consolidate_duplicates, and the S6 settlement tools receive_payment and pay_bill, which gain confirmed and preview on a clone of the ledger) returns preview (SHA-256 of the canonical preview) and refuses a confirm whose preview is missing or stale (stale_preview, before anything is written). A draft's digest is its own state and the settings seq; an operation's is the tool, its arguments as sent, the settings seq and the journal head seq, so another session's write forces a re-preview (D-50). New tools books.list_drafts, books.set_draft_state, books.discard_draft, books.preview_entry; reports carry as_of_status (drafts open on or before their end, unreconciled accounts, settings problems) and take strict; the CPA package is refused while a draft dated in it is open unless allow_open_drafts gives a reason, journaled as a decision; books.digest takes since_seq (what was posted since a journal line, backdated entries included: defect 6) and lists zero_net_accounts, drafts_by_state and the reconciliation split. Needs attention gains 'draft awaiting receipt' beside 'draft not posted'; the drafts handbook is rewritten. Every over-the-wire harness confirms the way an agent does. Docs: mcp-interface, rules B-43, parity, testing (with the For-P12 paragraph), architecture, and the unit's As-built block. Tests: drafts.rs (a_draft_waits_for_its_receipt_and_posts, the_gate_moved_and_the_mcp_error_is_unchanged, a_reversal_needs_no_receipt_of_its_own, discard_unblocks_the_close_over_the_wire, report_warns_on_draft_before_as_of, cpa_package_refuses_open_draft, stale_preview_refused, every_confirm_tool_takes_a_preview, digest_since_seq_is_posting_time, legacy_reversed_replays_identically, gui_and_server_gate_agree), attention_parity.rs, format_upgrade.rs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
7299cf90f2
commit
6a66a2855b
49 files changed
+2526
-202
No files matched your search
@@ -0,0 +1,779 @@
|
||||
//! Drafts, the evidence gate and the preview digest over the wire (plan
|
||||
//! P4; rules.md B-43, D-47, D-50): a draft waits for its receipt and
|
||||
//! posts when it arrives, a discard unblocks a close, a report and the CPA
|
||||
//! package say when drafts are open, a reversal needs no receipt of its
|
||||
//! own, and every tool with `confirmed` takes the `preview` it returned
|
||||
//! and refuses a stale one.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — drafts",
|
||||
//! "The preview digest") and rules.md B-43. Every call goes through a real
|
||||
//! books server as an agent reaches it (`common.rs`).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, answer_of};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Cash at 1000, the default 25.00 receipt threshold.
|
||||
const SETTINGS: &str = "[company]\nname = \"Drafts Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
fn chart(w: &mut common::Wire) {
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
("4000", "Sales", "revenue"),
|
||||
("6000", "Rent", "expense"),
|
||||
] {
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A draft of `amount` rent paid from cash on `date`.
|
||||
fn draft(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
|
||||
let r = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": what, "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap();
|
||||
let id = r["entry_id"].as_u64().unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn a_receipt(w: &mut common::Wire, entry: u64) -> u64 {
|
||||
let bytes = b"%PDF-1.4 a receipt";
|
||||
let upload = w.upload(bytes, "receipt.pdf", "application/pdf", "receipt");
|
||||
answer_of(&w.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": entry, "upload_id": upload}),
|
||||
))
|
||||
.unwrap()["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn setup(tag: &str) -> (Books, common::Wire) {
|
||||
let books = Books::with(tag, "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p4");
|
||||
chart(&mut w);
|
||||
(books, w)
|
||||
}
|
||||
|
||||
/// The refusal a call met, parsed when it is structured.
|
||||
fn refusal(r: &Value) -> Value {
|
||||
let text = answer_of(r).expect_err("expected a refusal");
|
||||
serde_json::from_str(&text).unwrap_or(Value::String(text))
|
||||
}
|
||||
|
||||
/// A draft finished but waiting for its receipt says so, is listed as
|
||||
/// missing its evidence, is refused at the post, and posts the moment the
|
||||
/// receipt is attached.
|
||||
#[test]
|
||||
fn a_draft_waits_for_its_receipt_and_posts() {
|
||||
let (_books, mut w) = setup("p4-waits");
|
||||
let id = draft(&mut w, "2026-03-01", "Figma annual plan", "100.00");
|
||||
let set = answer_of(&w.call(
|
||||
"books.set_draft_state",
|
||||
json!({"entry_id": id, "state": "awaiting_evidence", "note": "Figma invoice due"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(set["state"], "awaiting_evidence");
|
||||
let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
|
||||
let d = &listed["drafts"][0];
|
||||
assert_eq!(d["entry_id"], id);
|
||||
assert_eq!(d["state"], "awaiting_evidence");
|
||||
assert_eq!(d["note"], "Figma invoice due");
|
||||
assert_eq!(d["total"], "100.00");
|
||||
assert_eq!(d["missing_evidence"], true);
|
||||
let waiting = answer_of(&w.call(
|
||||
"books.list_drafts",
|
||||
json!({"state": "awaiting_evidence", "as_of": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(waiting["drafts"].as_array().unwrap().len(), 1);
|
||||
let building = answer_of(&w.call("books.list_drafts", json!({"state": "building"}))).unwrap();
|
||||
assert!(building["drafts"].as_array().unwrap().is_empty());
|
||||
// The preview names the need; the confirm is refused with the error the
|
||||
// MCP always gave.
|
||||
let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap();
|
||||
assert_eq!(p["status"], "preview");
|
||||
assert_eq!(p["receipt_required"], true);
|
||||
assert_eq!(p["receipt_missing"], true);
|
||||
let digest = p["preview"].as_str().unwrap().to_string();
|
||||
let r = w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true, "preview": digest}),
|
||||
);
|
||||
let err = refusal(&r);
|
||||
assert_eq!(err["code"], "receipt_required", "{err}");
|
||||
// The receipt arrives; the same draft posts.
|
||||
a_receipt(&mut w, id);
|
||||
let r = w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
let posted = answer_of(&r).unwrap();
|
||||
assert_eq!(posted["status"], "posted");
|
||||
let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
|
||||
assert!(listed["drafts"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// B-43: the structured `receipt_required` error is the one the MCP built
|
||||
/// before the gate moved into the store.
|
||||
#[test]
|
||||
fn the_gate_moved_and_the_mcp_error_is_unchanged() {
|
||||
let (_books, mut w) = setup("p4-error");
|
||||
let id = draft(&mut w, "2026-03-01", "Rent", "100.00");
|
||||
let r = w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
let err = refusal(&r);
|
||||
assert_eq!(err["code"], "receipt_required");
|
||||
assert_eq!(err["entry_id"], id);
|
||||
assert_eq!(err["debits"], "100.00");
|
||||
assert_eq!(
|
||||
err["policy"],
|
||||
"receipts required when entry debits are strictly greater than 25.00"
|
||||
);
|
||||
assert_eq!(err["remedy_tool"], "books.attach_receipt");
|
||||
assert!(
|
||||
err["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with(&format!("entry {id} moves 100.00 in debits ("))
|
||||
);
|
||||
assert!(
|
||||
err["remedy_hint"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("then retry books.post_entry with confirmed=true")
|
||||
);
|
||||
assert!(err.get("split_group").is_none());
|
||||
}
|
||||
|
||||
/// D-47: a reversal posts at once with no receipt of its own; its evidence
|
||||
/// is its original's.
|
||||
#[test]
|
||||
fn a_reversal_needs_no_receipt_of_its_own() {
|
||||
let (_books, mut w) = setup("p4-reversal");
|
||||
let id = draft(&mut w, "2026-03-01", "Rent", "100.00");
|
||||
a_receipt(&mut w, id);
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let r = answer_of(&w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": id, "date": "2026-03-05", "description": "Undo", "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(r["status"], "posted");
|
||||
let d = answer_of(&w.call("books.digest", json!({"since": "2026-03-01"}))).unwrap();
|
||||
for e in d["entries"].as_array().unwrap() {
|
||||
if e["reversal_of"].is_number() {
|
||||
assert_eq!(e["receipt_required"], false, "{e}");
|
||||
assert_eq!(e["receipt_missing"], false, "{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A period holding an open draft cannot close; discarded (previewed, then
|
||||
/// confirmed with its digest), it can.
|
||||
#[test]
|
||||
fn discard_unblocks_the_close_over_the_wire() {
|
||||
let (_books, mut w) = setup("p4-discard");
|
||||
let seed = draft(&mut w, "2026-03-01", "Seed", "10.00");
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": seed, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let per = answer_of(&w.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
|
||||
))
|
||||
.unwrap()["period_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let id = draft(&mut w, "2026-03-20", "Entered twice", "40.00");
|
||||
let r = w.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": per, "confirmed": true}),
|
||||
);
|
||||
assert!(
|
||||
answer_of(&r).is_err(),
|
||||
"a close is refused with a draft open"
|
||||
);
|
||||
let p = answer_of(&w.call(
|
||||
"books.discard_draft",
|
||||
json!({"entry_id": id, "reason": "entered twice"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(p["status"], "preview");
|
||||
assert_eq!(p["holds_open"], json!(["2026-03"]));
|
||||
let digest = p["preview"].as_str().unwrap().to_string();
|
||||
// A reason is the preview's; a different one is a different preview.
|
||||
let r = w.call_raw(
|
||||
"books.discard_draft",
|
||||
json!({"entry_id": id, "reason": "something else", "confirmed": true, "preview": digest}),
|
||||
);
|
||||
assert_eq!(refusal(&r)["code"], "stale_preview");
|
||||
let done = answer_of(&w.call_raw(
|
||||
"books.discard_draft",
|
||||
json!({"entry_id": id, "reason": "entered twice", "confirmed": true, "preview": digest}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(done["status"], "discarded");
|
||||
assert!(
|
||||
answer_of(&w.call("books.list_drafts", json!({}))).unwrap()["drafts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let closed = answer_of(&w.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": per, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(closed["status"], "closed");
|
||||
// Only a draft: the posted seed is never discarded.
|
||||
let r = w.call(
|
||||
"books.discard_draft",
|
||||
json!({"entry_id": seed, "reason": "no"}),
|
||||
);
|
||||
assert!(answer_of(&r).unwrap_err().contains("posted"));
|
||||
}
|
||||
|
||||
/// A report says what is open as of its end; strict refuses instead.
|
||||
#[test]
|
||||
fn report_warns_on_draft_before_as_of() {
|
||||
let (_books, mut w) = setup("p4-report");
|
||||
let id = draft(&mut w, "2026-03-10", "Open", "40.00");
|
||||
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
|
||||
let open = &tb["as_of_status"]["drafts_open_on_or_before"];
|
||||
assert_eq!(open[0]["entry_id"], id);
|
||||
assert_eq!(open[0]["state"], "building");
|
||||
let early = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-01"}))).unwrap();
|
||||
assert!(
|
||||
early["as_of_status"]["drafts_open_on_or_before"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"a draft dated after the report's end is not open in it"
|
||||
);
|
||||
for tool in [
|
||||
"books.trial_balance",
|
||||
"books.balance_sheet",
|
||||
"books.profit_and_loss",
|
||||
"books.cash_flow",
|
||||
] {
|
||||
let args = match tool {
|
||||
"books.trial_balance" | "books.balance_sheet" => {
|
||||
json!({"as_of": "2026-03-31", "strict": true})
|
||||
}
|
||||
_ => json!({"start": "2026-03-01", "end": "2026-03-31", "strict": true}),
|
||||
};
|
||||
let r = w.call(tool, args);
|
||||
let why = answer_of(&r).expect_err(tool);
|
||||
assert!(
|
||||
why.contains("strict") && why.contains("draft"),
|
||||
"{tool}: {why}"
|
||||
);
|
||||
}
|
||||
// Posted, the report is clean and strict gives it.
|
||||
a_receipt(&mut w, id);
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let ok = answer_of(&w.call(
|
||||
"books.trial_balance",
|
||||
json!({"as_of": "2026-03-31", "strict": true}),
|
||||
));
|
||||
// Cash is not reconciled, so strict still names it: the refusal says
|
||||
// so and not "draft".
|
||||
if let Err(why) = ok {
|
||||
assert!(!why.contains("draft"), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPA package is refused while a draft dated in it is open, unless
|
||||
/// the human says why; the reason is journaled as a decision.
|
||||
#[test]
|
||||
fn cpa_package_refuses_open_draft() {
|
||||
let (books, mut w) = setup("p4-cpa");
|
||||
let id = draft(&mut w, "2026-03-10", "Open", "40.00");
|
||||
let args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"]});
|
||||
let err = refusal(&w.call("books.export_cpa_package", args.clone()));
|
||||
assert_eq!(err["code"], "open_drafts", "{err}");
|
||||
assert!(err["message"].as_str().unwrap().contains(&format!("#{id}")));
|
||||
// A draft dated after as_of does not stop it.
|
||||
let before = books.journal().len();
|
||||
let mut allowed = args.clone();
|
||||
allowed["allow_open_drafts"] = json!({"reason": "the CPA wants it before the receipt comes"});
|
||||
let out = answer_of(&w.call("books.export_cpa_package", allowed)).unwrap();
|
||||
assert!(out["file"].is_object(), "{out}");
|
||||
let journal = String::from_utf8(books.journal()).unwrap();
|
||||
assert!(books.journal().len() > before);
|
||||
assert!(journal.contains("decision.recorded"));
|
||||
assert!(journal.contains("the CPA wants it before the receipt comes"));
|
||||
}
|
||||
|
||||
/// D-50: a confirm takes the preview it was shown; without it, or after a
|
||||
/// changed argument or a write by another session, it is refused with
|
||||
/// `stale_preview` before anything is written.
|
||||
#[test]
|
||||
fn stale_preview_refused() {
|
||||
let (books, mut w) = setup("p4-stale");
|
||||
let id = draft(&mut w, "2026-03-01", "Rent", "10.00");
|
||||
// A draft's preview is the draft's state: a line added since is stale.
|
||||
let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap();
|
||||
let digest = p["preview"].as_str().unwrap().to_string();
|
||||
// The same preview from post_entry itself.
|
||||
let p2 = answer_of(&w.call("books.post_entry", json!({"entry_id": id}))).unwrap();
|
||||
assert_eq!(p2["preview"], p["preview"]);
|
||||
// No preview at all.
|
||||
let r = w.call_raw(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(refusal(&r)["code"], "stale_preview");
|
||||
// A line added since the preview.
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": "6000", "side": "debit", "amount": "1.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": "1.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
let before = books.journal();
|
||||
let r = w.call_raw(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true, "preview": digest}),
|
||||
);
|
||||
let err = refusal(&r);
|
||||
assert_eq!(err["code"], "stale_preview");
|
||||
assert_eq!(err["remedy_tool"], "books.post_entry");
|
||||
assert_eq!(books.journal(), before, "a stale confirm writes nothing");
|
||||
// An operation's preview moves with the journal's head: a post by
|
||||
// another session in between forces a re-preview.
|
||||
let other = draft(&mut w, "2026-03-02", "Other", "5.00");
|
||||
let first = answer_of(&w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "x"}),
|
||||
));
|
||||
assert!(first.is_err(), "a draft is not reversible");
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": other, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let p = answer_of(&w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "x"}),
|
||||
))
|
||||
.unwrap();
|
||||
let digest = p["preview"].as_str().unwrap().to_string();
|
||||
// The same call with another description is another preview...
|
||||
let r = w.call_raw(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "y",
|
||||
"confirmed": true, "preview": digest}),
|
||||
);
|
||||
assert_eq!(refusal(&r)["code"], "stale_preview");
|
||||
// ...and so is a write between preview and confirm.
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": "6100", "name": "Software", "account_type": "expense"}),
|
||||
))
|
||||
.unwrap();
|
||||
let r = w.call_raw(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "x",
|
||||
"confirmed": true, "preview": digest}),
|
||||
);
|
||||
assert_eq!(refusal(&r)["code"], "stale_preview");
|
||||
// Re-previewed, it confirms.
|
||||
let r = w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": other, "date": "2026-03-03", "description": "x",
|
||||
"confirmed": true}),
|
||||
);
|
||||
assert_eq!(answer_of(&r).unwrap()["status"], "posted");
|
||||
}
|
||||
|
||||
/// Every tool that takes `confirmed` takes `preview` (D-50, from P4 on).
|
||||
#[test]
|
||||
fn every_confirm_tool_takes_a_preview() {
|
||||
let books = Books::new("p4-schema");
|
||||
let mut w = books.session("p4");
|
||||
let tools = w.rpc("tools/list", json!({}))["result"]["tools"].clone();
|
||||
let mut confirm = Vec::new();
|
||||
for t in tools.as_array().expect("tools") {
|
||||
let props = &t["inputSchema"]["properties"];
|
||||
if props.get("confirmed").is_some() {
|
||||
confirm.push(t["name"].as_str().unwrap().to_string());
|
||||
assert!(
|
||||
props.get("preview").is_some(),
|
||||
"{} takes confirmed but not preview",
|
||||
t["name"]
|
||||
);
|
||||
}
|
||||
}
|
||||
for tool in [
|
||||
"books.post_entry",
|
||||
"books.reverse_entry",
|
||||
"books.run_schedules",
|
||||
"books.dispose_asset",
|
||||
"books.reconcile",
|
||||
"books.close_period",
|
||||
"books.create_invoice",
|
||||
"books.create_bill",
|
||||
"books.receive_payment",
|
||||
"books.pay_bill",
|
||||
"books.write_off_invoice",
|
||||
"books.discard_draft",
|
||||
] {
|
||||
assert!(confirm.iter().any(|t| t == tool), "{tool} has no confirmed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Defect 6: an entry dated in the past but posted just now is in the
|
||||
/// digest asked for by the journal sequence number, and not in the one
|
||||
/// asked for by date.
|
||||
#[test]
|
||||
fn digest_since_seq_is_posting_time() {
|
||||
let (_books, mut w) = setup("p4-digest");
|
||||
let early = draft(&mut w, "2026-01-05", "Backdated", "20.00");
|
||||
let head =
|
||||
answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap()["head_seq"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": early, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let by_seq = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap();
|
||||
let ids: Vec<u64> = by_seq["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["entry_id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec![early]);
|
||||
assert!(by_seq["head_seq"].as_u64().unwrap() > head);
|
||||
let by_date = answer_of(&w.call("books.digest", json!({"since": "2026-02-01"}))).unwrap();
|
||||
assert!(by_date["entries"].as_array().unwrap().is_empty());
|
||||
// Nothing after the new head.
|
||||
let none =
|
||||
answer_of(&w.call("books.digest", json!({"since_seq": by_seq["head_seq"]}))).unwrap();
|
||||
assert!(none["entries"].as_array().unwrap().is_empty());
|
||||
// An account touched whose net movement is zero is listed, not lost.
|
||||
let second = draft(&mut w, "2026-01-06", "Refund", "20.00");
|
||||
// (reverse the sign on the same accounts: debit cash, credit rent)
|
||||
answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": second, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
let rev = answer_of(&w.call(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": second, "date": "2026-01-07", "description": "Undo",
|
||||
"confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(rev["status"], "posted");
|
||||
let d = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap();
|
||||
let zero: Vec<&str> = d["zero_net_accounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
assert!(
|
||||
zero.is_empty(),
|
||||
"the first entry still moves rent: {zero:?}"
|
||||
);
|
||||
let only_pair = answer_of(&w.call(
|
||||
"books.digest",
|
||||
json!({"since": "2026-01-06", "as_of": "2026-01-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
let zero: Vec<&str> = only_pair["zero_net_accounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
assert_eq!(zero, vec!["1000 Cash", "6000 Rent"]);
|
||||
assert_eq!(only_pair["drafts_by_state"], json!({}));
|
||||
assert_eq!(only_pair["never_reconciled"], json!(["1000"]));
|
||||
}
|
||||
|
||||
/// The viewer and the server ask one function whether an entry lacks its
|
||||
/// evidence (plan P4 step 2, I-5): on random books under three policies
|
||||
/// the viewer's missing-evidence set equals the server's, and both equal
|
||||
/// what the viewer decided before the gate moved (an entry's origin read
|
||||
/// from the journal's events: a hand entry or a recurring draft is
|
||||
/// covered; a document's, a payment's, a close, a schedule month's, a
|
||||
/// depreciation's, a payroll's and a reversal are not).
|
||||
#[cfg(feature = "gui")]
|
||||
#[test]
|
||||
fn gui_and_server_gate_agree() {
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_gui::data;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
|
||||
let usd = |c: i64| Money::new(Decimal::new(c, 2), Currency::USD);
|
||||
for (n, (threshold, required, exempt)) in [
|
||||
("0.00", vec![], vec![]),
|
||||
("25.00", vec!["6200"], vec!["6300"]),
|
||||
("100.00", vec![], vec!["6300", "6100"]),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let root = tempfile::Builder::new()
|
||||
.prefix(&format!("wb-p4-gate-{n}-"))
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let dir = root.path().join("book");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
|
||||
st.change_settings(
|
||||
&[
|
||||
Change {
|
||||
key: "policy.evidence.threshold".into(),
|
||||
value: Some(json!(threshold)),
|
||||
},
|
||||
Change {
|
||||
key: "policy.evidence.required_accounts".into(),
|
||||
value: Some(json!(required)),
|
||||
},
|
||||
Change {
|
||||
key: "policy.evidence.exempt_accounts".into(),
|
||||
value: Some(json!(exempt)),
|
||||
},
|
||||
],
|
||||
&Source {
|
||||
decided_by: "test".into(),
|
||||
decided_on: day(1, 1),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut acct =
|
||||
|code: &str, name: &str, ty| st.add_account(code, name, ty, None, false).unwrap();
|
||||
let bank = acct("1000", "Bank", AccountType::Asset);
|
||||
let prepaid = acct("1300", "Prepaid", AccountType::Asset);
|
||||
let capital = acct("3000", "Capital", AccountType::Equity);
|
||||
let sales = acct("4000", "Sales", AccountType::Revenue);
|
||||
let expenses = [
|
||||
acct("6100", "Supplies", AccountType::Expense),
|
||||
acct("6200", "Contractors", AccountType::Expense),
|
||||
acct("6300", "Fees", AccountType::Expense),
|
||||
];
|
||||
let seed = st.create_entry(day(1, 1), "Capital").unwrap();
|
||||
st.add_line(seed, bank, Side::Debit, usd(10_000_000))
|
||||
.unwrap();
|
||||
st.add_line(seed, capital, Side::Credit, usd(10_000_000))
|
||||
.unwrap();
|
||||
st.post_entry(seed).unwrap();
|
||||
// A deterministic pseudo-random run of hand entries.
|
||||
let mut x: u64 = 0x2545F4914F6CDD1D ^ (n as u64);
|
||||
let mut next = move || {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
x
|
||||
};
|
||||
let mut posted = Vec::new();
|
||||
for i in 0..36u32 {
|
||||
let e = st
|
||||
.create_entry(day(2, 1 + (i % 27)), &format!("Entry {i}"))
|
||||
.unwrap();
|
||||
let cents = 100 + (next() % 30_000) as i64;
|
||||
let ex = expenses[(next() % 3) as usize];
|
||||
st.add_line(e, ex, Side::Debit, usd(cents)).unwrap();
|
||||
st.add_line(e, bank, Side::Credit, usd(cents)).unwrap();
|
||||
if next() % 3 == 0 {
|
||||
st.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
|
||||
.unwrap();
|
||||
}
|
||||
// Some stay drafts.
|
||||
if next() % 6 != 0 {
|
||||
st.post_entry(e).unwrap();
|
||||
posted.push(e);
|
||||
}
|
||||
}
|
||||
for e in posted.iter().take(5) {
|
||||
st.reverse_entry(*e, day(3, 2), "Reversed").unwrap();
|
||||
}
|
||||
let customer = st.add_customer("Fernhill Labs").unwrap();
|
||||
let inv = st
|
||||
.create_invoice(
|
||||
customer,
|
||||
day(3, 3),
|
||||
day(4, 2),
|
||||
vec![ledger_core::arap::DocumentLine {
|
||||
description: "Design".into(),
|
||||
amount: Decimal::new(250_000, 2),
|
||||
class_id: None,
|
||||
account: None,
|
||||
quantity: None,
|
||||
unit_price: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
st.receive_payment(inv, day(3, 20), Decimal::new(100_000, 2), bank)
|
||||
.unwrap();
|
||||
let sched = st
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ledger_core::ScheduleKind::PrepaidExpense,
|
||||
total: usd(120_000),
|
||||
start: day(1, 1),
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: expenses[0],
|
||||
convention: ledger_core::ScheduleConvention::MonthlyEven,
|
||||
})
|
||||
.unwrap();
|
||||
st.post_schedule_month(sched, day(1, 31)).unwrap();
|
||||
let _ = sales;
|
||||
let per = st.create_period("2026-01", day(1, 1), day(1, 31)).unwrap();
|
||||
let _ = st.close_period(per);
|
||||
st.register_recurring(ledger_core::NewRecurring {
|
||||
description: "Figma".into(),
|
||||
lines: vec![
|
||||
(expenses[0], Side::Debit, usd(4_000)),
|
||||
(bank, Side::Credit, usd(4_000)),
|
||||
],
|
||||
frequency: ledger_core::Frequency::Monthly,
|
||||
start: day(5, 10),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
st.run_recurring(day(5, 31)).unwrap();
|
||||
// What the server decides: the engine, with the receipts filed.
|
||||
let server: BTreeSet<u64> = (1..st.ledger().next_entry_id())
|
||||
.filter(|id| {
|
||||
let Some(e) = st.ledger().get_entry(*id) else {
|
||||
return false;
|
||||
};
|
||||
ledger_core::evidence::requirement(st.ledger(), e, st.ledger().settings())
|
||||
.is_required()
|
||||
&& st.list_attachments(Some(*id)).unwrap().is_empty()
|
||||
})
|
||||
.collect();
|
||||
// What the viewer decided before: origin from the journal's events.
|
||||
let policy = ledger_core::evidence::Policy::from_settings(st.ledger().settings());
|
||||
let journal = String::from_utf8(std::fs::read(dir.join("journal.jsonl")).unwrap()).unwrap();
|
||||
let mut refs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
for line in journal.lines() {
|
||||
let v: Value = serde_json::from_str(line).unwrap();
|
||||
let ty = v["type"].as_str().unwrap().to_string();
|
||||
let p = &v["payload"];
|
||||
let mut ids = Vec::new();
|
||||
if ty.starts_with("entry.") {
|
||||
ids.extend(p["id"].as_u64());
|
||||
}
|
||||
ids.extend(p["entry_id"].as_u64());
|
||||
ids.extend(p["closing_entry"].as_u64());
|
||||
ids.extend(p["entry"]["id"].as_u64());
|
||||
for id in ids {
|
||||
refs.entry(id).or_default().push(ty.clone());
|
||||
}
|
||||
}
|
||||
let closings = st.ledger().closing_entry_ids();
|
||||
let old: BTreeSet<u64> = (1..st.ledger().next_entry_id())
|
||||
.filter(|id| {
|
||||
let Some(e) = st.ledger().get_entry(*id) else {
|
||||
return false;
|
||||
};
|
||||
let has = |t: &str| refs.get(id).is_some_and(|v| v.iter().any(|x| x == t));
|
||||
let by_hand = !closings.contains(id)
|
||||
&& !has("entry.reversed")
|
||||
&& !has("schedule.posted")
|
||||
&& !has("asset.depreciation_posted")
|
||||
&& !has("asset.disposed")
|
||||
&& !has("bill.created")
|
||||
&& !has("bill.paid")
|
||||
&& !has("invoice.created")
|
||||
&& !has("invoice.payment_received")
|
||||
&& !has("payroll.run")
|
||||
&& !has("payroll.taxes_remitted");
|
||||
by_hand
|
||||
&& e.reversal_of().is_none()
|
||||
&& st.list_attachments(Some(*id)).unwrap().is_empty()
|
||||
&& policy.receipt_required(st.ledger(), e)
|
||||
})
|
||||
.collect();
|
||||
drop(st);
|
||||
let books = data::load_once(&data::Source {
|
||||
data_dir: dir.clone(),
|
||||
today: Some(day(6, 1)),
|
||||
})
|
||||
.unwrap();
|
||||
let viewer: BTreeSet<u64> = (1..=books.ledger.next_entry_id())
|
||||
.filter(|id| books.entry(*id).is_some_and(|e| e.missing_receipt))
|
||||
.collect();
|
||||
assert!(!server.is_empty(), "policy {threshold}: nothing to compare");
|
||||
assert_eq!(viewer, server, "policy {threshold}: viewer vs server");
|
||||
assert_eq!(server, old, "policy {threshold}: engine vs the old reading");
|
||||
}
|
||||
}
|
||||
|
||||
/// Books an older version wrote hold a reversal and drafts (the fixture
|
||||
/// `p4_before`): opened under this version, with no upgrade, the figures
|
||||
/// are the writer's, the reversal needs no receipt of its own, and the
|
||||
/// drafts are open and building.
|
||||
#[test]
|
||||
fn legacy_reversed_replays_identically() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books/p4_before");
|
||||
let only = tempfile::tempdir().unwrap();
|
||||
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
|
||||
let books = Books::placed("p4-legacy", only.path());
|
||||
let mut w = books.session("p4");
|
||||
let d = answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap();
|
||||
let reversals: Vec<&Value> = d["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e["reversal_of"].is_number())
|
||||
.collect();
|
||||
assert_eq!(reversals.len(), 1);
|
||||
assert_eq!(reversals[0]["receipt_required"], false);
|
||||
assert_eq!(reversals[0]["receipt_missing"], false);
|
||||
let drafts = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
|
||||
let open: Vec<&Value> = drafts["drafts"].as_array().unwrap().iter().collect();
|
||||
assert_eq!(open.len(), 3, "{drafts}");
|
||||
assert!(open.iter().all(|d| d["state"] == "building"));
|
||||
assert_eq!(d["drafts_by_state"], json!({"building": 3}));
|
||||
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-12-31"}))).unwrap();
|
||||
assert_eq!(tb["balanced"], true);
|
||||
}
|
||||
Reference in new issue
Block a user