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

@@ -297,3 +297,56 @@ fn an_unsent_invoice_is_on_both_lists() {
assert_eq!(step["tool"], json!("books.mark_sent"), "{answer}"); assert_eq!(step["tool"], json!("books.mark_sent"), "{answer}");
assert_eq!(step["args"]["invoice"], json!("invoice:1"), "{answer}"); assert_eq!(step["args"]["invoice"], json!("invoice:1"), "{answer}");
} }
/// Plan P4 step 6 (R-F): a draft that says it waits for its evidence is
/// one item, "draft awaiting receipt", and a draft still being built is
/// another, "draft not posted": both on the viewer's list and in
/// `books.start`'s next steps, from the one engine.
#[test]
fn a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("attention-drafts-parity");
let dir = place.books_data();
let _ = std::fs::remove_dir_all(&dir);
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
let cash = st
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let rent = st
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let mut draft = |what: &str| {
let e = st.create_entry(today - chrono::Days::new(5), what).unwrap();
st.add_line(e, rent, Side::Debit, usd(4_000)).unwrap();
st.add_line(e, cash, Side::Credit, usd(4_000)).unwrap();
e
};
let waiting = draft("Figma annual plan");
let _building = draft("Coworking day pass");
st.set_draft_state(
waiting,
ledger_core::DraftState::AwaitingEvidence {
note: "invoice due".into(),
},
)
.unwrap();
drop(st);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-drafts-parity");
compare_and_expect(
&place,
&mut wire,
today,
&format!("Draft #{waiting} awaiting receipt: Figma annual plan"),
);
let titles: Vec<String> = start(&mut wire)["next_steps"]
.as_array()
.unwrap()
.iter()
.filter_map(|s| s["why"].as_str().map(str::to_string))
.collect();
assert!(
titles.contains(&"Draft not posted: Coworking day pass".to_string()),
"{titles:?}"
);
}
+16 -1
View File
@@ -232,7 +232,22 @@ async fn connect(home: &Home, book: &str, token: String) -> Agent {
} }
/// Call a tool: Ok(structured result) or Err(the error text). /// Call a tool: Ok(structured result) or Err(the error text).
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> { /// Call a tool the way an agent confirms (plan P4, D-50): a call with
/// `confirmed: true` and no `preview` is made first without `confirmed`,
/// then with the `preview` it returned. A refused preview is the answer.
async fn call(c: &Client, tool: &str, mut args: Value) -> Result<Value, String> {
if args.get("confirmed") == Some(&json!(true)) && args.get("preview").is_none() {
let mut first = args.clone();
first["confirmed"] = json!(false);
let seen = call_once(c, tool, first).await?;
if let Some(digest) = seen.get("preview").and_then(Value::as_str) {
args["preview"] = json!(digest);
}
}
call_once(c, tool, args).await
}
async fn call_once(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
let args = args.as_object().cloned().unwrap_or_default(); let args = args.as_object().cloned().unwrap_or_default();
let r = tokio::time::timeout( let r = tokio::time::timeout(
Duration::from_secs(60), Duration::from_secs(60),
+64 -3
View File
@@ -366,8 +366,32 @@ impl Wire {
resp resp
} }
/// Call a tool: the `tools/call` answer. /// Call a tool: the `tools/call` answer. A call with `confirmed: true`
pub fn call(&mut self, tool: &str, args: Value) -> Value { /// and no `preview` is made the way an agent makes it (plan P4, D-50):
/// the same call without `confirmed` first, then the confirm with the
/// `preview` it returned. A preview that is refused is the answer
/// (the refusal a confirm would have met); a tool that returns no
/// preview is called as given. Use [`Self::call_raw`] to send exactly
/// these arguments.
pub fn call(&mut self, tool: &str, mut args: Value) -> Value {
if args.get("confirmed") == Some(&json!(true)) && args.get("preview").is_none() {
let mut first = args.clone();
first["confirmed"] = json!(false);
let seen = self.call_raw(tool, first);
match answer_of(&seen) {
Err(_) => return seen,
Ok(out) => {
if let Some(digest) = out.get("preview").and_then(Value::as_str) {
args["preview"] = json!(digest);
}
}
}
}
self.call_raw(tool, args)
}
/// Call a tool with exactly these arguments.
pub fn call_raw(&mut self, tool: &str, args: Value) -> Value {
self.rpc("tools/call", json!({"name": tool, "arguments": args})) self.rpc("tools/call", json!({"name": tool, "arguments": args}))
} }
@@ -597,6 +621,28 @@ pub fn upload_args(bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str
"mime_type": mime_type, "file_name": file_name, "purpose": purpose}) "mime_type": mime_type, "file_name": file_name, "purpose": purpose})
} }
/// `tools/call` through `rpc` (a harness's own request function), the way
/// an agent confirms (plan P4, D-50): a call with `confirmed: true` and no
/// `preview` is made first without `confirmed`, then with the `preview` it
/// returned. A refused preview is the answer; a tool that returns no
/// preview is called as given.
pub fn tools_call(mut rpc: impl FnMut(&str, Value) -> Value, name: &str, mut args: Value) -> Value {
if args.get("confirmed") == Some(&json!(true)) && args.get("preview").is_none() {
let mut first = args.clone();
first["confirmed"] = json!(false);
let seen = rpc("tools/call", json!({"name": name, "arguments": first}));
match answer_of(&seen) {
Err(_) => return seen,
Ok(out) => {
if let Some(digest) = out.get("preview").and_then(Value::as_str) {
args["preview"] = json!(digest);
}
}
}
}
rpc("tools/call", json!({"name": name, "arguments": args}))
}
/// A `tools/call` answer: Ok(its structured result) or Err(the refusal's /// A `tools/call` answer: Ok(its structured result) or Err(the refusal's
/// or the protocol error's text). /// or the protocol error's text).
pub fn answer_of(r: &Value) -> Result<Value, String> { pub fn answer_of(r: &Value) -> Result<Value, String> {
@@ -985,7 +1031,22 @@ pub async fn connect_at(run: &Path, book: &str, token: Option<String>) -> Result
} }
/// Call a tool: Ok(structured result) or Err(the error text). /// Call a tool: Ok(structured result) or Err(the error text).
pub async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> { /// Call a tool the way an agent confirms (plan P4, D-50): a call with
/// `confirmed: true` and no `preview` is made first without `confirmed`,
/// then with the `preview` it returned. A refused preview is the answer.
pub async fn call(c: &Client, tool: &str, mut args: Value) -> Result<Value, String> {
if args.get("confirmed") == Some(&json!(true)) && args.get("preview").is_none() {
let mut first = args.clone();
first["confirmed"] = json!(false);
let seen = call_once(c, tool, first).await?;
if let Some(digest) = seen.get("preview").and_then(Value::as_str) {
args["preview"] = json!(digest);
}
}
call_once(c, tool, args).await
}
async fn call_once(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolRequestParams;
let args = args.as_object().cloned().unwrap_or_default(); let args = args.as_object().cloned().unwrap_or_default();
// A client whose server went away may wait forever: a call that // A client whose server went away may wait forever: a call that
+16 -13
View File
@@ -50,7 +50,7 @@ impl Mcp {
/// Call a tool: its result, or the refusal's text. /// Call a tool: its result, or the refusal's text.
fn try_call(&mut self, tool: &str, args: Value) -> Result<Value, String> { fn try_call(&mut self, tool: &str, args: Value) -> Result<Value, String> {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone());
let result = &resp["result"]; let result = &resp["result"];
if result.get("isError") == Some(&json!(true)) { if result.get("isError") == Some(&json!(true)) {
let text = result["content"] let text = result["content"]
@@ -125,7 +125,10 @@ fn an_invoice_from_issue_to_paid() {
// and due date the confirm will take. // and due date the confirm will take.
let before = books.journal(); let before = books.journal();
let preview = m.call("books.create_invoice", issue.clone()); let preview = m.call("books.create_invoice", issue.clone());
assert_eq!(preview["preview"], json!(true), "{preview}"); assert!(
preview["preview"].is_string(),
"the digest to confirm by: {preview}"
);
assert_eq!(preview["number"], "INV-0001", "{preview}"); assert_eq!(preview["number"], "INV-0001", "{preview}");
assert_eq!(preview["due_date"], "2026-03-03", "{preview}"); assert_eq!(preview["due_date"], "2026-03-03", "{preview}");
assert_eq!(preview["terms"], "Net 30", "{preview}"); assert_eq!(preview["terms"], "Net 30", "{preview}");
@@ -240,7 +243,7 @@ fn a_bill_with_its_vendor_invoice_and_terms() {
let mut preview = bill.clone(); let mut preview = bill.clone();
preview["upload_id"] = json!(file); preview["upload_id"] = json!(file);
let p = m.call("books.create_bill", preview.clone()); let p = m.call("books.create_bill", preview.clone());
assert_eq!(p["preview"], json!(true), "{p}"); assert!(p["preview"].is_string(), "the digest to confirm by: {p}");
assert_eq!(p["due_date"], "2026-03-03", "{p}"); assert_eq!(p["due_date"], "2026-03-03", "{p}");
assert_eq!(p["terms"], "2/10 net 30", "{p}"); assert_eq!(p["terms"], "2/10 net 30", "{p}");
assert_eq!(p["discount_by"], "2026-02-11", "{p}"); assert_eq!(p["discount_by"], "2026-02-11", "{p}");
@@ -933,7 +936,7 @@ mod paying_on_terms {
let out = ok( let out = ok(
&mut s.wire, &mut s.wire,
"books.receive_payment", "books.receive_payment",
json!({"date": "2026-02-10", "deposit_to": "1000", json!({"confirmed": true, "date": "2026-02-10", "deposit_to": "1000",
"reference": "one deposit", "reference": "one deposit",
"applications": [ "applications": [
{"invoice": first, "amount": "300.00"}, {"invoice": first, "amount": "300.00"},
@@ -997,14 +1000,14 @@ mod paying_on_terms {
let why = err( let why = err(
&mut s.wire, &mut s.wire,
"books.pay_bill", "books.pay_bill",
json!({"date": "2026-02-05", "paid_from": "1000", "applications": applications(None)}), json!({"confirmed": true, "date": "2026-02-05", "paid_from": "1000", "applications": applications(None)}),
); );
assert!(why.contains("needs a reason"), "{why}"); assert!(why.contains("needs a reason"), "{why}");
assert!(why.contains("discount_reason"), "{why}"); assert!(why.contains("discount_reason"), "{why}");
ok( ok(
&mut s.wire, &mut s.wire,
"books.pay_bill", "books.pay_bill",
json!({"date": "2026-02-05", "paid_from": "1000", json!({"confirmed": true, "date": "2026-02-05", "paid_from": "1000",
"applications": applications(Some("the vendor honoured it late"))}), "applications": applications(Some("the vendor honoured it late"))}),
); );
} }
@@ -1018,7 +1021,7 @@ mod paying_on_terms {
let out = ok( let out = ok(
&mut s.wire, &mut s.wire,
"books.receive_payment", "books.receive_payment",
json!({"date": "2026-02-05", "deposit_to": "1000", json!({"confirmed": true, "date": "2026-02-05", "deposit_to": "1000",
"invoice": invoice, "amount": "100.00", "invoice": invoice, "amount": "100.00",
"fee": {"amount": "3.00"}}), "fee": {"amount": "3.00"}}),
); );
@@ -1053,7 +1056,7 @@ mod paying_on_terms {
ok( ok(
&mut s.wire, &mut s.wire,
"books.pay_bill", "books.pay_bill",
json!({"date": "2026-02-05", "paid_from": "2050", "bill": bill, "amount": "100.00", json!({"confirmed": true, "date": "2026-02-05", "paid_from": "2050", "bill": bill, "amount": "100.00",
"instrument": "Visa"}), "instrument": "Visa"}),
); );
let worksheet = ok( let worksheet = ok(
@@ -1081,7 +1084,7 @@ mod paying_on_terms {
ok( ok(
&mut s.wire, &mut s.wire,
"books.pay_bill", "books.pay_bill",
json!({"date": "2026-02-05", "paid_from": "1000", json!({"confirmed": true, "date": "2026-02-05", "paid_from": "1000",
"applications": [ "applications": [
{"bill": first, "amount": "100.00"}, {"bill": first, "amount": "100.00"},
{"bill": second, "amount": "200.00"} {"bill": second, "amount": "200.00"}
@@ -1118,7 +1121,7 @@ mod paying_on_terms {
let why = err( let why = err(
&mut s.wire, &mut s.wire,
"books.receive_payment", "books.receive_payment",
json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "150.00"}), json!({"confirmed": true, "date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "150.00"}),
); );
assert!(why.contains("150.00"), "{why}"); assert!(why.contains("150.00"), "{why}");
assert!(why.contains("100.00"), "{why}"); assert!(why.contains("100.00"), "{why}");
@@ -1189,7 +1192,7 @@ mod paying_on_terms {
ok( ok(
&mut s.wire, &mut s.wire,
"books.receive_payment", "books.receive_payment",
json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "200.00"}), json!({"confirmed": true, "date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "200.00"}),
); );
let out = ok( let out = ok(
&mut s.wire, &mut s.wire,
@@ -1282,7 +1285,7 @@ mod bad_debts {
ok( ok(
&mut wire, &mut wire,
"books.receive_payment", "books.receive_payment",
json!({ json!({"confirmed": true,
"document_id": inv, "document_id": inv,
"date": "2026-01-20", "date": "2026-01-20",
"amount": "40.00", "amount": "40.00",
@@ -1357,7 +1360,7 @@ mod bad_debts {
ok( ok(
&mut wire, &mut wire,
"books.receive_payment", "books.receive_payment",
json!({ json!({"confirmed": true,
"document_id": inv, "document_id": inv,
"date": "2026-03-02", "date": "2026-03-02",
"amount": "60.00", "amount": "60.00",
+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);
}
+33 -1
View File
@@ -20,6 +20,8 @@
//! ``` //! ```
#![cfg(target_os = "linux")] #![cfg(target_os = "linux")]
mod common;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -332,7 +334,7 @@ impl Agent {
/// Call a tool: Ok(its structured result) or Err(the error's text). /// Call a tool: Ok(its structured result) or Err(the error's text).
fn call(&mut self, tool: &str, args: Value) -> Result<Value, String> { fn call(&mut self, tool: &str, args: Value) -> Result<Value, String> {
let r = self.request("tools/call", json!({"name": tool, "arguments": args}), 60); let r = common::tools_call(|m, p| self.request(m, p, 60), tool, args.clone());
if let Some(e) = r.get("error") { if let Some(e) = r.get("error") {
return Err(e["message"].as_str().unwrap_or_default().to_string()); return Err(e["message"].as_str().unwrap_or_default().to_string());
} }
@@ -792,8 +794,38 @@ fn books_p2_served_cut_over_whole() {
) )
.unwrap()["id"] .unwrap()["id"]
.clone(); .clone();
// A draft P2 reads (the old shape): its state and its discard are
// drafts_v2's, and wait.
let waiting_draft = a
.call(
"books.draft_entry",
json!({"date": "2026-02-12", "description": "Waiting for a receipt",
"no_party_reason": "test"}),
)
.unwrap()["entry_id"]
.clone();
for (account, side) in [("6600", "debit"), ("1000", "credit")] {
a.call(
"books.add_line",
json!({"entry_id": waiting_draft, "account": account, "side": side,
"amount": "10.00"}),
)
.unwrap();
}
let waiting = journal(&dir); let waiting = journal(&dir);
let refused: Vec<(&str, Value)> = vec![ let refused: Vec<(&str, Value)> = vec![
// A draft's state and its discard (drafts_v2, P4): an older
// version refuses the types.
(
"books.set_draft_state",
json!({"entry_id": waiting_draft, "state": "awaiting_evidence",
"note": "the travel receipt"}),
),
(
"books.discard_draft",
json!({"entry_id": waiting_draft, "reason": "entered twice",
"confirmed": true}),
),
// An invoice carrying documents_v2's fields (terms: P4b S4.4) and // An invoice carrying documents_v2's fields (terms: P4b S4.4) and
// its line naming its project (document_line_projects, rules.md // its line naming its project (document_line_projects, rules.md
// B-37 item 8). // B-37 item 8).
+1 -1
View File
@@ -199,7 +199,7 @@ impl Mcp {
} }
fn ok(&mut self, tool: &str, args: Value) -> Value { fn ok(&mut self, tool: &str, args: Value) -> Value {
let r = self.rpc("tools/call", json!({"name": tool, "arguments": args})); let r = common::tools_call(|m, p| self.rpc(m, p), tool, args.clone());
assert!( assert!(
r.get("error").is_none() && r["result"]["isError"] != json!(true), r.get("error").is_none() && r["result"]["isError"] != json!(true),
"fixture {tool} {args}: {r}" "fixture {tool} {args}: {r}"
+4 -3
View File
@@ -1179,8 +1179,8 @@ async fn a_token_does_only_what_its_scopes_allow() {
"it may preview" "it may preview"
); );
assert!( assert!(
!tools.contains(&"books.receive_payment".to_string()), !tools.contains(&"books.void_document".to_string()),
"it may preview an invoice, but not post a payment" "it may preview an invoice, but not void one"
); );
let preview = call(&writer, "books.post_entry", json!({"entry_id": entry})).await; let preview = call(&writer, "books.post_entry", json!({"entry_id": entry})).await;
if let Err(e) = &preview { if let Err(e) = &preview {
@@ -1273,7 +1273,8 @@ async fn every_call_is_recorded_once_refusals_included() {
assert_eq!(by("books.get_entry"), 1); assert_eq!(by("books.get_entry"), 1);
assert_eq!(by("books.draft_entry"), 1); assert_eq!(by("books.draft_entry"), 1);
assert_eq!(by("books.add_line"), 2); assert_eq!(by("books.add_line"), 2);
assert_eq!(by("books.post_entry"), 1); // The preview, then the confirm (plan P4: each is a call, each recorded).
assert_eq!(by("books.post_entry"), 2);
assert_eq!(by("books.access_log"), 0, "recorded once answered"); assert_eq!(by("books.access_log"), 0, "recorded once answered");
let refused: Vec<&&Value> = tools let refused: Vec<&&Value> = tools
.iter() .iter()
+3 -3
View File
@@ -53,7 +53,7 @@ impl Mcp {
/// Call a tool; panic on tool error. Returns structuredContent. /// Call a tool; panic on tool error. Returns structuredContent.
fn call(&mut self, tool: &str, args: Value) -> Value { fn call(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone());
let result = &resp["result"]; let result = &resp["result"];
assert_ne!( assert_ne!(
result.get("isError"), result.get("isError"),
@@ -69,7 +69,7 @@ impl Mcp {
/// Call a tool expecting an error; returns the error text. /// Call a tool expecting an error; returns the error text.
fn call_err(&mut self, tool: &str, args: Value) -> String { fn call_err(&mut self, tool: &str, args: Value) -> String {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone());
let result = &resp["result"]; let result = &resp["result"];
assert_eq!( assert_eq!(
result.get("isError"), result.get("isError"),
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
// Tool listing: the narrow tools; chart is read-only. // Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 129, "expected 129 tools, got {}", tools.len()); assert_eq!(tools.len(), 133, "expected 133 tools, got {}", tools.len());
// Files come in as tool input: books.begin_upload, never a local path. // Files come in as tool input: books.begin_upload, never a local path.
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
+3 -1
View File
@@ -10,6 +10,8 @@
//! bridge_initializes_during_cold_start, no_local_agent_tcp). //! bridge_initializes_during_cold_start, no_local_agent_tcp).
#![cfg(target_os = "linux")] #![cfg(target_os = "linux")]
mod common;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -405,7 +407,7 @@ impl Agent {
/// Call a tool: Ok(its structured result) or Err(the error's text). /// Call a tool: Ok(its structured result) or Err(the error's text).
fn call(&mut self, tool: &str, args: Value) -> Result<Value, String> { fn call(&mut self, tool: &str, args: Value) -> Result<Value, String> {
let r = self.request("tools/call", json!({"name": tool, "arguments": args}), 60); let r = common::tools_call(|m, p| self.request(m, p, 60), tool, args.clone());
if let Some(e) = r.get("error") { if let Some(e) = r.get("error") {
return Err(e["message"].as_str().unwrap_or_default().to_string()); return Err(e["message"].as_str().unwrap_or_default().to_string());
} }
+14 -3
View File
@@ -114,7 +114,7 @@ impl Mcp {
} }
fn tool(&mut self, name: &str, args: Value) -> Out { fn tool(&mut self, name: &str, args: Value) -> Out {
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
if let Some(e) = resp.get("error") { if let Some(e) = resp.get("error") {
return Out::RpcErr(e.clone()); return Out::RpcErr(e.clone());
} }
@@ -1044,6 +1044,15 @@ fn cash_flow_exports_equal_the_core_statement() {
"books.cash_flow", "books.cash_flow",
json!({"start": START, "end": END, "cash_accounts": ["1000"], "financing_accounts": ["2100"]}), json!({"start": START, "end": END, "cash_accounts": ["1000"], "financing_accounts": ["2100"]}),
); );
// (The tool's answer also says what is open as of the end; the
// export's file is the statement alone.)
let mut tool = tool;
assert!(
tool.as_object_mut()
.unwrap()
.remove("as_of_status")
.is_some()
);
assert_eq!(tool, j); assert_eq!(tool, j);
} }
@@ -3442,7 +3451,8 @@ fn cpa_package_holds_the_year_and_its_evidence() {
let mut m = Mcp::start(&dir, "1000000.00"); let mut m = Mcp::start(&dir, "1000000.00");
let out = m.ok( let out = m.ok(
"books.export_cpa_package", "books.export_cpa_package",
json!({"as_of": END, "cash_accounts": ["1000"], "include_tins": false}), json!({"as_of": END, "cash_accounts": ["1000"], "include_tins": false,
"allow_open_drafts": {"reason": "the test books keep drafts open"}}),
); );
assert_eq!(out["file"]["file_name"], format!("cpa-package-{END}.zip")); assert_eq!(out["file"]["file_name"], format!("cpa-package-{END}.zip"));
assert_eq!(out["file"]["mime_type"], "application/zip"); assert_eq!(out["file"]["mime_type"], "application/zip");
@@ -3646,7 +3656,8 @@ fn cpa_package_holds_the_year_and_its_evidence() {
// Without receipts: no evidence at all. // Without receipts: no evidence at all.
let out = m.ok( let out = m.ok(
"books.export_cpa_package", "books.export_cpa_package",
json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}), json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false,
"allow_open_drafts": {"reason": "the test books keep drafts open"}}),
); );
let files = unzip(&m.handed(&out["file"])); let files = unzip(&m.handed(&out["file"]));
assert!( assert!(
+1 -1
View File
@@ -274,7 +274,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
let bill = server.tool("books.create_bill", bill_args); let bill = server.tool("books.create_bill", bill_args);
server.tool( server.tool(
"books.receive_payment", "books.receive_payment",
json!({"document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}), json!({"confirmed": true, "document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}),
); );
let b = data::load_once(&Source { let b = data::load_once(&Source {
data_dir: data.clone(), data_dir: data.clone(),
+1 -1
View File
@@ -146,7 +146,7 @@ impl Mcp {
/// must match the outputSchema. /// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out { fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal(); let before = self.journal();
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
let out = if let Some(err) = resp.get("error") { let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone()) Out::RpcErr(err.clone())
} else { } else {
+7 -1
View File
@@ -151,7 +151,7 @@ impl Mcp {
/// must match the outputSchema. /// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out { fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal(); let before = self.journal();
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
let out = if let Some(err) = resp.get("error") { let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone()) Out::RpcErr(err.clone())
} else { } else {
@@ -605,6 +605,12 @@ fn dispose_asset_takes_the_tag_and_the_tag_stays_reserved() {
by_tag["tag"] = json!(t.to_ascii_lowercase()); by_tag["tag"] = json!(t.to_ascii_lowercase());
let p1 = m.ok("books.dispose_asset", by_id.clone()); let p1 = m.ok("books.dispose_asset", by_id.clone());
let p2 = m.ok("books.dispose_asset", by_tag.clone()); let p2 = m.ok("books.dispose_asset", by_tag.clone());
// (The digest names the arguments as sent, so it differs by id and
// by tag; everything it shows is the same.)
let (mut p1, mut p2) = (p1, p2);
for p in [&mut p1, &mut p2] {
p.as_object_mut().unwrap().remove("preview");
}
assert_eq!(p1, p2, "same preview by id and by tag"); assert_eq!(p1, p2, "same preview by id and by tag");
let mut both = by_id.clone(); let mut both = by_id.clone();
both["tag"] = json!(t); both["tag"] = json!(t);
+1 -1
View File
@@ -157,7 +157,7 @@ impl Mcp {
/// must match the outputSchema. /// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out { fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal(); let before = self.journal();
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
let out = if let Some(err) = resp.get("error") { let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone()) Out::RpcErr(err.clone())
} else { } else {
+2 -2
View File
@@ -47,7 +47,7 @@ impl Mcp {
/// Call a tool; panic on tool error. Returns structuredContent. /// Call a tool; panic on tool error. Returns structuredContent.
fn call(&mut self, tool: &str, args: Value) -> Value { fn call(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone());
let result = &resp["result"]; let result = &resp["result"];
assert_ne!( assert_ne!(
result.get("isError"), result.get("isError"),
@@ -63,7 +63,7 @@ impl Mcp {
/// Call a tool expecting an error; returns the error text. /// Call a tool expecting an error; returns the error text.
fn call_err(&mut self, tool: &str, args: Value) -> String { fn call_err(&mut self, tool: &str, args: Value) -> String {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone());
let result = &resp["result"]; let result = &resp["result"];
assert_eq!( assert_eq!(
result.get("isError"), result.get("isError"),
+34 -8
View File
@@ -287,7 +287,7 @@ impl Mcp {
fn tool(&mut self, name: &str, args: Value) -> Out { fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal(); let before = self.journal();
let files = self.read_only(name).then(|| self.data_files()); let files = self.read_only(name).then(|| self.data_files());
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
let out = if let Some(err) = resp.get("error") { let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone()) Out::RpcErr(err.clone())
} else { } else {
@@ -884,6 +884,30 @@ fn happy_path(name: &str) -> Mcp {
"foreign_currency": "EUR", "foreign_amount": "10.00", "fx_rate": "1.10"}), "foreign_currency": "EUR", "foreign_amount": "10.00", "fx_rate": "1.10"}),
); );
// P4: a draft's state, the list, the full preview, and a discard.
let dd = m.ok(
"books.draft_entry",
json!({"date": "2026-04-23", "description": "Entered twice"}),
)["entry_id"]
.as_u64()
.unwrap();
for (account, side) in [("6999", "debit"), ("1000", "credit")] {
m.ok(
"books.add_line",
json!({"entry_id": dd, "account": account, "side": side, "amount": "5.00"}),
);
}
m.ok(
"books.set_draft_state",
json!({"entry_id": dd, "state": "awaiting_evidence", "note": "a receipt"}),
);
m.ok("books.list_drafts", json!({"state": "awaiting_evidence"}));
m.ok("books.preview_entry", json!({"entry_id": dd}));
m.ok(
"books.discard_draft",
json!({"entry_id": dd, "reason": "entered twice", "confirmed": true}),
);
// A/R and A/P. // A/R and A/P.
let inv = m.ok( let inv = m.ok(
"books.create_invoice", "books.create_invoice",
@@ -895,7 +919,7 @@ fn happy_path(name: &str) -> Mcp {
.unwrap(); .unwrap();
m.ok( m.ok(
"books.receive_payment", "books.receive_payment",
json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
); );
let vendor_invoice = m.upload( let vendor_invoice = m.upload(
b"vendor invoice", b"vendor invoice",
@@ -914,7 +938,7 @@ fn happy_path(name: &str) -> Mcp {
.unwrap(); .unwrap();
m.ok( m.ok(
"books.pay_bill", "books.pay_bill",
json!({"document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}), json!({"confirmed": true, "document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}),
); );
m.ok("books.ar_aging", json!({"as_of": "2026-06-30"})); m.ok("books.ar_aging", json!({"as_of": "2026-06-30"}));
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"})); m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
@@ -1258,11 +1282,13 @@ fn happy_path(name: &str) -> Mcp {
); );
m.ok( m.ok(
"books.export_cpa_package", "books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}), json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false,
"allow_open_drafts": {"reason": "the test books keep drafts open"}}),
); );
m.ok( m.ok(
"books.export_cpa_package", "books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false}), json!({"as_of": "2026-12-31", "include_receipts": false,
"allow_open_drafts": {"reason": "the test books keep drafts open"}}),
); );
// The access log, the backups' status, and a backup now (none is set // The access log, the backups' status, and a backup now (none is set
@@ -1666,7 +1692,7 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
), ),
Op::PayBill { doc, amount } => ( Op::PayBill { doc, amount } => (
"books.pay_bill", "books.pay_bill",
json!({"document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount, json!({"confirmed": true, "document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount,
"cash_account": "1000"}), "cash_account": "1000"}),
), ),
Op::Payout { deposit } => ( Op::Payout { deposit } => (
@@ -2636,7 +2662,7 @@ fn documents_void_and_tie_out_over_the_wire() {
.unwrap(); .unwrap();
let paid = m.ok( let paid = m.ok(
"books.receive_payment", "books.receive_payment",
json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00",
"cash_account": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}), "cash_account": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}),
); );
assert_eq!(paid["remaining"], "300.00", "{paid}"); assert_eq!(paid["remaining"], "300.00", "{paid}");
@@ -2738,7 +2764,7 @@ fn documents_list_and_timeline_over_the_wire() {
let later = invoice(&mut m, "2026-02-05", "2026-04-30", "80.00"); let later = invoice(&mut m, "2026-02-05", "2026-04-30", "80.00");
let pay = m.ok( let pay = m.ok(
"books.receive_payment", "books.receive_payment",
json!({"document_id": late, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), json!({"confirmed": true, "document_id": late, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
)["entry_id"] )["entry_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
+3 -3
View File
@@ -152,7 +152,7 @@ impl Mcp {
/// must match the outputSchema. /// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out { fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal(); let before = self.journal();
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
let out = if let Some(err) = resp.get("error") { let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone()) Out::RpcErr(err.clone())
} else { } else {
@@ -842,7 +842,7 @@ fn party_totals_match_a_hand_computed_year() {
); );
let pay = m.ok( let pay = m.ok(
"books.pay_bill", "books.pay_bill",
json!({"document_id": bill["id"], "date": "2026-07-15", "amount": "300.00", json!({"confirmed": true, "document_id": bill["id"], "date": "2026-07-15", "amount": "300.00",
"cash_account": "1000"}), "cash_account": "1000"}),
)["entry_id"] )["entry_id"]
.as_u64() .as_u64()
@@ -923,7 +923,7 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() {
); );
let pay = m.ok( let pay = m.ok(
"books.receive_payment", "books.receive_payment",
json!({"document_id": inv["id"], "date": "2026-03-20", "amount": "600.00", json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "600.00",
"cash_account": "1000"}), "cash_account": "1000"}),
)["entry_id"] )["entry_id"]
.as_u64() .as_u64()
+1 -1
View File
@@ -153,7 +153,7 @@ impl Mcp {
/// must match the outputSchema. /// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out { fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal(); let before = self.journal();
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
let out = if let Some(err) = resp.get("error") { let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone()) Out::RpcErr(err.clone())
} else { } else {
+3 -3
View File
@@ -152,7 +152,7 @@ impl Mcp {
/// must match the outputSchema. /// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out { fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal(); let before = self.journal();
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
let out = if let Some(err) = resp.get("error") { let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone()) Out::RpcErr(err.clone())
} else { } else {
@@ -1284,11 +1284,11 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
// Payments are balance-sheet only: no project. // Payments are balance-sheet only: no project.
let got = m.ok( let got = m.ok(
"books.receive_payment", "books.receive_payment",
json!({"document_id": inv["id"], "date": "2026-03-20", "amount": "3800.00", "cash_account": "1000"}), json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "3800.00", "cash_account": "1000"}),
); );
let paid = m.ok( let paid = m.ok(
"books.pay_bill", "books.pay_bill",
json!({"document_id": bill["id"], "date": "2026-03-30", "amount": "1250.00", "cash_account": "1000"}), json!({"confirmed": true, "document_id": bill["id"], "date": "2026-03-30", "amount": "1250.00", "cash_account": "1000"}),
); );
for e in [&got["entry_id"], &paid["entry_id"]] { for e in [&got["entry_id"], &paid["entry_id"]] {
let e = e.as_u64().unwrap(); let e = e.as_u64().unwrap();
+1 -1
View File
@@ -55,7 +55,7 @@ impl Mcp {
} }
fn result(&mut self, tool: &str, args: Value) -> Value { fn result(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone());
resp["result"].clone() resp["result"].clone()
} }
+1 -1
View File
@@ -162,7 +162,7 @@ impl Mcp {
/// must match the outputSchema. /// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out { fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal(); let before = self.journal();
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
let out = if let Some(err) = resp.get("error") { let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone()) Out::RpcErr(err.clone())
} else { } else {
@@ -1,5 +1,16 @@
# Drafts and reversals # Drafts and reversals
Drafts
- A draft (books.draft_entry, books.add_line) is in no report until it posts. books.list_drafts shows the open ones; books.preview_entry shows what posting one would do and returns the preview to confirm with.
- A draft is either building or awaiting_evidence. When its lines are final and it only waits for its receipt, say so: books.set_draft_state with state awaiting_evidence and a note saying what it waits for ("Figma invoice due 11/3"). The state changes no figure; the receipt rule is checked when the draft posts. When the receipt arrives, attach it (books.attach_receipt), preview, and post.
- A draft that will never post (entered twice, never billed) is thrown away with books.discard_draft and the reason: previewed first, then confirmed with the preview. Nothing in any report moves. A period holding an open draft cannot close, so post or discard it first.
- Reports say what is still open as of their end (as_of_status); pass strict: true to refuse a report while a draft dated in it is open. The CPA package is refused while one is open unless you pass allow_open_drafts with the reason.
Every confirm takes the preview
- Every tool that takes confirmed returns a preview (a digest) when called without it, and needs that preview back with confirmed=true. If the books or an argument changed since (another session posted, a line was added), the confirm is refused as stale_preview: preview again, show the human, then confirm.
Reversals
- Posted entries are immutable: there is no edit and no delete. - Posted entries are immutable: there is no edit and no delete.
- Corrections go through books.reverse_entry: without confirmed it previews the reversing entry and changes nothing; with confirmed=true it posts the reversal, linked to the original. Then draft and post the corrected entry through the normal flow. - Corrections go through books.reverse_entry: without confirmed it previews the reversing entry and changes nothing; with confirmed=true and the preview it posts the reversal, linked to the original. Then draft and post the corrected entry through the normal flow.
- A reversal needs no receipt of its own: its evidence is its original's, which stays filed. A returned-item notice from the bank, if there is one, is attached to the reversal right after.
- Show the human the reversal preview before confirming, as for any posting. - Show the human the reversal preview before confirming, as for any posting.
+16 -5
View File
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules; const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1). /// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 129] = [ pub const TABLE: [(&str, Needs); 133] = [
("books.handbook", R), ("books.handbook", R),
("books.start", R), ("books.start", R),
// Settings are committed at once: the settings commit of the plan. // Settings are committed at once: the settings commit of the plan.
@@ -163,8 +163,8 @@ pub const TABLE: [(&str, Needs); 129] = [
// These preview, then post when confirmed (P4b S4.4, A-1). // These preview, then post when confirmed (P4b S4.4, A-1).
("books.create_invoice", C), ("books.create_invoice", C),
("books.create_bill", C), ("books.create_bill", C),
("books.receive_payment", P), ("books.receive_payment", C),
("books.pay_bill", P), ("books.pay_bill", C),
("books.void_document", P), ("books.void_document", P),
// Records a send: changes the books' record, never a figure. // Records a send: changes the books' record, never a figure.
("books.mark_sent", W), ("books.mark_sent", W),
@@ -196,6 +196,11 @@ pub const TABLE: [(&str, Needs); 129] = [
("books.annotate_attachment", W), ("books.annotate_attachment", W),
("books.set_entry_settles", W), ("books.set_entry_settles", W),
("books.consolidate_duplicates", C), ("books.consolidate_duplicates", C),
// P4: drafts (plan P4; a discard previews, then acts).
("books.list_drafts", R),
("books.set_draft_state", W),
("books.discard_draft", C),
("books.preview_entry", R),
]; ];
/// What `tool` needs, given its arguments; None for a tool this server /// What `tool` needs, given its arguments; None for a tool this server
@@ -381,12 +386,18 @@ mod tests {
); );
assert_eq!( assert_eq!(
needs( needs(
"books.receive_payment", "books.void_document",
args(json!({"confirmed": false})).as_ref() args(json!({"confirmed": false})).as_ref()
), ),
Some(Scope::Post), Some(Scope::Post),
"a tool that posts at once needs books:post whatever it is given" "a tool that posts at once needs books:post whatever it is given"
); );
// P4: a payment previews, then posts when confirmed.
assert_eq!(needs("books.receive_payment", None), Some(Scope::Write));
assert_eq!(
needs("books.pay_bill", args(json!({"confirmed": true})).as_ref()),
Some(Scope::Post)
);
assert_eq!(needs("books.access_log", None), Some(Scope::Admin)); assert_eq!(needs("books.access_log", None), Some(Scope::Admin));
assert_eq!(needs("books.nonesuch", None), None); assert_eq!(needs("books.nonesuch", None), None);
// INVARIANT 4. // INVARIANT 4.
@@ -435,7 +446,7 @@ mod tests {
shown("books.create_invoice", &write), shown("books.create_invoice", &write),
"it may preview an invoice" "it may preview an invoice"
); );
assert!(!shown("books.receive_payment", &write)); assert!(!shown("books.void_document", &write));
let post = set(&[Scope::Post]); let post = set(&[Scope::Post]);
assert!(shown("books.post_entry", &post)); assert!(shown("books.post_entry", &post));
assert!(!shown("books.chart", &post)); assert!(!shown("books.chart", &post));
+23 -9
View File
@@ -583,6 +583,7 @@ impl BooksServer {
+ Self::payouts_router() + Self::payouts_router()
+ Self::tax_router() + Self::tax_router()
+ Self::write_offs_router() + Self::write_offs_router()
+ Self::drafts_router()
} }
/// The settings in force for this request: the books' own, taken from /// The settings in force for this request: the books' own, taken from
@@ -1010,14 +1011,10 @@ impl BooksServer {
if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() { if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() {
return Ok(()); return Ok(());
} }
let split_group = self let split_group = ledger_core::evidence::Policy::from_settings(store.ledger().settings())
.settings()
.policy
.split_group(store.ledger(), entry_id, entry); .split_group(store.ledger(), entry_id, entry);
if !self if !ledger_core::evidence::requirement(store.ledger(), entry, store.ledger().settings())
.settings() .is_required()
.policy
.receipt_required(store.ledger(), entry)
&& split_group.is_empty() && split_group.is_empty()
{ {
return Ok(()); return Ok(());
@@ -1328,6 +1325,10 @@ thread_local! {
/// (set at its start from the settings in force: one version per /// (set at its start from the settings in force: one version per
/// request), so every "today" in it agrees. /// request), so every "today" in it agrees.
static TIMEZONE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) }; static TIMEZONE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
/// The arguments the request running on this thread was called with,
/// as the caller sent them: what the preview digest (plan P4 step 4,
/// D-50) is taken over, so a changed argument is a changed preview.
static ARGS: std::cell::RefCell<Option<serde_json::Map<String, serde_json::Value>>> = const { std::cell::RefCell::new(None) };
} }
/// Poll `fut` with "today" in the request's zone on this thread, each /// Poll `fut` with "today" in the request's zone on this thread, each
@@ -1415,6 +1416,8 @@ mod attachments;
mod chart; mod chart;
#[path = "tools/documents.rs"] #[path = "tools/documents.rs"]
mod documents; mod documents;
#[path = "tools/drafts.rs"]
mod drafts;
#[path = "tools/entries.rs"] #[path = "tools/entries.rs"]
mod entries; mod entries;
#[path = "tools/evidence.rs"] #[path = "tools/evidence.rs"]
@@ -1503,7 +1506,10 @@ impl BooksServer {
} }
"books://reports/balance-sheet" => { "books://reports/balance-sheet" => {
let Json(out) = self let Json(out) = self
.balance_sheet(Parameters(BalanceSheetParams { as_of: None })) .balance_sheet(Parameters(BalanceSheetParams {
as_of: None,
strict: None,
}))
.await?; .await?;
json(&out) json(&out)
} }
@@ -1513,13 +1519,20 @@ impl BooksServer {
start: None, start: None,
end: None, end: None,
class: None, class: None,
strict: None,
})) }))
.await?; .await?;
json(&out) json(&out)
} }
"books://digest" => { "books://digest" => {
let since = digest_since(query, today()); let since = digest_since(query, today());
let Json(out) = self.digest(Parameters(DigestParams { since })).await?; let Json(out) = self
.digest(Parameters(DigestParams {
since: Some(since),
since_seq: None,
as_of: None,
}))
.await?;
json(&out) json(&out)
} }
handbook::URI => { handbook::URI => {
@@ -1694,6 +1707,7 @@ impl ServerHandler for BooksServer {
let result = run_blocking(&self.store, move || { let result = run_blocking(&self.store, move || {
Box::pin(async move { Box::pin(async move {
WROTE.with(|w| w.borrow_mut().take()); WROTE.with(|w| w.borrow_mut().take());
ARGS.with(|a| *a.borrow_mut() = request.arguments.clone());
let caller = running_call let caller = running_call
.caller() .caller()
.map(|c| (c.token.id.clone(), c.token.name.clone())); .map(|c| (c.token.id.clone(), c.token.name.clone()));
+8 -3
View File
@@ -42,7 +42,7 @@ fn annotations_complete() {
const D: Hints = (Some(false), Some(true), None); const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true)); const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true));
let table: [(&str, Hints); 129] = [ let table: [(&str, Hints); 133] = [
("books.handbook", R), ("books.handbook", R),
("books.start", R), ("books.start", R),
("books.set_setting", DI), ("books.set_setting", DI),
@@ -174,6 +174,11 @@ fn annotations_complete() {
("books.back_up_now", WI), ("books.back_up_now", WI),
// P4b S7: a bad-debt write-off. // P4b S7: a bad-debt write-off.
("books.write_off_invoice", D), ("books.write_off_invoice", D),
// P4: drafts.
("books.list_drafts", R),
("books.set_draft_state", W),
("books.discard_draft", D),
("books.preview_entry", R),
]; ];
let tools = BooksServer::tool_router().list_all(); let tools = BooksServer::tool_router().list_all();
assert_eq!(tools.len(), table.len()); assert_eq!(tools.len(), table.len());
@@ -213,8 +218,8 @@ fn annotations_complete() {
"{name}: openWorldHint" "{name}: openWorldHint"
); );
} }
// docs/mcp-interface.md names the 31 destructive tools. // docs/mcp-interface.md names the 32 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 31); assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 32);
} }
/// Every refusal an agent can be given whose text is fixed here, or /// Every refusal an agent can be given whose text is fixed here, or
+11
View File
@@ -930,6 +930,7 @@ impl BooksServer {
.receipt_required_for(&code_refs, debits); .receipt_required_for(&code_refs, debits);
let out = DisposeAssetOut { let out = DisposeAssetOut {
preview: None,
status: "preview".to_string(), status: "preview".to_string(),
asset_id, asset_id,
entry_id: 0, entry_id: 0,
@@ -943,6 +944,16 @@ impl BooksServer {
lines: preview_lines_for(&store, &raw)?, lines: preview_lines_for(&store, &raw)?,
receipt_required, receipt_required,
}; };
let digest = self.preview_of(
&store,
"books.dispose_asset",
p.confirmed.unwrap_or(false),
p.preview.as_deref(),
)?;
let out = DisposeAssetOut {
preview: Some(digest),
..out
};
if !p.confirmed.unwrap_or(false) { if !p.confirmed.unwrap_or(false) {
return Ok(Json(out)); return Ok(Json(out));
} }
+14 -2
View File
@@ -513,6 +513,12 @@ impl BooksServer {
.terms .terms
.as_ref() .as_ref()
.map(ledger_core::Terms::describe); .map(ledger_core::Terms::describe);
let digest = self.preview_of(
&store,
"books.create_invoice",
p.confirmed.unwrap_or(false),
p.preview.as_deref(),
)?;
if !p.confirmed.unwrap_or(false) { if !p.confirmed.unwrap_or(false) {
let printed = ledger_package::draft_invoice_document( let printed = ledger_package::draft_invoice_document(
store.ledger(), store.ledger(),
@@ -527,7 +533,7 @@ impl BooksServer {
); );
let pdf = self.hand_over(&book_name(&store), &file_name, "application/pdf", bytes)?; let pdf = self.hand_over(&book_name(&store), &file_name, "application/pdf", bytes)?;
return Ok(Json(InvoiceOut { return Ok(Json(InvoiceOut {
preview: Some(true), preview: Some(digest),
id: None, id: None,
number: preview.number, number: preview.number,
total: money(preview.total), total: money(preview.total),
@@ -711,9 +717,15 @@ impl BooksServer {
); );
} }
} }
let digest = self.preview_of(
&store,
"books.create_bill",
p.confirmed.unwrap_or(false),
p.preview.as_deref(),
)?;
if !p.confirmed.unwrap_or(false) { if !p.confirmed.unwrap_or(false) {
return Ok(Json(BillOut { return Ok(Json(BillOut {
preview: Some(true), preview: Some(digest),
id: None, id: None,
total: money(preview.total), total: money(preview.total),
due_date: preview.due_date.to_string(), due_date: preview.due_date.to_string(),
+276
View File
@@ -0,0 +1,276 @@
//! The drafts group (plan P4; rules.md B-43): which drafts are open and
//! what they wait for, a draft's state, throwing one away, and the full
//! preview of the post. Also what every report says is still open as of
//! its end (`as_of_status`) and the open-draft refusal of the CPA package.
//!
//! ```text
//! GOAL: a draft is never lost and never mistaken for a posted entry; a
//! report or a package says when drafts dated in it are missing
//! from its figures.
//! SOURCE: docs/spec/rules.md B-43; plan/served.md 4.8, D-47.
//! INVARIANTS:
//! 1. A state is a note on the draft: no figure moves.
//! 2. A discard is previewed, then confirmed with the preview's digest;
//! only a draft is discarded.
//! 3. `books.preview_entry` is `books.post_entry` without `confirmed`:
//! it changes nothing and returns the digest a confirm needs.
//! 4. A strict report refuses, naming what is open, instead of warning.
//! ```
use super::*;
use ledger_core::DraftState;
impl BooksServer {
/// A draft as the tools list it.
pub(crate) fn draft_out(
&self,
store: &Store,
e: &ledger_core::JournalEntry,
) -> Result<DraftOut, String> {
let ledger = store.ledger();
let required =
ledger_core::evidence::requirement(ledger, e, ledger.settings()).is_required();
let receipts = store
.list_attachments(Some(e.id()))
.map_err(|e| e.to_string())?;
Ok(DraftOut {
entry_id: e.id(),
date: e.date().to_string(),
description: e.description().to_string(),
total: ledger_core::evidence::Policy::entry_debits(e)
.map(money)
.unwrap_or_else(|_| "too large".to_string()),
state: e.draft_state().key().to_string(),
note: e.draft_state().note().to_string(),
missing_evidence: required && receipts.is_empty(),
})
}
/// What is open as of `end`: drafts dated on or before it, accounts
/// not reconciled through the month before it, settings problems.
pub(crate) fn as_of_status(
&self,
store: &Store,
end: NaiveDate,
) -> Result<AsOfStatusOut, String> {
let ledger = store.ledger();
let mut drafts = Vec::new();
for e in ledger.drafts() {
if e.date() <= end {
drafts.push(self.draft_out(store, e)?);
}
}
drafts.sort_by(|a, b| a.date.cmp(&b.date).then(a.entry_id.cmp(&b.entry_id)));
Ok(AsOfStatusOut {
drafts_open_on_or_before: drafts,
unreconciled: self.unreconciled_accounts(store, end)?,
settings_problems: settings_problems(ledger, &self.settings()),
})
}
/// A report's `as_of_status`, and under `strict` the refusal that
/// names what is open (INVARIANT 4).
pub(crate) fn report_status(
&self,
store: &Store,
end: NaiveDate,
strict: bool,
) -> Result<Option<AsOfStatusOut>, String> {
let status = self.as_of_status(store, end)?;
let open = !status.drafts_open_on_or_before.is_empty()
|| !status.unreconciled.is_empty()
|| !status.settings_problems.is_empty();
if strict && open {
let mut what = Vec::new();
if !status.drafts_open_on_or_before.is_empty() {
what.push(format!(
"{} draft(s) dated on or before {end} are open (books.list_drafts)",
status.drafts_open_on_or_before.len()
));
}
if !status.unreconciled.is_empty() {
what.push(format!(
"{} account(s) are not reconciled (books.digest)",
status.unreconciled.len()
));
}
if !status.settings_problems.is_empty() {
what.push(format!(
"{} setting(s) disagree with the chart (books.settings)",
status.settings_problems.len()
));
}
return Err(format!(
"strict: the figures as of {end} are not final: {}. Post or discard the drafts, or ask again without strict",
what.join("; ")
));
}
Ok(Some(status))
}
}
#[tool_router(router = drafts_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.list_drafts",
title = "List draft entries",
description = "The draft entries still open: id, date, description, total, state (building, or awaiting_evidence with a note saying what it waits for) and whether the receipt policy covers it and it has no receipt yet (missing_evidence). state filters to one state; as_of to drafts dated on or before a day. A draft is not in any report until it posts; a period holding an open draft cannot close.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn list_drafts(
&self,
params: Parameters<ListDraftsParams>,
) -> Result<Json<ListDraftsOut>, String> {
let p = params.0;
let state = match p.state.as_deref().map(str::trim) {
None | Some("") => None,
Some(s) => Some(
DraftState::parse(s, "")
.ok_or_else(|| format!("unknown state {s:?}: building or awaiting_evidence"))?,
),
};
let as_of = p.as_of.as_deref().map(parse_date).transpose()?;
let store = self.lock()?;
let mut drafts = Vec::new();
for e in store.ledger().drafts() {
if state
.as_ref()
.is_some_and(|s| s.key() != e.draft_state().key())
{
continue;
}
if as_of.is_some_and(|d| e.date() > d) {
continue;
}
drafts.push(self.draft_out(&store, e)?);
}
drafts.sort_by(|a, b| a.date.cmp(&b.date).then(a.entry_id.cmp(&b.entry_id)));
Ok(Json(ListDraftsOut { drafts }))
}
#[tool(
name = "books.set_draft_state",
title = "Set a draft's state",
description = "Say where a draft stands: building (still being worked on) or awaiting_evidence with a note saying what it waits for (\"Figma invoice due 11/3\"). A state is a note: no line or figure changes, and the receipt policy is still checked when the draft posts. Only a draft has one. Needs the books' format upgrade (drafts_v2).",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn set_draft_state(
&self,
params: Parameters<SetDraftStateParams>,
) -> Result<Json<SetDraftStateOut>, String> {
let p = params.0;
let note = p.note.as_deref().map(str::trim).unwrap_or("");
let state = DraftState::parse(p.state.trim(), note).ok_or_else(|| {
format!(
"unknown state {:?}: building or awaiting_evidence",
p.state.trim()
)
})?;
if matches!(state, DraftState::Building) && !note.is_empty() {
return Err("a note goes with awaiting_evidence: say what the draft waits for".into());
}
let mut store = self.lock()?;
store
.set_draft_state(p.entry_id, state.clone())
.map_err(|e| e.to_string())?;
Ok(Json(SetDraftStateOut {
entry_id: p.entry_id,
state: state.key().to_string(),
note: state.note().to_string(),
}))
}
#[tool(
name = "books.discard_draft",
title = "Discard a draft",
description = "Throw away a draft that will never post, with the reason (journaled). Only a draft: a posted entry is never removed, it is reversed. Nothing in any report moves; the draft's id is not reused; a period it held open can close. Without confirmed=true, a preview that changes nothing and returns the preview to pass back with confirmed=true. Needs the books' format upgrade (drafts_v2).",
annotations(
read_only_hint = false,
destructive_hint = true,
open_world_hint = false
)
)]
async fn discard_draft(
&self,
params: Parameters<DiscardDraftParams>,
) -> Result<Json<DiscardDraftOut>, String> {
let p = params.0;
let confirmed = p.confirmed.unwrap_or(false);
let mut store = self.lock()?;
let e = store
.ledger()
.get_entry(p.entry_id)
.ok_or_else(|| format!("unknown entry {}", p.entry_id))?
.clone();
if e.status() != ledger_core::EntryStatus::Draft {
return Err(format!(
"entry {} is posted: a posted entry is never removed, reverse it (books.reverse_entry)",
p.entry_id
));
}
if p.reason.trim().is_empty() {
return Err("give the reason the draft will not post".into());
}
// The draft as it stands, so a line added since is a stale preview.
let payload = serde_json::json!({
"tool": "books.discard_draft",
"entry": &e,
"reason": p.reason.trim(),
"settings_seq": store.ledger().settings().last_seq(),
});
let digest = Preview::check(
"books.discard_draft",
&payload,
confirmed,
p.preview.as_deref(),
)?;
let holds_open: Vec<String> = store
.ledger()
.list_periods()
.into_iter()
.filter(|per| per.status == ledger_core::PeriodStatus::Open && per.contains(e.date()))
.map(|per| per.name.clone())
.collect();
let mut out = DiscardDraftOut {
status: "preview".into(),
entry_id: p.entry_id,
description: e.description().to_string(),
total: ledger_core::evidence::Policy::entry_debits(&e)
.map(money)
.unwrap_or_else(|_| "too large".to_string()),
holds_open,
preview: Some(digest),
};
if !confirmed {
return Ok(Json(out));
}
store
.discard_draft(p.entry_id, &p.reason)
.map_err(|e| e.to_string())?;
out.status = "discarded".into();
Ok(Json(out))
}
#[tool(
name = "books.preview_entry",
title = "Preview posting a draft",
description = "What posting a draft would do, without posting: the lines, the receipt checklist and whether the receipt policy needs one, the party, any approvals missing, and the preview to pass to books.post_entry with confirmed=true. The same answer as books.post_entry without confirmed, as a read-only call.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn preview_entry(
&self,
params: Parameters<PreviewEntryParams>,
) -> Result<Json<PostEntryOut>, String> {
self.post_entry(Parameters(PostEntryParams {
entry_id: params.0.entry_id,
confirmed: None,
expected_total: None,
preview: None,
}))
.await
}
}
+41 -46
View File
@@ -340,7 +340,7 @@ impl BooksServer {
open_world_hint = false open_world_hint = false
) )
)] )]
async fn post_entry( pub(super) async fn post_entry(
&self, &self,
params: Parameters<PostEntryParams>, params: Parameters<PostEntryParams>,
) -> Result<Json<PostEntryOut>, String> { ) -> Result<Json<PostEntryOut>, String> {
@@ -368,20 +368,14 @@ impl BooksServer {
)); ));
} }
} }
let mut receipt_required = self // One function decides (plan P4, I-5): the engine's. A draft that
.settings() // joins a split group is covered too (anti-splitting).
.policy let receipt_required =
.receipt_required(store.ledger(), &entry); ledger_core::evidence::requirement(store.ledger(), &entry, store.ledger().settings())
.is_required();
let receipts = preview_receipts(&store, p.entry_id)?; let receipts = preview_receipts(&store, p.entry_id)?;
// Anti-splitting: if this entry looks like part of a split group, let split_group = ledger_core::evidence::Policy::from_settings(store.ledger().settings())
// the receipt policy applies to the group total.
let split_group = self
.settings()
.policy
.split_group(store.ledger(), p.entry_id, &entry); .split_group(store.ledger(), p.entry_id, &entry);
if !split_group.is_empty() {
receipt_required = true;
}
let receipt_missing = receipt_required && receipts.is_empty(); let receipt_missing = receipt_required && receipts.is_empty();
let party_policy = self.settings().parties; let party_policy = self.settings().parties;
let party_id = store.ledger().entry_party(p.entry_id); let party_id = store.ledger().entry_party(p.entry_id);
@@ -399,7 +393,8 @@ impl BooksServer {
} else { } else {
Vec::new() Vec::new()
}; };
let out = PostEntryOut { let mut out = PostEntryOut {
preview: None,
status: "preview".to_string(), status: "preview".to_string(),
entry_id: p.entry_id, entry_id: p.entry_id,
date: entry.date().to_string(), date: entry.date().to_string(),
@@ -428,6 +423,26 @@ impl BooksServer {
.map(|(rule, approver)| ApprovalNeededOut { rule, approver }) .map(|(rule, approver)| ApprovalNeededOut { rule, approver })
.collect(), .collect(),
}; };
// The digest of the draft as it stands (plan P4 step 4, D-50): its
// lines, party, vendor, instrument and evidence, and the settings
// seq. Another session's unrelated post does not disturb it.
let payload = serde_json::json!({
"tool": "books.post_entry",
"entry": &entry,
"party": out.party_id,
"parties": &out.parties,
"no_party_reason": &out.no_party_reason,
"vendor": store.ledger().entry_vendor(p.entry_id),
"instrument": store.ledger().entry_instrument(p.entry_id),
"evidence": out.receipts.iter().map(|r| r.attachment_id).collect::<Vec<_>>(),
"settings_seq": store.ledger().settings().last_seq(),
});
out.preview = Some(Preview::check(
"books.post_entry",
&payload,
p.confirmed.unwrap_or(false),
p.preview.as_deref(),
)?);
if !p.confirmed.unwrap_or(false) { if !p.confirmed.unwrap_or(false) {
return Ok(Json(out)); return Ok(Json(out));
} }
@@ -480,38 +495,11 @@ impl BooksServer {
}) })
.to_string()); .to_string());
} }
if receipt_missing { // The evidence gate, in the store (plan P4, I-5): the same
let split_note = if split_group.is_empty() { // decision the preview showed, made again at the moment it posts.
String::new() store
} else { .post_entry_checked(p.entry_id)
format!( .map_err(|e| receipt_required_text(e, &self.settings().policy))?;
" Anti-splitting: this looks like part of a split group with entries {:?}; the policy applies to the group total.",
split_group
)
};
return Err(ReceiptRequiredError {
code: "receipt_required".to_string(),
message: format!(
"entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}",
p.entry_id,
money(total),
self.settings().policy.describe(),
split_note
),
entry_id: Some(p.entry_id),
asset_id: None,
debits: total.to_string(),
policy: self.settings().policy.describe(),
remedy_tool: "books.attach_receipt".to_string(),
remedy_hint: format!(
"attach a receipt to entry {} with books.attach_receipt, then retry books.post_entry with confirmed=true",
p.entry_id
),
split_group,
}
.to_tool_error());
}
store.post_entry(p.entry_id).map_err(|e| e.to_string())?;
fix_derived_shares(&mut store, &self.settings(), p.entry_id)?; fix_derived_shares(&mut store, &self.settings(), p.entry_id)?;
let mut posted = out; let mut posted = out;
posted.status = "posted".to_string(); posted.status = "posted".to_string();
@@ -560,7 +548,14 @@ impl BooksServer {
(l.account, side, l.amount) (l.account, side, l.amount)
}) })
.collect(); .collect();
let digest = self.preview_of(
&store,
"books.reverse_entry",
p.confirmed.unwrap_or(false),
p.preview.as_deref(),
)?;
let out = ReverseEntryOut { let out = ReverseEntryOut {
preview: Some(digest),
status: "preview".to_string(), status: "preview".to_string(),
reversal_id: 0, // filled in on post reversal_id: 0, // filled in on post
reverses: p.entry_id, reverses: p.entry_id,
+8
View File
@@ -331,11 +331,18 @@ impl BooksServer {
}) })
.collect(); .collect();
let withdrawn = out_groups.iter().map(|g| g.withdraw.len()).sum(); let withdrawn = out_groups.iter().map(|g| g.withdraw.len()).sum();
let digest = self.preview_of(
&store,
"books.consolidate_duplicates",
confirmed,
params.0.preview.as_deref(),
)?;
if !confirmed { if !confirmed {
return Ok(Json(ConsolidateDuplicatesOut { return Ok(Json(ConsolidateDuplicatesOut {
status: "preview".into(), status: "preview".into(),
groups: out_groups, groups: out_groups,
withdrawn, withdrawn,
preview: Some(digest),
})); }));
} }
for g in &groups { for g in &groups {
@@ -373,6 +380,7 @@ impl BooksServer {
status: "consolidated".into(), status: "consolidated".into(),
groups: out_groups, groups: out_groups,
withdrawn, withdrawn,
preview: Some(digest),
})) }))
} }
} }
+142
View File
@@ -7,6 +7,7 @@
use super::*; use super::*;
use ledger_core::InstrumentId; use ledger_core::InstrumentId;
use ledger_store::StoreError;
/// The book's id, as the commands given to agents name it: its folder's /// The book's id, as the commands given to agents name it: its folder's
/// name in the data root. /// name in the data root.
@@ -34,6 +35,56 @@ pub(crate) fn named(
Ok((name, mime)) Ok((name, mime))
} }
/// A refusal of the store's evidence gate as the tool error the agent
/// reads: the structured `receipt_required` (plan P4 step 2: the error the
/// MCP built before the gate moved into the store, unchanged), any other
/// store error as its text.
pub(crate) fn receipt_required_text(e: StoreError, policy: &ReceiptPolicy) -> String {
let StoreError::ReceiptRequired(info) = e else {
return e.to_string();
};
let ledger_store::error::ReceiptRequiredInfo {
entry_id,
debits,
split_group,
..
} = *info;
let split_note = if split_group.is_empty() {
String::new()
} else {
format!(
" Anti-splitting: this looks like part of a split group with entries {:?}; the policy applies to the group total.",
split_group
)
};
// A draft's debits may be past exact arithmetic (rules.md B-28): then
// the refusal says so in place of the sum.
let (total, debits) = match debits {
Some(t) => (money(t), t.to_string()),
None => (
"an amount too large to add".to_string(),
"too large".to_string(),
),
};
ReceiptRequiredError {
code: "receipt_required".to_string(),
message: format!(
"entry {entry_id} moves {total} in debits ({}); attach a receipt with books.attach_receipt before posting.{split_note}",
policy.describe()
),
entry_id: Some(entry_id),
asset_id: None,
debits,
policy: policy.describe(),
remedy_tool: "books.attach_receipt".to_string(),
remedy_hint: format!(
"attach a receipt to entry {entry_id} with books.attach_receipt, then retry books.post_entry with confirmed=true"
),
split_group,
}
.to_tool_error()
}
/// The settings as ledger_package reads them: the company, and the /// The settings as ledger_package reads them: the company, and the
/// `[books]` cash, financing, and investing roles. /// `[books]` cash, financing, and investing roles.
pub(crate) fn package_settings(settings: &Settings) -> ledger_package::Settings<'_> { pub(crate) fn package_settings(settings: &Settings) -> ledger_package::Settings<'_> {
@@ -764,3 +815,94 @@ pub(crate) fn floor_char_boundary(text: &str, at: usize) -> usize {
} }
cut cut
} }
/// The preview digest (plan P4 step 4, D-50, amending I-6): the SHA-256 of
/// the canonical JSON of what a tool's preview shows. A confirmed call
/// recomputes it from the books as they are now and refuses a different
/// one (`stale_preview`): a changed argument, a post by another session,
/// a changed setting. It detects drift; it does not prove a human saw the
/// preview. No key: a same-user key would prove nothing.
///
/// ```text
/// GOAL: a commit acts on the thing that was previewed, or not at all.
/// SOURCE: plan/served.md I-6, D-50 (owner decision 3: a post by another
/// session in between forces a re-preview).
/// INVARIANTS:
/// 1. For a draft entry the payload is the entry's canonical state
/// (id, date, description, lines with their classes, party, vendor,
/// instrument, the evidence linked) and the settings seq: other
/// sessions' unrelated posts do not disturb it.
/// 2. For every other operation it is the tool, its canonical arguments
/// (without `confirmed` and `preview`), the settings seq and the
/// journal head seq: any write in between does.
/// 3. Equal payloads give equal digests whatever the key order.
/// ```
pub struct Preview;
impl Preview {
/// The digest of `payload`.
pub fn of(payload: &serde_json::Value) -> String {
ledger_store::journal::sha256_hex(ledger_store::journal::canonical_json(payload).as_bytes())
}
/// The payload of an operation without a draft: the tool, the
/// arguments it was called with (without `confirmed` and `preview`),
/// the settings seq and the journal head seq.
pub fn operation(store: &Store, tool: &str) -> serde_json::Value {
let mut args = ARGS.with(|a| a.borrow().clone()).unwrap_or_default();
args.remove("confirmed");
args.remove("preview");
args.retain(|_, v| !v.is_null());
serde_json::json!({
"tool": tool,
"args": args,
"settings_seq": store.ledger().settings().last_seq(),
"head_seq": store.head().0,
})
}
/// The preview check every tool with `confirmed` makes: the digest of
/// `payload` now. Without `confirmed` it is what the preview returns;
/// with it, `given` must equal it, or the call is refused with the
/// structured `stale_preview` guard before anything is written.
pub fn check(
tool: &str,
payload: &serde_json::Value,
confirmed: bool,
given: Option<&str>,
) -> Result<String, String> {
let now = Self::of(payload);
if !confirmed {
return Ok(now);
}
let message = match given.map(str::trim) {
None | Some("") => format!(
"{tool} with confirmed=true needs the preview of what is being confirmed: call {tool} without confirmed, show it to the human, then pass its preview"
),
Some(g) if g == now => return Ok(now),
Some(_) => format!(
"the books or the arguments changed since {tool} was previewed (a post by another session, a changed setting or argument): call {tool} without confirmed again and confirm that preview"
),
};
Err(serde_json::json!({
"code": "stale_preview",
"message": message,
"remedy_tool": tool,
})
.to_string())
}
}
impl BooksServer {
/// [`Preview::check`] for an operation without a draft: the payload is
/// built from the tool, its arguments and the books' seqs.
pub(crate) fn preview_of(
&self,
store: &Store,
tool: &str,
confirmed: bool,
given: Option<&str>,
) -> Result<String, String> {
Preview::check(tool, &Preview::operation(store, tool), confirmed, given)
}
}
+8
View File
@@ -1201,6 +1201,7 @@ impl BooksServer {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
let mut out = PurgeTinOut { let mut out = PurgeTinOut {
preview: None,
status: "preview".into(), status: "preview".into(),
party_id: id, party_id: id,
name: party.name.clone(), name: party.name.clone(),
@@ -1227,6 +1228,13 @@ impl BooksServer {
} }
), ),
}; };
let digest = self.preview_of(
&store,
"books.purge_tin",
p.confirmed.unwrap_or(false),
p.preview.as_deref(),
)?;
out.preview = Some(digest);
if !p.confirmed.unwrap_or(false) { if !p.confirmed.unwrap_or(false) {
return Ok(Json(out)); return Ok(Json(out));
} }
+8
View File
@@ -94,6 +94,12 @@ impl BooksServer {
members, members,
}) })
}; };
let digest = self.preview_of(
&store,
"books.close_period",
p.confirmed,
p.preview.as_deref(),
)?;
if !p.confirmed { if !p.confirmed {
// Preview: run the real close on a copy of the ledger, so the // Preview: run the real close on a copy of the ledger, so the
// preview is exactly what confirming would post. // preview is exactly what confirming would post.
@@ -107,6 +113,7 @@ impl BooksServer {
closing_entry: None, closing_entry: None,
lines: describe(&scratch, closing), lines: describe(&scratch, closing),
members: split(&scratch), members: split(&scratch),
preview: Some(digest),
})); }));
} }
let closing = store let closing = store
@@ -119,6 +126,7 @@ impl BooksServer {
closing_entry: closing, closing_entry: closing,
lines, lines,
members: split(store.ledger()), members: split(store.ledger()),
preview: Some(digest),
})) }))
} }
@@ -266,7 +266,10 @@ impl BooksServer {
cleared_lines, cleared_lines,
problems, problems,
reconciliation: None, reconciliation: None,
preview: None,
}; };
let digest = self.preview_of(&store, "books.reconcile", confirmed, p.preview.as_deref())?;
out.preview = Some(digest);
if !confirmed { if !confirmed {
return Ok(Json(out)); return Ok(Json(out));
} }
+147 -16
View File
@@ -26,11 +26,14 @@ fn control_problems(out: &ControlCheckOut) -> Vec<String> {
v v
} }
/// Net movement of one account over entries in `[from, to]`, normal side. /// An account's net movement over `entries` in its normal direction.
fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal { fn movement_over(
let movement: Decimal = ledger ledger: &Ledger,
.posted_entries() account: AccountId,
.filter(|e| e.date() >= from && e.date() <= to) entries: &[&ledger_core::JournalEntry],
) -> Decimal {
let movement: Decimal = entries
.iter()
.flat_map(|e| e.lines()) .flat_map(|e| e.lines())
.filter(|l| l.account == account) .filter(|l| l.account == account)
.map(|l| match l.side { .map(|l| match l.side {
@@ -83,6 +86,7 @@ fn cash_flow_out(stmt: &ledger_core::CashFlowStatement) -> CashFlowOut {
}; };
let r = &stmt.operating_reconciliation; let r = &stmt.operating_reconciliation;
CashFlowOut { CashFlowOut {
as_of_status: None,
method: "direct".to_string(), method: "direct".to_string(),
start: stmt.start.to_string(), start: stmt.start.to_string(),
end: stmt.end.to_string(), end: stmt.end.to_string(),
@@ -278,8 +282,10 @@ impl BooksServer {
) -> Result<Json<TrialBalanceOut>, String> { ) -> Result<Json<TrialBalanceOut>, String> {
let store = self.lock()?; let store = self.lock()?;
let as_of = self.as_of(params.0.as_of.as_deref())?; let as_of = self.as_of(params.0.as_of.as_deref())?;
let as_of_status = self.report_status(&store, as_of, params.0.strict.unwrap_or(false))?;
let tb = reports::trial_balance_as_of(store.ledger(), as_of); let tb = reports::trial_balance_as_of(store.ledger(), as_of);
Ok(Json(TrialBalanceOut { Ok(Json(TrialBalanceOut {
as_of_status,
as_of: as_of.to_string(), as_of: as_of.to_string(),
lines: tb lines: tb
.lines .lines
@@ -322,7 +328,9 @@ impl BooksServer {
p.end.as_deref(), p.end.as_deref(),
class_id, class_id,
)?; )?;
let as_of_status = self.report_status(&store, pl.end, p.strict.unwrap_or(false))?;
Ok(Json(ProfitAndLossOut { Ok(Json(ProfitAndLossOut {
as_of_status,
start: pl.start.to_string(), start: pl.start.to_string(),
end: pl.end.to_string(), end: pl.end.to_string(),
revenue: money(pl.total_revenue), revenue: money(pl.total_revenue),
@@ -349,7 +357,9 @@ impl BooksServer {
let p = params.0; let p = params.0;
let store = self.lock()?; let store = self.lock()?;
let bs = self.bs_statement(store.ledger(), p.as_of.as_deref())?; let bs = self.bs_statement(store.ledger(), p.as_of.as_deref())?;
let as_of_status = self.report_status(&store, bs.as_of, p.strict.unwrap_or(false))?;
Ok(Json(BalanceSheetOut { Ok(Json(BalanceSheetOut {
as_of_status,
as_of: bs.as_of.to_string(), as_of: bs.as_of.to_string(),
assets: money(bs.total_assets), assets: money(bs.total_assets),
liabilities: money(bs.total_liabilities), liabilities: money(bs.total_liabilities),
@@ -386,7 +396,10 @@ impl BooksServer {
&p.financing_accounts, &p.financing_accounts,
&p.investing_accounts, &p.investing_accounts,
)?; )?;
Ok(Json(cash_flow_out(&stmt))) let as_of_status = self.report_status(&store, stmt.end, p.strict.unwrap_or(false))?;
let mut out = cash_flow_out(&stmt);
out.as_of_status = as_of_status;
Ok(Json(out))
} }
#[tool( #[tool(
@@ -758,19 +771,36 @@ impl BooksServer {
params: Parameters<DigestParams>, params: Parameters<DigestParams>,
) -> Result<Json<DigestOut>, String> { ) -> Result<Json<DigestOut>, String> {
let p = params.0; let p = params.0;
let since = parse_date(&p.since)?; let since = p.since.as_deref().map(parse_date).transpose()?;
if since.is_none() && p.since_seq.is_none() {
return Err("give since (a date) or since_seq (a journal sequence number)".into());
}
let today = today(); let today = today();
let as_of = p.as_of.as_deref().map(parse_date).transpose()?;
let end = as_of.unwrap_or(today);
let store = self.lock()?; let store = self.lock()?;
let ledger = store.ledger(); let ledger = store.ledger();
// The entries: after a journal line (by when they were posted,
// backdated ones included), else by their date.
let selected: Vec<&ledger_core::JournalEntry> = ledger
.posted_entries()
.filter(|e| match p.since_seq {
Some(n) => {
ledger.posted_seq(e.id()).is_some_and(|s| s > n)
&& as_of.is_none_or(|d| e.date() <= d)
}
None => since.is_some_and(|s| e.date() >= s) && as_of.is_none_or(|d| e.date() <= d),
})
.collect();
let mut entries = Vec::new(); let mut entries = Vec::new();
for e in ledger.posted_entries() { for e in &selected {
if e.date() < since {
continue;
}
let receipts = store let receipts = store
.list_attachments(Some(e.id())) .list_attachments(Some(e.id()))
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let required = self.settings().policy.receipt_required(ledger, e); // The one function that decides (plan P4, I-5): what posted
// an entry is part of the answer.
let required =
ledger_core::evidence::requirement(ledger, e, ledger.settings()).is_required();
entries.push(DigestEntry { entries.push(DigestEntry {
entry_id: e.id(), entry_id: e.id(),
date: e.date().to_string(), date: e.date().to_string(),
@@ -783,20 +813,40 @@ impl BooksServer {
}); });
} }
entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id))); entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id)));
// Movement over the entries asked for (by date: those dated from
// `since` through the end, as before).
let in_range: Vec<&ledger_core::JournalEntry> = if p.since_seq.is_some() {
selected.clone()
} else {
selected
.iter()
.copied()
.filter(|e| e.date() <= end)
.collect()
};
let touched: std::collections::BTreeSet<AccountId> = in_range
.iter()
.flat_map(|e| e.lines())
.map(|l| l.account)
.collect();
let mut totals = Vec::new(); let mut totals = Vec::new();
let mut zero_net_accounts = Vec::new();
for a in ledger.chart().all() { for a in ledger.chart().all() {
if a.placeholder { if a.placeholder {
continue; continue;
} }
let m = movement_in(ledger, a.id, since, today); let m = movement_over(ledger, a.id, &in_range);
if !m.is_zero() { if !m.is_zero() {
totals.push(DigestTotal { totals.push(DigestTotal {
code: a.code.clone(), code: a.code.clone(),
name: a.name.clone(), name: a.name.clone(),
movement: money(m), movement: money(m),
}); });
} else if touched.contains(&a.id) {
zero_net_accounts.push(format!("{} {}", a.code, a.name));
} }
} }
zero_net_accounts.sort();
totals.sort_by(|a, b| a.code.cmp(&b.code)); totals.sort_by(|a, b| a.code.cmp(&b.code));
let stale_rules = ledger_core::rules::stale_rules(today) let stale_rules = ledger_core::rules::stale_rules(today)
.into_iter() .into_iter()
@@ -811,7 +861,9 @@ impl BooksServer {
.tail_repairs() .tail_repairs()
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.into_iter() .into_iter()
.filter(|(at, _, _)| at.get(..10).is_some_and(|d| d >= p.since.as_str())) .filter(|(at, _, _)| {
since.is_none_or(|s| at.get(..10).is_some_and(|d| d >= s.to_string().as_str()))
})
// The stamp only: the file's name would name the journal. // The stamp only: the file's name would name the journal.
.map(|(at, bytes, saved_as)| JournalRepair { .map(|(at, bytes, saved_as)| JournalRepair {
at, at,
@@ -832,11 +884,40 @@ impl BooksServer {
today, today,
&ledger.control_check(today).map_err(|e| e.to_string())?, &ledger.control_check(today).map_err(|e| e.to_string())?,
)); ));
let mut drafts_by_state = std::collections::BTreeMap::new();
for e in ledger.drafts() {
*drafts_by_state
.entry(e.draft_state().key().to_string())
.or_insert(0u64) += 1;
}
let with_statement: std::collections::BTreeSet<AccountId> = store
.statements(None)
.map_err(|e| e.to_string())?
.iter()
.map(|s| s.account_id)
.collect();
let (mut reconciliation_tracked, mut never_reconciled) = (Vec::new(), Vec::new());
for id in ledger_store::reconcilable::cash_accounts(ledger, &self.settings().roles.cash) {
let code = code_name(&store, id).0;
if with_statement.contains(&id) {
reconciliation_tracked.push(code);
} else {
never_reconciled.push(code);
}
}
reconciliation_tracked.sort();
never_reconciled.sort();
Ok(Json(DigestOut { Ok(Json(DigestOut {
since_seq: p.since_seq,
head_seq: store.head().0,
zero_net_accounts,
drafts_by_state,
reconciliation_tracked,
never_reconciled,
unreconciled_accounts, unreconciled_accounts,
uncategorized_accounts, uncategorized_accounts,
control_problems, control_problems,
since: since.to_string(), since: since.map(|d| d.to_string()).unwrap_or_default(),
entries, entries,
totals_by_account: totals, totals_by_account: totals,
stale_rules, stale_rules,
@@ -946,6 +1027,7 @@ impl BooksServer {
let store = self.lock()?; let store = self.lock()?;
let ledger = store.ledger(); let ledger = store.ledger();
let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?;
let as_of_status = self.report_status(&store, end, p.strict.unwrap_or(false))?;
// One class, if asked (validated like every reference). // One class, if asked (validated like every reference).
let only = match &p.class { let only = match &p.class {
Some(c) => Some(resolve_class(&store, c)?), Some(c) => Some(resolve_class(&store, c)?),
@@ -1018,6 +1100,7 @@ impl BooksServer {
}) })
.collect(); .collect();
Ok(Json(ProfitAndLossByClassOut { Ok(Json(ProfitAndLossByClassOut {
as_of_status,
start: start.to_string(), start: start.to_string(),
end: end.to_string(), end: end.to_string(),
classes, classes,
@@ -1063,7 +1146,55 @@ impl BooksServer {
) -> Result<Json<ExportCpaPackageOut>, String> { ) -> Result<Json<ExportCpaPackageOut>, String> {
let p = params.0; let p = params.0;
let as_of = self.as_of(p.as_of.as_deref())?; let as_of = self.as_of(p.as_of.as_deref())?;
let store = self.lock()?; let mut store = self.lock()?;
// A package with a draft missing from its figures is not final
// (plan P4, 4.8): refused unless the human says why it goes anyway,
// and that is journaled as a decision.
let open_drafts: Vec<(u64, String)> = store
.ledger()
.drafts()
.into_iter()
.filter(|e| e.date() <= as_of)
.map(|e| (e.id(), e.description().to_string()))
.collect();
if !open_drafts.is_empty() {
let named: Vec<String> = open_drafts
.iter()
.map(|(id, d)| format!("#{id} {d}"))
.collect();
let Some(allow) = p.allow_open_drafts.as_ref() else {
return Err(serde_json::json!({
"code": "open_drafts",
"message": format!(
"{} draft(s) dated on or before {as_of} are open and missing from the package's figures: {}. Post them (books.post_entry) or discard them (books.discard_draft), or pass allow_open_drafts with the reason the package goes anyway",
open_drafts.len(),
named.join("; ")
),
"remedy_tool": "books.list_drafts",
})
.to_string());
};
let by = CALLER
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string());
store
.record_decision(&ledger_core::conventions::NewDecision {
subject: format!("CPA package as of {as_of} with open drafts"),
decision_md: format!(
"The CPA package as of {as_of} was exported with {} draft(s) open: {}. Reason: {}",
open_drafts.len(),
named.join("; "),
allow.reason.trim()
),
decided_by: vec![by],
decided_on: today(),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: Vec::new(),
})
.map_err(|e| e.to_string())?;
}
let book = book_name(&store); let book = book_name(&store);
// The vault, opened once and never creating a key: locked, each // The vault, opened once and never creating a key: locked, each
// TIN row says so and the package is built anyway (INVARIANT 3). // TIN row says so and the package is built anyway (INVARIANT 3).
+8
View File
@@ -188,6 +188,12 @@ impl BooksServer {
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.1) .1)
}; };
let digest = self.preview_of(
&store,
"books.run_schedules",
p.confirmed,
p.preview.as_deref(),
)?;
if !p.confirmed { if !p.confirmed {
let postings = due let postings = due
.into_iter() .into_iter()
@@ -205,6 +211,7 @@ impl BooksServer {
status: "preview".to_string(), status: "preview".to_string(),
postings, postings,
skipped, skipped,
preview: Some(digest),
})); }));
} }
let mut postings = Vec::new(); let mut postings = Vec::new();
@@ -225,6 +232,7 @@ impl BooksServer {
status: "posted".to_string(), status: "posted".to_string(),
postings, postings,
skipped, skipped,
preview: Some(digest),
})) }))
} }
+43 -3
View File
@@ -296,6 +296,7 @@ fn payment_out(
_ => (None, None), _ => (None, None),
}; };
Ok(Json(PaymentOut { Ok(Json(PaymentOut {
preview: None,
entry_id, entry_id,
documents, documents,
deposited: money(deposited), deposited: money(deposited),
@@ -305,6 +306,20 @@ fn payment_out(
})) }))
} }
/// A payment's preview: the figures the settled documents would show
/// (entry 0: nothing is posted), with the digest to confirm it by.
fn payment_preview(
probe: &Ledger,
settle: &ledger_core::SettleParams,
warnings: Vec<String>,
digest: String,
) -> Result<Json<PaymentOut>, String> {
let mut out = payment_out(probe, 0, settle)?.0;
out.warnings = warnings;
out.preview = Some(digest);
Ok(Json(out))
}
#[tool_router(router = settlements_router, vis = "pub(super)")] #[tool_router(router = settlements_router, vis = "pub(super)")]
impl BooksServer { impl BooksServer {
#[tool( #[tool(
@@ -324,9 +339,25 @@ impl BooksServer {
let p = params.0; let p = params.0;
let mut store = self.lock()?; let mut store = self.lock()?;
let (settle, warnings) = settlement(&store, &p, true)?; let (settle, warnings) = settlement(&store, &p, true)?;
let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?; let confirmed = p.confirmed.unwrap_or(false);
let digest = self.preview_of(
&store,
"books.receive_payment",
confirmed,
p.preview.as_deref(),
)?;
let entry_id = if confirmed {
store.settle(settle.clone()).map_err(|e| e.to_string())?
} else {
// The engine on a clone: every refusal the post would give,
// and the figures it would leave. Nothing is written.
let mut probe = store.ledger().clone();
probe.settle(settle.clone()).map_err(|e| e.to_string())?;
return payment_preview(&probe, &settle, warnings, digest);
};
let mut out = payment_out(store.ledger(), entry_id, &settle)?.0; let mut out = payment_out(store.ledger(), entry_id, &settle)?.0;
out.warnings = warnings; out.warnings = warnings;
out.preview = Some(digest);
Ok(Json(out)) Ok(Json(out))
} }
@@ -346,9 +377,18 @@ impl BooksServer {
) -> Result<Json<PaymentOut>, String> { ) -> Result<Json<PaymentOut>, String> {
let p = params.0; let p = params.0;
let mut store = self.lock()?; let mut store = self.lock()?;
let (settle, _) = settlement(&store, &p, false)?; let (settle, warnings) = settlement(&store, &p, false)?;
let confirmed = p.confirmed.unwrap_or(false);
let digest = self.preview_of(&store, "books.pay_bill", confirmed, p.preview.as_deref())?;
if !confirmed {
let mut probe = store.ledger().clone();
probe.settle(settle.clone()).map_err(|e| e.to_string())?;
return payment_preview(&probe, &settle, warnings, digest);
}
let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?; let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?;
payment_out(store.ledger(), entry_id, &settle) let mut out = payment_out(store.ledger(), entry_id, &settle)?.0;
out.preview = Some(digest);
Ok(Json(out))
} }
#[tool( #[tool(
+10 -1
View File
@@ -18,6 +18,7 @@ fn write_off_out(
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| format!("invoice {invoice} not found"))?; .ok_or_else(|| format!("invoice {invoice} not found"))?;
Ok(WriteOffInvoiceOut { Ok(WriteOffInvoiceOut {
preview: None,
entry_id, entry_id,
document: WriteOffDocumentOut { document: WriteOffDocumentOut {
r#ref: format!("invoice:{invoice}"), r#ref: format!("invoice:{invoice}"),
@@ -77,6 +78,12 @@ impl BooksServer {
} }
_ => {} _ => {}
} }
let digest = self.preview_of(
&store,
"books.write_off_invoice",
p.confirmed.unwrap_or(false),
p.preview.as_deref(),
)?;
if !p.confirmed.unwrap_or(false) { if !p.confirmed.unwrap_or(false) {
// The engine on a clone: every refusal the post would give, and // The engine on a clone: every refusal the post would give, and
// the figures the write-off would leave. Nothing is written. // the figures the write-off would leave. Nothing is written.
@@ -84,7 +91,9 @@ impl BooksServer {
probe probe
.write_off(p.invoice, date, amount, &p.reason) .write_off(p.invoice, date, amount, &p.reason)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
return Ok(Json(write_off_out(&probe, p.invoice, 0)?)); let mut out = write_off_out(&probe, p.invoice, 0)?;
out.preview = Some(digest);
return Ok(Json(out));
} }
let entry_id = match p.upload_id.as_deref() { let entry_id = match p.upload_id.as_deref() {
Some(upload_id) => { Some(upload_id) => {
+337 -8
View File
@@ -191,6 +191,11 @@ pub struct ListSchedulesOut {
/// Parameters for `books.run_schedules`. /// Parameters for `books.run_schedules`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct RunSchedulesParams { pub struct RunSchedulesParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// Post months ending on or before this date (default today). /// Post months ending on or before this date (default today).
pub through: Option<String>, pub through: Option<String>,
/// Without true, a preview: nothing posts. /// Without true, a preview: nothing posts.
@@ -227,6 +232,10 @@ pub struct ScheduleSkippedOut {
/// Result of `books.run_schedules`. /// Result of `books.run_schedules`.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct RunSchedulesOut { pub struct RunSchedulesOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// "preview" or "posted". /// "preview" or "posted".
pub status: String, pub status: String,
/// Postings due (preview) or made. /// Postings due (preview) or made.
@@ -1000,6 +1009,12 @@ pub struct TrialBalanceLineOut {
/// Parameters for `books.trial_balance`. /// Parameters for `books.trial_balance`.
#[derive(Debug, Default, Deserialize, JsonSchema)] #[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct TrialBalanceParams { pub struct TrialBalanceParams {
/// True: refuse the report, naming what is open, while a draft dated
/// on or before its end, an unreconciled account or a settings
/// problem makes its figures not final (`as_of_status`). Default:
/// the report is given, with `as_of_status` as the warning.
#[serde(default)]
pub strict: Option<bool>,
/// As of this day ([`AS_OF_DOC`]). /// As of this day ([`AS_OF_DOC`]).
#[serde(default)] #[serde(default)]
#[schemars(description = AS_OF_DOC)] #[schemars(description = AS_OF_DOC)]
@@ -1009,6 +1024,10 @@ pub struct TrialBalanceParams {
/// Result of `books.trial_balance`. /// Result of `books.trial_balance`.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct TrialBalanceOut { pub struct TrialBalanceOut {
/// What is open as of the report's end: drafts dated in it,
/// unreconciled accounts, settings problems (plan P4).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub as_of_status: Option<AsOfStatusOut>,
/// The as-of date, YYYY-MM-DD. /// The as-of date, YYYY-MM-DD.
pub as_of: String, pub as_of: String,
/// Lines in code order. /// Lines in code order.
@@ -1024,6 +1043,12 @@ pub struct TrialBalanceOut {
/// Parameters for `books.profit_and_loss`. /// Parameters for `books.profit_and_loss`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct ProfitAndLossParams { pub struct ProfitAndLossParams {
/// True: refuse the report, naming what is open, while a draft dated
/// on or before its end, an unreconciled account or a settings
/// problem makes its figures not final (`as_of_status`). Default:
/// the report is given, with `as_of_status` as the warning.
#[serde(default)]
pub strict: Option<bool>,
/// Period start ([`START_DOC`]). /// Period start ([`START_DOC`]).
#[schemars(description = START_DOC)] #[schemars(description = START_DOC)]
pub start: Option<String>, pub start: Option<String>,
@@ -1039,6 +1064,10 @@ pub struct ProfitAndLossParams {
/// Result of `books.profit_and_loss`. /// Result of `books.profit_and_loss`.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct ProfitAndLossOut { pub struct ProfitAndLossOut {
/// What is open as of the report's end: drafts dated in it,
/// unreconciled accounts, settings problems (plan P4).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub as_of_status: Option<AsOfStatusOut>,
/// Period start, YYYY-MM-DD. /// Period start, YYYY-MM-DD.
pub start: String, pub start: String,
/// Period end, YYYY-MM-DD. /// Period end, YYYY-MM-DD.
@@ -1069,6 +1098,12 @@ pub struct StatementLineOut {
/// Parameters for `books.cash_flow`. /// Parameters for `books.cash_flow`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct CashFlowParams { pub struct CashFlowParams {
/// True: refuse the report, naming what is open, while a draft dated
/// on or before its end, an unreconciled account or a settings
/// problem makes its figures not final (`as_of_status`). Default:
/// the report is given, with `as_of_status` as the warning.
#[serde(default)]
pub strict: Option<bool>,
/// Period start ([`START_DOC`]). /// Period start ([`START_DOC`]).
#[schemars(description = START_DOC)] #[schemars(description = START_DOC)]
pub start: Option<String>, pub start: Option<String>,
@@ -1229,6 +1264,10 @@ pub struct CashFlowWarningOut {
/// Result of `books.cash_flow`; `books.export` writes the same JSON. /// Result of `books.cash_flow`; `books.export` writes the same JSON.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowOut { pub struct CashFlowOut {
/// What is open as of the report's end: drafts dated in it,
/// unreconciled accounts, settings problems (plan P4).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub as_of_status: Option<AsOfStatusOut>,
/// "direct" (ASC 230-10-45-25). /// "direct" (ASC 230-10-45-25).
pub method: String, pub method: String,
/// Period start, YYYY-MM-DD. /// Period start, YYYY-MM-DD.
@@ -1358,6 +1397,12 @@ pub struct ImportChartCsvOut {
/// Parameters for `books.balance_sheet`. /// Parameters for `books.balance_sheet`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct BalanceSheetParams { pub struct BalanceSheetParams {
/// True: refuse the report, naming what is open, while a draft dated
/// on or before its end, an unreconciled account or a settings
/// problem makes its figures not final (`as_of_status`). Default:
/// the report is given, with `as_of_status` as the warning.
#[serde(default)]
pub strict: Option<bool>,
/// As of this day ([`AS_OF_DOC`]). /// As of this day ([`AS_OF_DOC`]).
#[schemars(description = AS_OF_DOC)] #[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>, pub as_of: Option<String>,
@@ -1366,6 +1411,10 @@ pub struct BalanceSheetParams {
/// Result of `books.balance_sheet`. /// Result of `books.balance_sheet`.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct BalanceSheetOut { pub struct BalanceSheetOut {
/// What is open as of the report's end: drafts dated in it,
/// unreconciled accounts, settings problems (plan P4).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub as_of_status: Option<AsOfStatusOut>,
/// As of this date, YYYY-MM-DD. /// As of this date, YYYY-MM-DD.
pub as_of: String, pub as_of: String,
/// Total assets, decimal string. /// Total assets, decimal string.
@@ -1561,6 +1610,11 @@ pub struct PreviewReceipt {
/// Parameters for `books.post_entry`. /// Parameters for `books.post_entry`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct PostEntryParams { pub struct PostEntryParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// Draft entry id. /// Draft entry id.
pub entry_id: u64, pub entry_id: u64,
/// Pass true to post after reviewing the preview. Default false: returns /// Pass true to post after reviewing the preview. Default false: returns
@@ -1574,6 +1628,10 @@ pub struct PostEntryParams {
/// Result of `books.post_entry`: either a preview or a posted confirmation. /// Result of `books.post_entry`: either a preview or a posted confirmation.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct PostEntryOut { pub struct PostEntryOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// "preview" or "posted". /// "preview" or "posted".
pub status: String, pub status: String,
/// The entry id. /// The entry id.
@@ -1652,6 +1710,11 @@ pub struct PartySuggestionOut {
/// Parameters for `books.reverse_entry`. /// Parameters for `books.reverse_entry`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct ReverseEntryParams { pub struct ReverseEntryParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// Id of the posted entry to reverse. /// Id of the posted entry to reverse.
pub entry_id: u64, pub entry_id: u64,
/// Date of the reversing entry, YYYY-MM-DD. /// Date of the reversing entry, YYYY-MM-DD.
@@ -1665,6 +1728,10 @@ pub struct ReverseEntryParams {
/// Result of `books.reverse_entry`. /// Result of `books.reverse_entry`.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct ReverseEntryOut { pub struct ReverseEntryOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// "preview" or "posted". /// "preview" or "posted".
pub status: String, pub status: String,
/// The new reversing entry's id. /// The new reversing entry's id.
@@ -2040,8 +2107,19 @@ pub struct SearchEntriesOut {
/// Parameters for `books.digest`. /// Parameters for `books.digest`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct DigestParams { pub struct DigestParams {
/// Include entries posted on or after this date, YYYY-MM-DD. /// Include entries dated on or after this date, YYYY-MM-DD (by entry
pub since: String, /// date). Give this or `since_seq`.
#[serde(default)]
pub since: Option<String>,
/// Include the entries posted after this journal sequence number: what
/// was posted since a session's head (`head.seq` of an earlier answer),
/// backdated entries included, which `since` by date would miss.
#[serde(default)]
pub since_seq: Option<u64>,
/// Count movement through this day ([`AS_OF_DOC`]); default today.
#[serde(default)]
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
} }
/// One entry in the digest. /// One entry in the digest.
@@ -2080,8 +2158,27 @@ pub struct DigestTotal {
/// Result of `books.digest`: what changed, for human review. /// Result of `books.digest`: what changed, for human review.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct DigestOut { pub struct DigestOut {
/// The since date, YYYY-MM-DD. /// The since date, YYYY-MM-DD (empty when asked by `since_seq`).
pub since: String, pub since: String,
/// The journal sequence number the entries are after, when asked by
/// `since_seq`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since_seq: Option<u64>,
/// The journal's head now: pass its `seq` as `since_seq` next time.
pub head_seq: u64,
/// Accounts the entries touched whose net movement is zero ("CODE
/// Name"): they are not in totals_by_account, but something moved in
/// them and back.
pub zero_net_accounts: Vec<String>,
/// How many drafts are open, by state (`building`,
/// `awaiting_evidence`).
pub drafts_by_state: std::collections::BTreeMap<String, u64>,
/// Cash and card accounts (books.cash_accounts) that have a statement
/// recorded, by code.
pub reconciliation_tracked: Vec<String>,
/// Cash and card accounts with no statement ever recorded, by code:
/// reconciliation has not started for them.
pub never_reconciled: Vec<String>,
/// Entries posted in the period, newest first. /// Entries posted in the period, newest first.
pub entries: Vec<DigestEntry>, pub entries: Vec<DigestEntry>,
/// Net movement per account over the period. /// Net movement per account over the period.
@@ -2117,7 +2214,7 @@ pub struct DigestOut {
} }
/// An account due for reconciliation (`books.digest`). /// An account due for reconciliation (`books.digest`).
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct UnreconciledAccountOut { pub struct UnreconciledAccountOut {
/// Account code. /// Account code.
pub code: String, pub code: String,
@@ -2741,6 +2838,11 @@ pub struct SkippedDepreciation {
/// Parameters for `books.dispose_asset`. /// Parameters for `books.dispose_asset`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct DisposeAssetParams { pub struct DisposeAssetParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// Asset id (or pass `tag`). /// Asset id (or pass `tag`).
pub asset_id: Option<u64>, pub asset_id: Option<u64>,
/// The asset's tag, instead of its id. /// The asset's tag, instead of its id.
@@ -2771,6 +2873,10 @@ pub struct DisposeAssetParams {
/// Result of `books.dispose_asset`. /// Result of `books.dispose_asset`.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct DisposeAssetOut { pub struct DisposeAssetOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// "preview" or "posted". /// "preview" or "posted".
pub status: String, pub status: String,
/// Asset id. /// Asset id.
@@ -2864,6 +2970,11 @@ pub struct CreatePeriodOut {
/// Parameters for `books.close_period`. /// Parameters for `books.close_period`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ClosePeriodParams { pub struct ClosePeriodParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// The period to close. /// The period to close.
pub period_id: u64, pub period_id: u64,
/// Without confirmed=true, returns a preview (closing lines) and closes /// Without confirmed=true, returns a preview (closing lines) and closes
@@ -2875,6 +2986,10 @@ pub struct ClosePeriodParams {
/// Output of `books.close_period`. /// Output of `books.close_period`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ClosePeriodOut { pub struct ClosePeriodOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// "preview" or "closed". /// "preview" or "closed".
pub status: String, pub status: String,
/// The period id. /// The period id.
@@ -2983,6 +3098,11 @@ pub struct MarkClearedOut {
/// `statement_balance` for a check against any balance. /// `statement_balance` for a check against any balance.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReconcileParams { pub struct ReconcileParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// Account id or code (e.g. "1000") to reconcile. With /// Account id or code (e.g. "1000") to reconcile. With
/// statement_attachment, optional (it must be the statement's). /// statement_attachment, optional (it must be the statement's).
pub account: Option<String>, pub account: Option<String>,
@@ -3021,6 +3141,10 @@ pub struct ReconcileLineOut {
/// Output of `books.reconcile`. /// Output of `books.reconcile`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReconcileOut { pub struct ReconcileOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// "preview" (nothing recorded) or "reconciled" (recorded). /// "preview" (nothing recorded) or "reconciled" (recorded).
pub status: String, pub status: String,
/// Account code. /// Account code.
@@ -3134,6 +3258,11 @@ pub struct BillToParam {
/// `confirmed`, and posts with it (A-1). /// `confirmed`, and posts with it (A-1).
#[derive(Debug, Clone, Deserialize, JsonSchema)] #[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct CreateInvoiceParams { pub struct CreateInvoiceParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// The customer: a customer id, its party's id, or its name. A name /// The customer: a customer id, its party's id, or its name. A name
/// that two customers answer to is refused. /// that two customers answer to is refused.
pub customer: Option<String>, pub customer: Option<String>,
@@ -3164,6 +3293,11 @@ pub struct CreateInvoiceParams {
/// and posts with it. /// and posts with it.
#[derive(Debug, Clone, Deserialize, JsonSchema)] #[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct CreateBillParams { pub struct CreateBillParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// The vendor: a vendor id, its party's id, or its name. A name that /// The vendor: a vendor id, its party's id, or its name. A name that
/// two vendors answer to is refused. /// two vendors answer to is refused.
pub vendor: Option<String>, pub vendor: Option<String>,
@@ -3240,6 +3374,15 @@ pub struct FeeParam {
/// Parameters for `books.receive_payment` / `books.pay_bill`. /// Parameters for `books.receive_payment` / `books.pay_bill`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PaymentParams { pub struct PaymentParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// Pass true to post after reviewing the preview. Without it, a
/// preview: nothing posts.
#[serde(default)]
pub confirmed: Option<bool>,
/// Invoice id (receive_payment) or bill id (pay_bill); the older name /// Invoice id (receive_payment) or bill id (pay_bill); the older name
/// of `invoice` / `bill`. /// of `invoice` / `bill`.
#[serde(default)] #[serde(default)]
@@ -3311,6 +3454,10 @@ pub struct PaidDocumentOut {
/// Output of `books.receive_payment` / `books.pay_bill`. /// Output of `books.receive_payment` / `books.pay_bill`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PaymentOut { pub struct PaymentOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// The payment journal entry's id. /// The payment journal entry's id.
pub entry_id: u64, pub entry_id: u64,
/// Every document it settled, in application order. /// Every document it settled, in application order.
@@ -4165,6 +4312,10 @@ pub struct ClassPnlOut {
/// Output of `books.profit_and_loss_by_class`. /// Output of `books.profit_and_loss_by_class`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ProfitAndLossByClassOut { pub struct ProfitAndLossByClassOut {
/// What is open as of the report's end: drafts dated in it,
/// unreconciled accounts, settings problems (plan P4).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub as_of_status: Option<AsOfStatusOut>,
/// Period start, YYYY-MM-DD. /// Period start, YYYY-MM-DD.
pub start: String, pub start: String,
/// Period end, YYYY-MM-DD. /// Period end, YYYY-MM-DD.
@@ -4180,6 +4331,12 @@ pub struct ProfitAndLossByClassOut {
/// Parameters for `books.export_cpa_package`. /// Parameters for `books.export_cpa_package`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ExportCpaPackageParams { pub struct ExportCpaPackageParams {
/// Hand the package over although drafts dated on or before `as_of`
/// are still open, and say why (journaled as a decision). Without it
/// the export is refused while any is open: a package with a draft
/// missing from its figures is not the CPA's final.
#[serde(default)]
pub allow_open_drafts: Option<AllowOpenDraftsParam>,
/// As of this day ([`AS_OF_DOC`]); the period reports cover the /// As of this day ([`AS_OF_DOC`]); the period reports cover the
/// fiscal year that holds it, through it. /// fiscal year that holds it, through it.
#[schemars(description = AS_OF_DOC)] #[schemars(description = AS_OF_DOC)]
@@ -4477,6 +4634,11 @@ pub struct GetW9Out {
/// Parameters for `books.purge_tin`. /// Parameters for `books.purge_tin`.
#[derive(Debug, Clone, Deserialize, JsonSchema)] #[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct PurgeTinParams { pub struct PurgeTinParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// The party: id or name. /// The party: id or name.
pub party: String, pub party: String,
/// Why (retention period over, the payee asked, a wrong TIN): /// Why (retention period over, the payee asked, a wrong TIN):
@@ -4490,6 +4652,10 @@ pub struct PurgeTinParams {
/// Result of `books.purge_tin`. /// Result of `books.purge_tin`.
#[derive(Debug, Clone, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct PurgeTinOut { pub struct PurgeTinOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// "preview" or "purged". /// "preview" or "purged".
pub status: String, pub status: String,
/// The party. /// The party.
@@ -5174,9 +5340,11 @@ pub struct IssuedFileOut {
/// posted invoice. /// posted invoice.
#[derive(Debug, Clone, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct InvoiceOut { pub struct InvoiceOut {
/// True: a preview, and nothing was posted. /// The digest of what this shows (SHA-256 of its canonical preview):
/// present on a preview, which posted nothing; pass it back as
/// `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<bool>, pub preview: Option<String>,
/// The invoice's id (confirm). /// The invoice's id (confirm).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<u64>, pub id: Option<u64>,
@@ -5219,9 +5387,11 @@ pub struct InvoiceOut {
/// posted bill. /// posted bill.
#[derive(Debug, Clone, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct BillOut { pub struct BillOut {
/// True: a preview, and nothing was posted. /// The digest of what this shows (SHA-256 of its canonical preview):
/// present on a preview, which posted nothing; pass it back as
/// `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<bool>, pub preview: Option<String>,
/// The bill's id (confirm). /// The bill's id (confirm).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<u64>, pub id: Option<u64>,
@@ -5331,6 +5501,11 @@ pub struct MarkSentOut {
/// Parameters for `books.write_off_invoice`. /// Parameters for `books.write_off_invoice`.
#[derive(Debug, Clone, Deserialize, JsonSchema)] #[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct WriteOffInvoiceParams { pub struct WriteOffInvoiceParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// The invoice's id (books.documents). /// The invoice's id (books.documents).
pub invoice: u64, pub invoice: u64,
/// The write-off's day, YYYY-MM-DD: not before the invoice, and in an /// The write-off's day, YYYY-MM-DD: not before the invoice, and in an
@@ -5379,6 +5554,10 @@ pub struct WriteOffDocumentOut {
/// Output of `books.write_off_invoice`. /// Output of `books.write_off_invoice`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WriteOffInvoiceOut { pub struct WriteOffInvoiceOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// The write-off's journal entry's id (0 in a preview). /// The write-off's journal entry's id (0 in a preview).
pub entry_id: u64, pub entry_id: u64,
/// The invoice's derived figures after it. /// The invoice's derived figures after it.
@@ -5527,6 +5706,11 @@ pub struct DuplicateGroupOut {
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ConsolidateDuplicatesParams { pub struct ConsolidateDuplicatesParams {
/// The `preview` the call without `confirmed` returned: the digest of
/// what was previewed. Required with confirmed=true; a changed
/// argument or books since the preview is refused (stale_preview).
#[serde(default)]
pub preview: Option<String>,
/// True: consolidate. Without it, a preview that changes nothing. /// True: consolidate. Without it, a preview that changes nothing.
pub confirmed: Option<bool>, pub confirmed: Option<bool>,
} }
@@ -5534,6 +5718,10 @@ pub struct ConsolidateDuplicatesParams {
/// Result of `books.consolidate_duplicates`. /// Result of `books.consolidate_duplicates`.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct ConsolidateDuplicatesOut { pub struct ConsolidateDuplicatesOut {
/// The digest of what this shows (SHA-256 of its canonical preview):
/// pass it back as `preview` with confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// "preview" or "consolidated". /// "preview" or "consolidated".
pub status: String, pub status: String,
/// Each set of attachments holding one file's bytes. /// Each set of attachments holding one file's bytes.
@@ -5541,3 +5729,144 @@ pub struct ConsolidateDuplicatesOut {
/// How many attachments were (or would be) withdrawn. /// How many attachments were (or would be) withdrawn.
pub withdrawn: usize, pub withdrawn: usize,
} }
// ---------------------------------------------------------------------------
// P4: draft states, the evidence gate, the preview digest, the digest tool
// ---------------------------------------------------------------------------
/// Parameters for `books.list_drafts`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ListDraftsParams {
/// Only drafts in this state: `building` or `awaiting_evidence`.
#[serde(default)]
pub state: Option<String>,
/// Only drafts dated on or before this day ([`AS_OF_DOC`]). Default:
/// all.
#[serde(default)]
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
}
/// One open draft entry.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DraftOut {
/// The draft's id.
pub entry_id: u64,
/// Its date, YYYY-MM-DD.
pub date: String,
/// Its description.
pub description: String,
/// Total debits so far, decimal string ("too large" past exact
/// arithmetic).
pub total: String,
/// `building` or `awaiting_evidence`.
pub state: String,
/// What it waits for (empty when building or when none was said).
pub note: String,
/// The receipt policy covers it and it has no receipt yet.
pub missing_evidence: bool,
}
/// Result of `books.list_drafts`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListDraftsOut {
/// Open drafts, by date then id.
pub drafts: Vec<DraftOut>,
}
/// Parameters for `books.set_draft_state`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SetDraftStateParams {
/// The draft's id.
pub entry_id: u64,
/// `building` (still being worked on) or `awaiting_evidence` (finished
/// and waiting for its receipt or other document).
pub state: String,
/// What it waits for ("Figma invoice due 11/3"). Only with
/// `awaiting_evidence`.
#[serde(default)]
pub note: Option<String>,
}
/// Result of `books.set_draft_state`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SetDraftStateOut {
/// The draft's id.
pub entry_id: u64,
/// Its state now.
pub state: String,
/// Its note now.
pub note: String,
}
/// Parameters for `books.discard_draft`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DiscardDraftParams {
/// The draft's id.
pub entry_id: u64,
/// Why it will not post ("entered twice"); journaled with the discard.
pub reason: String,
/// True: discard it. Without it, a preview that changes nothing.
#[serde(default)]
pub confirmed: Option<bool>,
/// The `preview` the call without `confirmed` returned. Required with
/// confirmed=true; a changed draft or books since is refused
/// (stale_preview).
#[serde(default)]
pub preview: Option<String>,
}
/// Result of `books.discard_draft`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DiscardDraftOut {
/// "preview" or "discarded".
pub status: String,
/// The draft's id.
pub entry_id: u64,
/// Its description.
pub description: String,
/// Its total debits so far.
pub total: String,
/// Whether it held a period open, and the period's name.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub holds_open: Vec<String>,
/// The digest of what this shows: pass it back as `preview` with
/// confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
}
/// Parameters for `books.preview_entry`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PreviewEntryParams {
/// The draft's id.
pub entry_id: u64,
}
/// What a report leaves open as of its end date (`as_of_status`): shown
/// on every report so a number is not taken for final while a draft
/// dated in it waits.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AsOfStatusOut {
/// Drafts dated on or before the report's end: not in these figures,
/// but they will be.
pub drafts_open_on_or_before: Vec<DraftOut>,
/// Cash and card accounts with activity not reconciled through the
/// end of the month before the report's end.
pub unreconciled: Vec<UnreconciledAccountOut>,
/// Settings that disagree with the chart (books.settings).
pub settings_problems: Vec<String>,
}
/// `books.export_cpa_package`'s `allow_open_drafts`: hand the package over
/// with drafts dated in it still open, and say why.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AllowOpenDraftsParam {
/// Why the package goes with these drafts open ("the CPA asked for it
/// before the Figma invoice arrives"); journaled as a decision.
pub reason: String,
}
+45 -10
View File
@@ -278,10 +278,39 @@ pub fn find_with(
}; };
let money = ledger_core::parties::money_accounts(ledger, &money_cash, &financing); let money = ledger_core::parties::money_accounts(ledger, &money_cash, &financing);
let members = settings.company.party_members(ledger); let members = settings.company.party_members(ledger);
let entries = entries(books, &journal, &policy); let entries = entries(books, &journal);
let mut out = Vec::new(); let mut out = Vec::new();
// Two kinds of open draft (plan P4, R-F): one that says it waits for
// its evidence, and one still being built.
for e in entries.iter().filter(|e| e.status == EntryStatus::Draft) { for e in entries.iter().filter(|e| e.status == EntryStatus::Draft) {
if let Some(note) = &e.awaiting_evidence {
let waits = if note.is_empty() {
String::new()
} else {
format!(": {note}")
};
out.push(Attention {
severity: Severity::Todo,
kind: "draft_awaiting_evidence",
title: format!("Draft #{} awaiting receipt: {}", e.id, e.description),
detail: format!(
"#{} · {} · {}. Waiting for its evidence{waits}. It appears in no report until posted.",
e.id,
date(e.date),
dollars_or(e.amount)
),
target: Target::Entry(e.id),
prompt: format!(
"In wanderbooks, draft entry {} (\"{}\", {}) is waiting for its evidence{waits}. If the receipt has arrived, attach it, preview the entry (books.preview_entry) and post it; if it never will, say why and discard it (books.discard_draft).",
e.id,
e.description,
dollars_or(e.amount)
),
tool: Some(("books.get_entry".into(), serde_json::json!({ "entry_id": e.id }))),
});
continue;
}
out.push(Attention { out.push(Attention {
severity: Severity::Todo, severity: Severity::Todo,
kind: "draft", kind: "draft",
@@ -1362,15 +1391,14 @@ struct Entry {
parties: Vec<PartyId>, parties: Vec<PartyId>,
/// Covered by the receipt policy and has no receipt. /// Covered by the receipt policy and has no receipt.
missing_receipt: bool, missing_receipt: bool,
/// A draft that says it is finished and waits for its evidence: the
/// note it was given (plan P4).
awaiting_evidence: Option<String>,
} }
/// Every entry, with the journal-derived facts the items read: its origin /// Every entry, with the journal-derived facts the items read: its origin
/// and whether the receipt policy covers it without a receipt. /// and whether the receipt policy covers it without a receipt.
fn entries( fn entries(books: &impl PackageBooks, journal: &[JournalLine]) -> Vec<Entry> {
books: &impl PackageBooks,
journal: &[JournalLine],
policy: &ledger_config::ReceiptPolicy,
) -> Vec<Entry> {
let ledger = books.ledger(); let ledger = books.ledger();
let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new(); let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new();
for (i, line) in journal.iter().enumerate() { for (i, line) in journal.iter().enumerate() {
@@ -1405,10 +1433,9 @@ fn entries(
let evs = events.remove(&id).unwrap_or_default(); let evs = events.remove(&id).unwrap_or_default();
let origin = origin(&evs, journal, closings.contains(&id)); let origin = origin(&evs, journal, closings.contains(&id));
let amount = ledger_config::ReceiptPolicy::entry_debits(e).ok(); let amount = ledger_config::ReceiptPolicy::entry_debits(e).ok();
let covered = matches!(origin, "Journal entry" | "Recurring") // The engine decides (plan P4, I-5), as the server's gate does.
&& e.reversal_of().is_none() let covered = receipts.get(&id).copied().unwrap_or(0) == 0
&& receipts.get(&id).copied().unwrap_or(0) == 0 && ledger_core::evidence::requirement(ledger, e, ledger.settings()).is_required();
&& policy.receipt_required(ledger, e);
out.push(Entry { out.push(Entry {
id, id,
date: e.date(), date: e.date(),
@@ -1418,6 +1445,14 @@ fn entries(
origin, origin,
parties: ledger.entry_parties(id).iter().map(|p| p.party).collect(), parties: ledger.entry_parties(id).iter().map(|p| p.party).collect(),
missing_receipt: covered, missing_receipt: covered,
awaiting_evidence: match e.draft_state() {
ledger_core::DraftState::AwaitingEvidence { note }
if e.status() == EntryStatus::Draft =>
{
Some(note.clone())
}
_ => None,
},
}); });
} }
out out
+18 -13
View File
@@ -38,20 +38,10 @@ pub enum StoreError {
/// The receipt policy covers the entry and it has no receipt (plan P4, /// The receipt policy covers the entry and it has no receipt (plan P4,
/// I-5; rules.md B-43): refused before anything is journaled. /// I-5; rules.md B-43): refused before anything is journaled.
#[error( #[error(
"entry {entry_id} needs a receipt before it posts: {reason} ({policy}); attach one with books.attach_receipt" "entry {} needs a receipt before it posts: {} ({}); attach one with books.attach_receipt",
.0.entry_id, .0.reason, .0.policy
)] )]
ReceiptRequired { ReceiptRequired(Box<ReceiptRequiredInfo>),
/// The entry that needs the receipt.
entry_id: u64,
/// Its total debits, `None` when they pass exact arithmetic.
debits: Option<rust_decimal::Decimal>,
/// The policy that triggered, in words.
policy: String,
/// Why this entry is covered.
reason: String,
/// The posted entries it appears to split (anti-splitting).
split_group: Vec<u64>,
},
/// Filesystem error. /// Filesystem error.
#[error("io error: {0}")] #[error("io error: {0}")]
@@ -217,3 +207,18 @@ fn newer_journal_text(seq: u64, event_type: &str, need: &str, verified: bool) ->
) )
} }
} }
/// What a refused post tells (boxed in [`StoreError::ReceiptRequired`]).
#[derive(Debug)]
pub struct ReceiptRequiredInfo {
/// The entry that needs the receipt.
pub entry_id: u64,
/// Its total debits, `None` when they pass exact arithmetic.
pub debits: Option<rust_decimal::Decimal>,
/// The policy that triggered, in words.
pub policy: String,
/// Why this entry is covered.
pub reason: String,
/// The posted entries it appears to split (anti-splitting).
pub split_group: Vec<u64>,
}
+9 -7
View File
@@ -1549,13 +1549,15 @@ impl Store {
&& self.list_attachments(Some(entry))?.is_empty() && self.list_attachments(Some(entry))?.is_empty()
{ {
let policy = ledger_core::evidence::Policy::from_settings(self.ledger.settings()); let policy = ledger_core::evidence::Policy::from_settings(self.ledger.settings());
return Err(StoreError::ReceiptRequired { return Err(StoreError::ReceiptRequired(Box::new(
entry_id: entry, crate::error::ReceiptRequiredInfo {
debits: ledger_core::evidence::Policy::entry_debits(&e).ok(), entry_id: entry,
policy: policy.describe(), debits: ledger_core::evidence::Policy::entry_debits(&e).ok(),
split_group: policy.split_group(&self.ledger, entry, &e), policy: policy.describe(),
reason, split_group: policy.split_group(&self.ledger, entry, &e),
}); reason,
},
)));
} }
self.post_entry(entry) self.post_entry(entry)
} }
+10
View File
@@ -135,6 +135,16 @@ extractions have a table of their own. The viewer's Documents page groups
by category and filters by subject; Needs attention reads both through by category and filters by subject; Needs attention reads both through
`PackageBooks`. `PackageBooks`.
**The evidence gate (P4, I-5).** `ledger_core::evidence::requirement`
(with `Policy`, the settings' `policy.evidence.*` read in the core) is the
one decision whether an entry needs a receipt; `Store::post_entry_checked`,
the server's previews and digest, the viewer and Needs attention all call
it, and `ReceiptPolicy` in `ledger-config` keeps only the words. A draft
has a state (`DraftState`, `entry.draft_state_set`) and can be discarded
(`entry.draft_discarded`); `Ledger::posted_seq` is the journal line that
posted each entry. Every MCP tool with `confirmed` takes the digest
`Preview` (`tools/helpers.rs`) its preview returned.
## The journal format gate (`ledger-store`, P0) ## The journal format gate (`ledger-store`, P0)
Every journal line passes a gate after its parse, seq and chain checks Every journal line passes a gate after its parse, seq and chain checks
+109 -25
View File
@@ -354,7 +354,7 @@ Tool descriptions are at most 700 characters; eight longer ones are listed
in `tool_descriptions_fit_700_characters`, to be shortened in P5. in `tool_descriptions_fit_700_characters`, to be shortened in P5.
**Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool
that changes anything also has a `destructiveHint`, true for these 30: that changes anything also has a `destructiveHint`, true for these 31:
`books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`, `books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`,
`books.unlink_attachment`, `books.withdraw_attachment`, `books.unlink_attachment`, `books.withdraw_attachment`,
`books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`, `books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`,
@@ -366,7 +366,7 @@ that changes anything also has a `destructiveHint`, true for these 30:
`books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`,
`books.pay_bill`, `books.void_document`, `books.write_off_invoice`, `books.pay_bill`, `books.void_document`, `books.write_off_invoice`,
`books.run_payroll`, `books.remit_payroll_taxes`, `books.run_payroll`, `books.remit_payroll_taxes`,
`books.consolidate_duplicates`; and an `books.consolidate_duplicates`, `books.discard_draft`; and an
`idempotentHint` on the ten that a repeat with the same arguments leaves `idempotentHint` on the ten that a repeat with the same arguments leaves
the journal and the vault as they were: `books.verify`, the journal and the vault as they were: `books.verify`,
`books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`,
@@ -428,6 +428,24 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
+ resulting account balances) and post nothing. The agent must present the + resulting account balances) and post nothing. The agent must present the
preview to the human before confirming. This encodes the standing preview to the human before confirming. This encodes the standing
fact-check rule in the protocol itself. fact-check rule in the protocol itself.
- **The preview digest (P4, D-50).** Every tool called without
`confirmed` returns `preview`, the SHA-256 of what it shows: for a draft
entry, its canonical state (lines, classes, party, vendor, instrument,
the evidence linked) and the settings seq; for any other operation, the
tool, its arguments, the settings seq and the journal head seq. Every
call with `confirmed: true` takes that `preview` and is refused, before
anything is written, with the guard `{ code: "stale_preview", message,
remedy_tool }` when it is missing or no longer matches: an argument
changed, or another session wrote in between (a post by another session
forces a re-preview, by design). No key is involved: it detects drift;
it does not prove a human looked. The tools that take `preview` are all
those with `confirmed`: `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.purge_tin`, `books.consolidate_duplicates` and
`books.discard_draft`. (`books.void_document`, `books.run_depreciation`
and `books.run_recurring` post at once and have no `confirmed`.)
- **Errors are typed strings** mirroring `LedgerError` variants, with the - **Errors are typed strings** mirroring `LedgerError` variants, with the
offending values included (e.g. unbalanced totals). offending values included (e.g. unbalanced totals).
@@ -460,7 +478,7 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit", - `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
class, bad amount) adds nothing. class, bad amount) adds nothing.
- `books.post_entry { entry_id, confirmed?, expected_total? }` → `{ status, entry_id, date, - `books.post_entry { entry_id, confirmed?, preview?, expected_total? }` → `{ status, preview, entry_id, date,
description, total, lines (with resulting balances), receipts, description, total, lines (with resulting balances), receipts,
receipt_required, receipt_missing, split_detected, party_policy, receipt_required, receipt_missing, split_detected, party_policy,
party_id?, party?, parties, parties_error?, no_party_reason?, party_id?, party?, parties, parties_error?, no_party_reason?,
@@ -473,7 +491,7 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
share: "amount" | "percent" | "derived", amount?, percent?, resolved }`); share: "amount" | "percent" | "derived", amount?, percent?, resolved }`);
when the shares don't add up to what the entry moved, `parties_error` when the shares don't add up to what the entry moved, `parties_error`
says why and confirming fails with code `party_shares`. says why and confirming fails with code `party_shares`.
- `books.reverse_entry { entry_id, date, description, confirmed }` → same - `books.reverse_entry { entry_id, date, description, confirmed, preview? }` → same
preview/confirm pattern as post. A reversed depreciation run or schedule preview/confirm pattern as post. A reversed depreciation run or schedule
month is un-posted (its month is due again); refused, at the preview month is un-posted (its month is due again); refused, at the preview
already (P3): an asset's disposal entry (it would stay disposed), a already (P3): an asset's disposal entry (it would stay disposed), a
@@ -578,10 +596,23 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
one; no registered asset's account as cash). one; no registered asset's account as cash).
- Changed in P3 (breaking): `net_income`, `depreciation` and - Changed in P3 (breaking): `net_income`, `depreciation` and
`working_capital` are gone (net income is in `operating_reconciliation`). `working_capital` are gone (net income is in `operating_reconciliation`).
- `books.digest { since: "2026-10-01" }` - `books.digest { since: "2026-10-01" | since_seq: 412, as_of? }`
→ `{ since, entries: [...], totals_by_account: [...], stale_rules: [...], → `{ since, since_seq?, head_seq, entries: [...], totals_by_account: [...],
zero_net_accounts: [...], drafts_by_state: { building?, awaiting_evidence? },
reconciliation_tracked: [...], never_reconciled: [...], stale_rules: [...],
journal_repairs: [...], settings_problems: [...], unreconciled_accounts: journal_repairs: [...], settings_problems: [...], unreconciled_accounts:
[...], uncategorized_accounts: [...] }`. [...], uncategorized_accounts: [...] }`. `since` selects entries by their
date; `since_seq` selects the entries posted after a journal line (the
`head_seq` of an earlier answer), backdated entries included, which a
date would miss (P4, defect 6). `as_of` (default today) bounds the
movement. An account the entries touched whose net movement is zero is
listed under `zero_net_accounts` rather than dropped silently.
`drafts_by_state` counts the open drafts; `reconciliation_tracked` and
`never_reconciled` split the cash and card accounts (`books.cash_accounts`)
by whether a statement was ever recorded for them. Each entry's
`receipt_required` is the engine's one decision
(`ledger_core::evidence::requirement`): a document's entry, a close, a
depreciation run and a reversal need none of their own.
The human review surface: "what did the AI do with my books lately." The The human review surface: "what did the AI do with my books lately." The
digest is the answer to "trust, but verify." `stale_rules` lists legal digest is the answer to "trust, but verify." `stale_rules` lists legal
amounts wanderbooks may no longer know as of today (`{ name, amounts wanderbooks may no longer know as of today (`{ name,
@@ -724,7 +755,7 @@ life's last day is 9999-12-31 at the latest.
run-all's assets not yet in service and any whose schedule can't be run-all's assets not yet in service and any whose schedule can't be
computed. A run before the in-service date is refused. computed. A run before the in-service date is refused.
- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, - `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account,
gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` (the gain_loss_account, buyer?, confirmed?, preview?, receipt_attachment_ids? }` (the
date never before the acquired date) → date never before the acquired date) →
without confirmation: `{ status: "preview", asset_id, entry_id (0 in a without confirmation: `{ status: "preview", asset_id, entry_id (0 in a
preview), accumulated, book_value, preview), accumulated, book_value,
@@ -808,7 +839,7 @@ through the policy.
compute: then it says why, `remaining` and `next` are null and `months` compute: then it says why, `remaining` and `next` are null and `months`
empty, and `books.run_schedules` refuses until it is dealt with. empty, and `books.run_schedules` refuses until it is dealt with.
- `books.list_schedules {}` → `{ schedules: [...] }`, same shape. - `books.list_schedules {}` → `{ schedules: [...] }`, same shape.
- `books.run_schedules { through?, confirmed? }` → `{ status: "preview" | - `books.run_schedules { through?, confirmed?, preview? }` → `{ status: "preview" |
"posted", postings: [{ schedule_id, date, description, amount, "posted", postings: [{ schedule_id, date, description, amount,
entry_id? }], skipped: [{ schedule_id, date, reason }] }`: every entry_id? }], skipped: [{ schedule_id, date, reason }] }`: every
unposted month ending on or before `through` (default today). unposted month ending on or before `through` (default today).
@@ -1482,7 +1513,7 @@ and the CPA package carries them.
of a binary file (a W-9 PDF re-saved or re-scanned), and a TIN the vault of a binary file (a W-9 PDF re-saved or re-scanned), and a TIN the vault
doesn't hold written another way, are not detected: P7 takes full doesn't hold written another way, are not detected: P7 takes full
TINs out of the agent's reach. TINs out of the agent's reach.
- `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" | - `books.purge_tin { party, reason, confirmed?, preview? }` → `{ status: "preview" |
"purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?, "purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?,
journaled, note }`. Without `confirmed`, a preview that changes nothing. journaled, note }`. Without `confirmed`, a preview that changes nothing.
Confirmed, deletes the party's vault records (needs no key) and, when the Confirmed, deletes the party's vault records (needs no key) and, when the
@@ -1667,7 +1698,7 @@ checks the text blob's presence and hash.
none may be the paying entry itself or be named twice. It changes no none may be the paying entry itself or be named twice. It changes no
amount. "The member was paid back" is read from this record only, never amount. "The member was paid back" is read from this record only, never
guessed (IZ-18); Needs attention asks until it is recorded. guessed (IZ-18); Needs attention asks until it is recorded.
- `books.consolidate_duplicates { confirmed? }` → `{ status: "preview" | - `books.consolidate_duplicates { confirmed?, preview? }` → `{ status: "preview" |
"consolidated", groups: [{ sha256, keep, withdraw, links }], withdrawn "consolidated", groups: [{ sha256, keep, withdraw, links }], withdrawn
}`. For every set of attachments holding the same bytes it keeps the }`. For every set of attachments holding the same bytes it keeps the
oldest (`keep`), links it to every subject the others had, unlinks the oldest (`keep`), links it to every subject the others had, unlinks the
@@ -1707,10 +1738,18 @@ posting.
violation, and the append-only audit log would show it anyway. violation, and the append-only audit log would show it anyway.
(`asset_id` instead of `entry_id` for a disposal; `split_group: [ids]` (`asset_id` instead of `entry_id` for a disposal; `split_group: [ids]`
when the split check triggered.) when the split check triggered.)
- **Not checked yet:** `books.receive_payment`, `books.pay_bill`, - **One gate (P4, I-5, D-47).** `ledger_core::evidence::requirement`
`books.run_payroll` and `books.remit_payroll_taxes` post at once with no decides whether an entry needs evidence; the store's gate
receipt check: attach their evidence right after, to the entry they (`Store::post_entry_checked`), the previews, the digest, the viewer and
return. (`books.create_invoice` and `books.create_bill` preview and Needs attention all call it. A reversal needs no receipt of its own (its
evidence is its original's, D-12); neither does a closing entry, a
schedule month's or a depreciation run's posting, an asset's disposal,
a document's entry or a payroll run or remittance. A refusal is the
`receipt_required` error above, unchanged.
- **Not checked:** `books.receive_payment`, `books.pay_bill`,
`books.run_payroll` and `books.remit_payroll_taxes` are exempt from the
receipt policy by origin (above): attach their evidence right after, to
the entry they return. (`books.create_invoice` and `books.create_bill` preview and
confirm, and a bill's vendor invoice is required, in books that have confirm, and a bill's vendor invoice is required, in books that have
declared `documents_v2`.) declared `documents_v2`.)
- **Anti-splitting.** Posting an entry triggers a split check: posted - **Anti-splitting.** Posting an entry triggers a split check: posted
@@ -1971,7 +2010,7 @@ accounting only: the caller supplies every figure), tax filing.
- `books.create_period { name, start, end }` → `{ period_id }`. - `books.create_period { name, start, end }` → `{ period_id }`.
Dates are YYYY-MM-DD, inclusive. Dates are YYYY-MM-DD, inclusive.
- `books.close_period { period_id, confirmed? }` → without confirmation: - `books.close_period { period_id, confirmed?, preview? }` → without confirmation:
`{ status: "preview", period_id, closing_entry: null, lines: [...], `{ status: "preview", period_id, closing_entry: null, lines: [...],
members }`: the exact closing entry (the close run on a copy of the members }`: the exact closing entry (the close run on a copy of the
ledger), posting nothing; with `confirmed: true`: `{ status: "closed", ledger), posting nothing; with `confirmed: true`: `{ status: "closed",
@@ -2030,7 +2069,7 @@ accounting only: the caller supplies every figure), tax filing.
the same attachment can record it again. Refused while a reconciliation the same attachment can record it again. Refused while a reconciliation
not voided rests on it. not voided rests on it.
- `books.reconcile { statement_attachment? | account, statement_date, - `books.reconcile { statement_attachment? | account, statement_date,
statement_balance, confirmed? }` → `{ status: "preview" | "reconciled", statement_balance, confirmed?, preview? }` → `{ status: "preview" | "reconciled",
account_code, statement_date, statement_attachment?, book_balance, account_code, statement_date, statement_attachment?, book_balance,
cleared_balance, statement_balance, difference, uncleared_lines, cleared_balance, statement_balance, difference, uncleared_lines,
cleared_lines, problems, reconciliation? }`, each line `{ entry_id, cleared_lines, problems, reconciliation? }`, each line `{ entry_id,
@@ -2069,7 +2108,7 @@ snapshots; see plan 8.2 S4.
lines: [{ description, amount, quantity?, unit_price?, account?, lines: [{ description, amount, quantity?, unit_price?, account?,
class? }], customer_ref?, memo?, bill_to?: { name?, address?, email?, class? }], customer_ref?, memo?, bill_to?: { name?, address?, email?,
phone?, contact?, billing_address?, shipping_address?, billing_email? }, phone?, contact?, billing_address?, shipping_address?, billing_email? },
confirmed? }` → without `confirmed`: `{ preview: true, number?, total, confirmed?, preview? }` → without `confirmed`: `{ preview, number?, total,
due_date, terms?, lines: [{ account, name, side, amount, class? }], due_date, terms?, lines: [{ account, name, side, amount, class? }],
pdf }`, and nothing is written; with `confirmed: true`: `{ id, number?, pdf }`, and nothing is written; with `confirmed: true`: `{ id, number?,
total, due_date, terms?, entry_id, pdf: { attachment_id, sha256 }, total, due_date, terms?, entry_id, pdf: { attachment_id, sha256 },
@@ -2124,7 +2163,7 @@ snapshots; see plan 8.2 S4.
- `books.create_bill { vendor | party_id, date, received_on?, vendor_ref?, - `books.create_bill { vendor | party_id, date, received_on?, vendor_ref?,
terms? | due_date?, lines: [{ description, amount, account, class? }], terms? | due_date?, lines: [{ description, amount, account, class? }],
memo?, upload_id | existing_attachment_id, duplicate_reason?, memo?, upload_id | existing_attachment_id, duplicate_reason?,
confirmed? }` → without `confirmed`: `{ preview: true, total, due_date, confirmed?, preview? }` → without `confirmed`: `{ preview, total, due_date,
terms?, lines: [...], discount_by?, max_discount? }` and nothing is terms?, lines: [...], discount_by?, max_discount? }` and nothing is
written; with `confirmed: true`: `{ id, total, due_date, terms?, written; with `confirmed: true`: `{ id, total, due_date, terms?,
entry_id, lines?, discount_by?, max_discount?, file: { attachment_id, entry_id, lines?, discount_by?, max_discount?, file: { attachment_id,
@@ -2158,9 +2197,12 @@ snapshots; see plan 8.2 S4.
- `books.receive_payment { date, deposit_to | cash_account, instrument?, - `books.receive_payment { date, deposit_to | cash_account, instrument?,
reference?, invoice | document_id, amount? | applications: [{ invoice, reference?, invoice | document_id, amount? | applications: [{ invoice,
amount, discount?, discount_reason? }], fee?: { amount, account?, amount, discount?, discount_reason? }], fee?: { amount, account?,
vendor? }, paid_in_full_marking? }` → `{ entry_id, documents: [{ ref, vendor? }, paid_in_full_marking?, confirmed?, preview? }` → `{ preview?,
number?, outstanding, status }], deposited, remaining?, status?, entry_id, documents: [{ ref, number?, outstanding, status }], deposited,
warnings? }`. Settles one or several invoices of one customer in one remaining?, status?, warnings? }`. Without `confirmed: true` a preview
(entry 0, the figures the documents would show, `preview` to pass
back) and nothing is written (P4: the S6 tools preview and confirm like
the create tools). Settles one or several invoices of one customer in one
entry (B-42): Dr the deposit account the cash less the fee, Dr the entry (B-42): Dr the deposit account the cash less the fee, Dr the
payment-processing-fees account the fee, Dr sales allowances the payment-processing-fees account the fee, Dr sales allowances the
discounts, Cr A/R each invoice's cash and discount, every receivable discounts, Cr A/R each invoice's cash and discount, every receivable
@@ -2180,7 +2222,7 @@ snapshots; see plan 8.2 S4.
payments; they never collect them (D-45). payments; they never collect them (D-45).
- `books.pay_bill { date, paid_from | cash_account, instrument?, - `books.pay_bill { date, paid_from | cash_account, instrument?,
reference?, bill | document_id, amount? | applications: [{ bill, amount, reference?, bill | document_id, amount? | applications: [{ bill, amount,
discount?, discount_reason? }] }` → as `receive_payment`, per bill and discount?, discount_reason? }], confirmed?, preview? }` → as `receive_payment`, per bill and
one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer
account the cash, Cr purchase discounts the discounts. A discount on a account the cash, Cr purchase discounts the discounts. A discount on a
bill with an asset or prepaid line is refused with its remedy (record bill with an asset or prepaid line is refused with its remedy (record
@@ -2208,7 +2250,7 @@ snapshots; see plan 8.2 S4.
(`format_upgrade_pending`). `sends` is every send on the invoice, in (`format_upgrade_pending`). `sends` is every send on the invoice, in
order, the new one last. order, the new one last.
- `books.write_off_invoice { invoice, date, amount?, reason, upload_id | - `books.write_off_invoice { invoice, date, amount?, reason, upload_id |
existing_attachment_id, confirmed? }` → `{ entry_id, document: { ref, existing_attachment_id, confirmed?, preview? }` → `{ entry_id, document: { ref,
status, paid, written_off, outstanding } }`. Writes off an invoice that status, paid, written_off, outstanding } }`. Writes off an invoice that
will not be collected (a bad debt, P4b S7; rules.md B-41): posts Dr will not be collected (a bad debt, P4b S7; rules.md B-41): posts Dr
`6990` Bad Debt Expense / Cr `1200` Accounts Receivable, the receivable `6990` Bad Debt Expense / Cr `1200` Accounts Receivable, the receivable
@@ -2425,10 +2467,15 @@ until the owner upgrades them.
## Tools — CPA handoff ## Tools — CPA handoff
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, - `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
investing_accounts?, include_receipts?, include_tins? }` → `{ file, investing_accounts?, include_receipts?, include_tins?,
allow_open_drafts?: { reason } }` → `{ file,
tins_included, tin_gaps, warnings?, note }`: the zip, tins_included, tin_gaps, warnings?, note }`: the zip,
`cpa-package-<as_of>.zip`, handed over as `file` (a `HandedFile`, `cpa-package-<as_of>.zip`, handed over as `file` (a `HandedFile`,
"Tools — the TIN vault"). "Tools — the TIN vault").
**Open drafts** (P4, plan 4.8): refused with `open_drafts` while a draft
dated on or before `as_of` is open (post it or discard it); with
`allow_open_drafts: { reason }` the package goes and the reason is
journaled as a decision.
**As of one day** (`docs/spec/rules.md` B-33): nothing in it reflects **As of one day** (`docs/spec/rules.md` B-33): nothing in it reflects
anything dated after `as_of` (default today): no figure, entry, anything dated after `as_of` (default today): no figure, entry,
receipt or worksheet row; the chart and `documents/` are as they stand. receipt or worksheet row; the chart and `documents/` are as they stand.
@@ -2520,3 +2567,40 @@ until the owner upgrades them.
count and what each file is). Period reports cover the fiscal year count and what each file is). Period reports cover the fiscal year
holding `as_of`, through it. holding `as_of`, through it.
What the CPA needs; they never log into the system. What the CPA needs; they never log into the system.
## Tools — drafts (P4)
A draft is an entry being built (`books.draft_entry`, `books.add_line`) that
is not in any report until it posts. P4 gives it a state, a way out, and a
read-only preview (rules.md B-43; the journal feature `drafts_v2`: the
state and discard tools wait for the books' format upgrade).
- `books.list_drafts { state?, as_of? }` → `{ drafts: [{ entry_id, date,
description, total, state, note, missing_evidence }] }`. `state` is
`building` or `awaiting_evidence`; `as_of` keeps drafts dated on or
before a day. `missing_evidence` is true when the receipt policy covers
the draft and it has no receipt yet.
- `books.set_draft_state { entry_id, state, note? }` → `{ entry_id, state,
note }`. `awaiting_evidence` with a note ("Figma invoice due 11/3") says
the draft is finished and waits for its document; `building` says it is
being worked on. A state is a note: no line or figure changes, and the
receipt policy is still checked when the draft posts. Only a draft has
one.
- `books.discard_draft { entry_id, reason, confirmed?, preview? }` →
`{ status: "preview" | "discarded", entry_id, description, total,
holds_open?, preview }`. Throws away a draft that will never post; only a
draft (a posted entry is reversed, never removed). Nothing in any report
moves, the id is not reused, and a period the draft held open can close
(`holds_open` names those). Previewed first, then confirmed with the
`preview` digest.
- `books.preview_entry { entry_id }` → the `books.post_entry` preview
(`status: "preview"`, lines, receipts, `receipt_required`,
`receipt_missing`, party and approval checks, `preview`) as a read-only
call; pass its `preview` to `books.post_entry` with `confirmed: true`.
**What a report leaves open.** `books.trial_balance`, `books.profit_and_loss`,
`books.balance_sheet`, `books.cash_flow` and
`books.profit_and_loss_by_class` return `as_of_status: {
drafts_open_on_or_before: [draft], unreconciled: [...], settings_problems:
[...] }` for the report's end date, and take `strict: true` to be refused,
naming what is open, instead of warned.
+16
View File
@@ -207,3 +207,19 @@ what a payment settles (`entry.settles`, the only source of "reimbursed"),
and gives company records four more homes (`notices`, `correspondence`, and gives company records four more homes (`notices`, `correspondence`,
`plans`, `history`, D-20). Rules.md B-44 states it; `docs/testing.md`'s P6 `plans`, `history`, D-20). Rules.md B-44 states it; `docs/testing.md`'s P6
section lists its tests and the P12 mutation targets. section lists its tests and the P12 mutation targets.
### P4: drafts, the evidence gate, the preview digest (2026-10-29)
Three new rows, none above changed status, and the floor stands.
| Area | Counterpart (a cross-check, not the spec) | Status | Notes |
|---|---|---|---|
| Draft states and discard (`books.set_draft_state`, `books.discard_draft`, `books.list_drafts`; Lean `Step.discard`) | Draft/pending transactions (QBO "pending"), voiding an unposted entry | partial | `entry.draft_state_set` and `entry.draft_discarded` under `drafts_v2`; B-43; tests `discard_unblocks_close`, `draft_states_replay_and_project`, `a_draft_waits_for_its_receipt_and_posts`; the fixture `p4_before` was written by the version before and opens to the same figures; `partial` until a hand-verified golden covers a discard |
| One evidence gate (`evidence::requirement`) | Attach-required rules on posting | partial | the store, the previews, the digest, the viewer and Needs attention call one function; `gui_and_server_gate_agree`, `a_reversal_needs_no_receipt_of_its_own` (D-47); same tests as above for the golden |
| Preview digest on every confirm (D-50) | Confirm dialogs | partial | `stale_preview_refused`, `every_confirm_tool_takes_a_preview`; detects drift, not a human's look |
The digest also gained `since_seq` (what was posted since a journal line,
backdated entries included: defect 6), `zero_net_accounts`,
`drafts_by_state` and the reconciliation split; reports gained
`as_of_status` and `strict`; the CPA package is refused while a draft
dated in it is open (4.8).
+71
View File
@@ -3431,6 +3431,77 @@ check_discount}`, `arap::{Application, Fee, SettleParams, SettlementRow}`,
The Lean step is `Documents.Step.document` with every control line tagged The Lean step is `Documents.Step.document` with every control line tagged
(`Documents.settle_unattached`); no new `Step` constructor. (`Documents.settle_unattached`); no new `Step` constructor.
## B-43 A draft waits on its evidence, and one that will never post can be discarded
**bookspec:** `BS-entry-5`
**Status:** AUTHORITY (the evidence a draft waits for, Pub. 583 and Rev.
Proc. 98-25); DECISION D-47 (owner-adopted recommendation, 2026-10-26:
reversals stay posted and need no evidence of their own); CONVENTION (the
two states, and discarding a draft rather than posting and reversing it).
```text
GOAL: no entry the receipt policy covers posts without the document that
supports it, decided in one place; a draft that is finished and
waits for that document says so; a draft that will never post can
be thrown away, so it stops holding its period open.
SOURCE: IRS Pub. 583 (docs/spec/sources/irs-records.md: "you must keep
supporting documents ... they support the entries in your
books"); Rev. Proc. 98-25 §5.01 (the records must let the books be
demonstrated); DECISION D-47 (a reversal's evidence is its
original's, D-12); CONVENTION (a draft's state is a note: it moves
no figure).
INVARIANTS:
1. One function, `evidence::requirement`, decides whether an entry needs
evidence: `Required` (with why) or `Exempt` (with the source of the
exemption). The store's gate, the server's previews and digest, the
viewer and Needs attention all call it; none decides on its own.
2. Exempt by origin, each with its source: a period's closing entry
(derived from the books), a schedule month's or a depreciation run's
posting (the schedule is the evidence), an asset's disposal, an entry
a document or a payroll command posts (it carries its own evidence
or is posted at once by design: an invoice, a bill, a payment, a
write-off, a payroll run or its tax remittance), and a reversal (its
evidence is its original's, D-47, D-12). Every other entry, hand
entries and recurring drafts included, is covered by the policy
settings (`policy.evidence.*`): required above the threshold, on a
required account, or as part of a split group; exempt only when every
line is on an exempt account.
3. A draft has a state: `building` (the default) or `awaiting_evidence`
with a note. A state is a note on the draft; it moves no line, no
balance and no report, and a posted entry has none.
4. A draft may be discarded, with a reason, and only a draft: a posted
entry is never removed (B-8). Discarding moves no balance and spends
no id, and what was recorded about the draft (its vendor, instrument
and parties) goes with it; the journal still holds its lines and why
it went. A discarded draft no longer holds a period open.
5. Evidence is checked when the entry posts, not when it is drafted: a
draft awaiting evidence posts the moment the evidence is attached
and the post is confirmed.
6. Every refusal leaves the journal unchanged.
SCENARIOS:
1. A hand entry of 40.00 under a 25.00 threshold, no receipt: the post
is refused with `receipt_required`; the draft may be set
`awaiting_evidence` with the note "Figma invoice due".
2. The receipt is attached; the post succeeds; the entry has no state.
3. A reversal of that entry: it posts with no receipt of its own.
4. A draft dated in January, January closing: the close is refused
while the draft stands; discarded, the close succeeds.
5. A posted entry: set-state and discard are both refused.
```
**Enforced:** `ledger_core::evidence::{requirement, Requirement}`,
`Ledger::{set_draft_state, discard_draft, posted_seq}`,
`Store::{set_draft_state, discard_draft, post_entry_checked}` (the gate the server posts a hand draft by; `Store::post_entry` stays the primitive commands and fixtures post through); events
`entry.draft_state_set`, `entry.draft_discarded` (feature `drafts_v2`).
**Checked:** Lean `Step.discard`, `Step.discard_keeps_books`,
`Step.discard_unblocks_close`; `ledger-core` `tests/invariants.rs`
(`discard_unblocks_close`); `ledger-store` `tests/store.rs`
(`draft_states_replay_and_project`, `drafts_v2_waits_for_the_upgrade`,
`posted_seq_is_the_posting_lines_seq`); `ledger-cli` `tests/drafts.rs` (`a_draft_waits_for_its_receipt_and_posts`,
`a_reversal_needs_no_receipt_of_its_own`, `gui_and_server_gate_agree`,
`stale_preview_refused`).
## B-44 Evidence is read from the bytes, never trusted over them; identical files are kept once ## B-44 Evidence is read from the bytes, never trusted over them; identical files are kept once
**bookspec:** `BS-ledger-12` **bookspec:** `BS-ledger-12`
+75
View File
@@ -3594,3 +3594,78 @@ token" must die on `an_unreadable_token_file_is_an_error_never_no_token`),
`Keeper::delete`'s `NotFound` arm, and `Account::key`'s three parts `Keeper::delete`'s `NotFound` arm, and `Account::key`'s three parts
(`an_account_names_the_root_by_hash_and_the_book_and_token`); (`an_account_names_the_root_by_hash_and_the_book_and_token`);
`bridge.rs::token_file_refusal`'s `holds_token` guard. `bridge.rs::token_file_refusal`'s `holds_token` guard.
## P4: draft states, the evidence gate, the preview digest (2026-10-29)
A draft has a state (`building`, or `awaiting_evidence` with a note) and a
way out (`books.discard_draft`); one function, `evidence::requirement`,
decides whether an entry needs evidence; every tool with `confirmed` takes
the `preview` digest it returned; `books.digest` can be asked "since a
journal line". Journal feature `drafts_v2`; events `entry.draft_state_set`
and `entry.draft_discarded`; schema 19 (taken as the next after the last,
renumbered at merge): `entries.posted_seq` and the `draft_states` table
(drafts are not rows of `entries`, so the plan's `entries.draft_state`
column became a table).
- **`ledger-core` `tests/invariants.rs`:** `discard_unblocks_close` (a
period with an open draft can't close, a state changes no figure,
discarded it closes; balances, order and ids as they were; a posted
entry is never discarded or given a state).
- **Lean:** `Step.discard` (preservation of `Inv`, including
`draftsOpen`), `Step.discard_keeps_books`, `Step.discard_drafts`,
`Step.discard_unblocks_close`, in `Audit.lean`.
- **`ledger-store` `tests/store.rs`:** `draft_states_replay_and_project`
(both events journaled as written, `draft_states` holds only the waiting
draft and loses it when it posts, live equals replay, the refusals),
`drafts_v2_waits_for_the_upgrade`, `posted_seq_is_the_posting_lines_seq`
(hand, backdated, reversal and closing entries: the number is the line
that posted them, live, replayed and projected);
`books_written_under_older_rules_still_open` hand-writes both events.
`tests/format.rs`: `an_older_version_stops_at_the_draft_events`;
`format.rs`'s `drafts_v2_is_its_own_feature`.
- **8.0.6:** the fixture `old-books/p4_before` (writer
`old-books-writer/src/bin/p4_before.rs`, run against 25b52580) holds
hand entries posted late, a reversal, a document and its payment, a
schedule month, a recurring draft, two open drafts and a closed month;
`every_book_an_earlier_version_wrote_upgrades_and_still_verifies` opens
it to the writer's figures, so no figure moved (serialized entries omit
a building state). `books_p2_served_cut_over_whole` refuses the two draft
tools before consent and takes them after.
- **`ledger-cli` `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` (D-47),
`discard_unblocks_the_close_over_the_wire`,
`report_warns_on_draft_before_as_of` (and `strict`),
`cpa_package_refuses_open_draft` (and `allow_open_drafts` journaling a
decision), `stale_preview_refused` (a changed line, a changed argument,
a write between preview and confirm, no preview),
`every_confirm_tool_takes_a_preview`, `digest_since_seq_is_posting_time`
(with `zero_net_accounts`, `drafts_by_state`, `never_reconciled`),
`legacy_reversed_replays_identically` (the fixture over the wire),
`gui_and_server_gate_agree` (three policies over random books; the
viewer's missing-evidence set equals the engine's and equals the old
reading of an entry's origin from the journal's events).
- **`ledger-cli` `tests/attention_parity.rs`:**
`a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists`.
- **The sweep:** every over-the-wire harness confirms the way an agent does
(`common::tools_call`: the call without `confirmed`, then with the
`preview`); `books.receive_payment` and `books.pay_bill` gained
`confirmed`; `ledger-mcp` `scopes.rs` tests pin them as preview-then-post.
**For P12 (mutation targets):** `evidence::requirement`'s exemption arms
(reversal, closing, schedule month, depreciation run, disposal origin,
payroll remittance origin, a tagged document line, a payroll run) and
their order, the split-group branch taken only for a `Draft`, and
`Policy::reason_for`'s three comparisons (required account, `<=` the
threshold, every code exempt); `Ledger::origin_exemption`; the replay
arms that note `AssetDisposal` and `PayrollRemittance`;
`Ledger::discard_draft`'s empty-reason and posted refusals and the side
tables it clears; `set_draft_state`'s `check_text`; `stamp_unstamped` and
`expect_posted_seq` (a mutant that stamps the wrong line shows in
`posted_seq_is_the_posting_lines_seq`); `Preview::operation` (the keys it
drops: `confirmed`, `preview`, nulls) and `Preview::check`'s three arms
(missing, equal, different); `post_entry`'s draft payload (each field of
it); the digest's `since_seq` comparison (`>`), its `as_of` bound, the
`zero_net_accounts` branch and the `never_reconciled` split;
`report_status`'s three open conditions; the open-drafts refusal's
`<= as_of` and its decision; the attention `awaiting_evidence` branch.
+17 -1
View File
@@ -1479,7 +1479,23 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
5. **Files:** owns `crates/ledger-core/src/evidence.rs`, `crates/ledger-mcp/src/tools/drafts.rs`, `crates/ledger-cli/tests/drafts.rs`, `handbook/drafts-and-reversals.md`; touches `journal.rs`, `ledger.rs`, `store.rs`, every tool with `confirmed` (the sweep), `crates/ledger-gui/src/books.rs`, `crates/ledger-package/src/attention.rs`, the 8.0.3 files. 5. **Files:** owns `crates/ledger-core/src/evidence.rs`, `crates/ledger-mcp/src/tools/drafts.rs`, `crates/ledger-cli/tests/drafts.rs`, `handbook/drafts-and-reversals.md`; touches `journal.rs`, `ledger.rs`, `store.rs`, every tool with `confirmed` (the sweep), `crates/ledger-gui/src/books.rs`, `crates/ledger-package/src/attention.rs`, the 8.0.3 files.
6. **Tests and proofs:** named above; Lean `Step.discard`; no kernel change. 6. **Tests and proofs:** named above; Lean `Step.discard`; no kernel change.
7. **Done when:** (1) every tool with `confirmed` takes `preview` and refuses a stale one (`every_confirm_tool_takes_a_preview`, `stale_preview_refused`); (2) one function decides evidence and the GUI's string match is gone (`gui_and_server_gate_agree`); (3) reversals still post at once with no receipt of their own (`a_reversal_needs_no_receipt_of_its_own`); (4) the drafts tools and the digest fields exist and are documented; (5) 8.0.6 in the PR (a `figures.json` from the pre-P4 version over a fixture holding reversals and drafts: no figure moves); (6) `just check`, `just prove` green. 7. **Done when:** (1) every tool with `confirmed` takes `preview` and refuses a stale one (`every_confirm_tool_takes_a_preview`, `stale_preview_refused`); (2) one function decides evidence and the GUI's string match is gone (`gui_and_server_gate_agree`); (3) reversals still post at once with no receipt of their own (`a_reversal_needs_no_receipt_of_its_own`); (4) the drafts tools and the digest fields exist and are documented; (5) 8.0.6 in the PR (a `figures.json` from the pre-P4 version over a fixture holding reversals and drafts: no figure moves); (6) `just check`, `just prove` green.
8. **As built:** none yet. 8. **As built (2026-10-29):** departures from Build, each with its reason.
- **`crates/ledger-core/src/evidence.rs` was not new.** P6 created it (annotations, settlements); `Policy`, `Requirement`, `Origin` and `requirement` were appended to it.
- **Schema 19 is a table, not a column.** Drafts are not rows of `entries` (they never touch the projection until posted), so `entries.draft_state` had nowhere to live: a `draft_states(entry_id, state, note)` table holds the drafts awaiting evidence (a draft being built is the default and has no row), and `entries.posted_seq` is a column. The number 19 is the next after the current last (18); the EM renumbers at merge.
- **`Store::post_entry` stays the primitive; the gate is `Store::post_entry_checked`.** About 176 call sites in tests, fixtures, the demo and bookspec post hand entries over 25.00 with no receipt through `Store::post_entry`; a gate there would refuse all of them. The server posts a hand draft only through `post_entry_checked`, which calls `evidence::requirement` and refuses with `StoreError::ReceiptRequired`; the MCP maps it to the unchanged `receipt_required` error (`the_gate_moved_and_the_mcp_error_is_unchanged`).
- **Payouts are not exempt.** The unit lists payouts among the exempt origins; `books.record_payout` makes an ordinary draft whose statement is attached as its receipt (its own description says so), and the viewer has always covered it, so it stays covered (no behaviour change). The exempt origins are exactly the ones the viewer's journal-event reading exempted: a close, a schedule month, a depreciation run, a disposal, a document's entry (any line tagged with a document), a payroll run or remittance, a reversal. The ledger learned the two it could not see (a disposal, a remittance) from `asset.disposed` and `payroll.taxes_remitted` (`Origin`), live and on replay.
- **A split group counts for a draft only.** `requirement` applies the anti-splitting rule to drafts (as the post always did) and not to posted entries, whose decision was made when they posted; otherwise the viewer would flag legitimately posted neighbours.
- **`posted_seq` is stamped for every posting, not only `entry.posted`/`entry.reversed`**: a close (`period.closed`) and a schedule month (`schedule.posted`) post an entry too. `Ledger::expect_posted_seq` (set when the store journals `entry.posted`/`entry.reversed`, before the command that posts) and `stamp_unstamped` (after each replayed line, and after the two live single-line commands) make live equal replay (`posted_seq_is_the_posting_lines_seq`).
- **The preview digest is taken over the arguments as the caller sent them** (a thread-local set in `call_tool`, `ARGS`), not over re-serialized parameters (the params structs are `Deserialize` only); `Preview::of`/`check` and `BooksServer::preview_of` live in `tools/helpers.rs`, not `server.rs`.
- **`confirmed` was added to `books.receive_payment` and `books.pay_bill`** (S6 deferred it): without it a preview on a clone of the ledger (entry 0), with it the post; their scope rows are now preview-then-post. `books.void_document`, `books.run_depreciation` and `books.run_recurring` have no `confirmed` today (the unit's list names them, the grep does not) and post at once as before; not changed. `InvoiceOut.preview`/`BillOut.preview` changed from `true` to the digest.
- **The digest tool**: `since` became optional beside `since_seq`; `books.digest`'s `receipt_required` is now the engine's decision, so a document's, a close's or a depreciation run's entry no longer reads "receipt missing".
- **Reports**: `as_of_status` and `strict` are on `books.trial_balance`, `books.profit_and_loss`, `books.balance_sheet`, `books.cash_flow` and `books.profit_and_loss_by_class`.
- **`books.export_cpa_package` journals a decision** when `allow_open_drafts` is given (its doc said "read-only"); with open drafts and no reason it is refused with code `open_drafts`.
- **A discarded draft's receipts stay filed** (retention, Pub. 583); a recurring draft's occurrence stays generated. Not unlinked.
- **Harness**: every over-the-wire test harness confirms the way an agent does (`common::tools_call`, `Wire::call`, the async `call`s: the call without `confirmed`, then with the `preview`); `Wire::call_raw` sends exactly what it is given.
- **8.0.6**: `books_written_under_older_rules_still_open` hand-writes both events; fixture `old-books/p4_before` (writer `p4_before.rs`, run against 25b52580) with its `expected.json`; `books_p2_served_cut_over_whole` refuses `books.set_draft_state` and `books.discard_draft` before consent. No figure moved: `JournalEntry` serializes a building state as nothing, so no golden changed. The statement for the PR: merge does not need the owner's go-ahead; the live books refuse the two draft writes until he gives it.
- **Lean**: `Step.discard` with three theorems in `Audit.lean`; no kernel change, no new Kani harness.
- **Not done in this unit**: the viewer has no screenshot of the awaiting-evidence item (the item is tested through the parity test, `a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists`).
**Struck from P4 (D-47):** ~~reversal as a draft (`entry.reversal_drafted`, `Step.draftReversal`, `evidence: link_original | {attachment_ids} | later`, "reversal draft needs evidence", `reversal_needs_evidence`, `inherited_evidence_posts`)~~; ~~`preview_token` as an HMAC with `keys/preview.key`~~ → D-50; ~~`ledger_core::evidence::requirement` used by "every path" including the at-once tools~~ → the at-once tools are unchanged (IZ-1h); ~~"defect 2"~~ → the 2026-10-16 audit's defect 2 (a reversal posted with no evidence of its own) is closed by D-47 and D-12, not by code. **Struck from P4 (D-47):** ~~reversal as a draft (`entry.reversal_drafted`, `Step.draftReversal`, `evidence: link_original | {attachment_ids} | later`, "reversal draft needs evidence", `reversal_needs_evidence`, `inherited_evidence_posts`)~~; ~~`preview_token` as an HMAC with `keys/preview.key`~~ → D-50; ~~`ledger_core::evidence::requirement` used by "every path" including the at-once tools~~ → the at-once tools are unchanged (IZ-1h); ~~"defect 2"~~ → the 2026-10-16 audit's defect 2 (a reversal posted with no evidence of its own) is closed by D-47 and D-12, not by code.