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:
swaitsandClaude Fable 5.1 committed 2026-10-29 13:55:38 -06:00
1 parent 7299cf90f2
commit 6a66a2855b
49 files changed
+2526 -202

No files matched your search

+779
View File
@@ -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);
}