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

+16 -5
View File
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 129] = [
pub const TABLE: [(&str, Needs); 133] = [
("books.handbook", R),
("books.start", R),
// 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).
("books.create_invoice", C),
("books.create_bill", C),
("books.receive_payment", P),
("books.pay_bill", P),
("books.receive_payment", C),
("books.pay_bill", C),
("books.void_document", P),
// Records a send: changes the books' record, never a figure.
("books.mark_sent", W),
@@ -196,6 +196,11 @@ pub const TABLE: [(&str, Needs); 129] = [
("books.annotate_attachment", W),
("books.set_entry_settles", W),
("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
@@ -381,12 +386,18 @@ mod tests {
);
assert_eq!(
needs(
"books.receive_payment",
"books.void_document",
args(json!({"confirmed": false})).as_ref()
),
Some(Scope::Post),
"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.nonesuch", None), None);
// INVARIANT 4.
@@ -435,7 +446,7 @@ mod tests {
shown("books.create_invoice", &write),
"it may preview an invoice"
);
assert!(!shown("books.receive_payment", &write));
assert!(!shown("books.void_document", &write));
let post = set(&[Scope::Post]);
assert!(shown("books.post_entry", &post));
assert!(!shown("books.chart", &post));
+23 -9
View File
@@ -583,6 +583,7 @@ impl BooksServer {
+ Self::payouts_router()
+ Self::tax_router()
+ Self::write_offs_router()
+ Self::drafts_router()
}
/// 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() {
return Ok(());
}
let split_group = self
.settings()
.policy
let split_group = ledger_core::evidence::Policy::from_settings(store.ledger().settings())
.split_group(store.ledger(), entry_id, entry);
if !self
.settings()
.policy
.receipt_required(store.ledger(), entry)
if !ledger_core::evidence::requirement(store.ledger(), entry, store.ledger().settings())
.is_required()
&& split_group.is_empty()
{
return Ok(());
@@ -1328,6 +1325,10 @@ thread_local! {
/// (set at its start from the settings in force: one version per
/// request), so every "today" in it agrees.
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
@@ -1415,6 +1416,8 @@ mod attachments;
mod chart;
#[path = "tools/documents.rs"]
mod documents;
#[path = "tools/drafts.rs"]
mod drafts;
#[path = "tools/entries.rs"]
mod entries;
#[path = "tools/evidence.rs"]
@@ -1503,7 +1506,10 @@ impl BooksServer {
}
"books://reports/balance-sheet" => {
let Json(out) = self
.balance_sheet(Parameters(BalanceSheetParams { as_of: None }))
.balance_sheet(Parameters(BalanceSheetParams {
as_of: None,
strict: None,
}))
.await?;
json(&out)
}
@@ -1513,13 +1519,20 @@ impl BooksServer {
start: None,
end: None,
class: None,
strict: None,
}))
.await?;
json(&out)
}
"books://digest" => {
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)
}
handbook::URI => {
@@ -1694,6 +1707,7 @@ impl ServerHandler for BooksServer {
let result = run_blocking(&self.store, move || {
Box::pin(async move {
WROTE.with(|w| w.borrow_mut().take());
ARGS.with(|a| *a.borrow_mut() = request.arguments.clone());
let caller = running_call
.caller()
.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 WI: Hints = (Some(false), Some(false), 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.start", R),
("books.set_setting", DI),
@@ -174,6 +174,11 @@ fn annotations_complete() {
("books.back_up_now", WI),
// P4b S7: a bad-debt write-off.
("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();
assert_eq!(tools.len(), table.len());
@@ -213,8 +218,8 @@ fn annotations_complete() {
"{name}: openWorldHint"
);
}
// docs/mcp-interface.md names the 31 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 31);
// docs/mcp-interface.md names the 32 destructive tools.
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
+11
View File
@@ -930,6 +930,7 @@ impl BooksServer {
.receipt_required_for(&code_refs, debits);
let out = DisposeAssetOut {
preview: None,
status: "preview".to_string(),
asset_id,
entry_id: 0,
@@ -943,6 +944,16 @@ impl BooksServer {
lines: preview_lines_for(&store, &raw)?,
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) {
return Ok(Json(out));
}
+14 -2
View File
@@ -513,6 +513,12 @@ impl BooksServer {
.terms
.as_ref()
.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) {
let printed = ledger_package::draft_invoice_document(
store.ledger(),
@@ -527,7 +533,7 @@ impl BooksServer {
);
let pdf = self.hand_over(&book_name(&store), &file_name, "application/pdf", bytes)?;
return Ok(Json(InvoiceOut {
preview: Some(true),
preview: Some(digest),
id: None,
number: preview.number,
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) {
return Ok(Json(BillOut {
preview: Some(true),
preview: Some(digest),
id: None,
total: money(preview.total),
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
)
)]
async fn post_entry(
pub(super) async fn post_entry(
&self,
params: Parameters<PostEntryParams>,
) -> Result<Json<PostEntryOut>, String> {
@@ -368,20 +368,14 @@ impl BooksServer {
));
}
}
let mut receipt_required = self
.settings()
.policy
.receipt_required(store.ledger(), &entry);
// One function decides (plan P4, I-5): the engine's. A draft that
// joins a split group is covered too (anti-splitting).
let receipt_required =
ledger_core::evidence::requirement(store.ledger(), &entry, store.ledger().settings())
.is_required();
let receipts = preview_receipts(&store, p.entry_id)?;
// Anti-splitting: if this entry looks like part of a split group,
// the receipt policy applies to the group total.
let split_group = self
.settings()
.policy
let split_group = ledger_core::evidence::Policy::from_settings(store.ledger().settings())
.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 party_policy = self.settings().parties;
let party_id = store.ledger().entry_party(p.entry_id);
@@ -399,7 +393,8 @@ impl BooksServer {
} else {
Vec::new()
};
let out = PostEntryOut {
let mut out = PostEntryOut {
preview: None,
status: "preview".to_string(),
entry_id: p.entry_id,
date: entry.date().to_string(),
@@ -428,6 +423,26 @@ impl BooksServer {
.map(|(rule, approver)| ApprovalNeededOut { rule, approver })
.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) {
return Ok(Json(out));
}
@@ -480,38 +495,11 @@ impl BooksServer {
})
.to_string());
}
if receipt_missing {
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
)
};
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())?;
// The evidence gate, in the store (plan P4, I-5): the same
// decision the preview showed, made again at the moment it posts.
store
.post_entry_checked(p.entry_id)
.map_err(|e| receipt_required_text(e, &self.settings().policy))?;
fix_derived_shares(&mut store, &self.settings(), p.entry_id)?;
let mut posted = out;
posted.status = "posted".to_string();
@@ -560,7 +548,14 @@ impl BooksServer {
(l.account, side, l.amount)
})
.collect();
let digest = self.preview_of(
&store,
"books.reverse_entry",
p.confirmed.unwrap_or(false),
p.preview.as_deref(),
)?;
let out = ReverseEntryOut {
preview: Some(digest),
status: "preview".to_string(),
reversal_id: 0, // filled in on post
reverses: p.entry_id,
+8
View File
@@ -331,11 +331,18 @@ impl BooksServer {
})
.collect();
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 {
return Ok(Json(ConsolidateDuplicatesOut {
status: "preview".into(),
groups: out_groups,
withdrawn,
preview: Some(digest),
}));
}
for g in &groups {
@@ -373,6 +380,7 @@ impl BooksServer {
status: "consolidated".into(),
groups: out_groups,
withdrawn,
preview: Some(digest),
}))
}
}
+142
View File
@@ -7,6 +7,7 @@
use super::*;
use ledger_core::InstrumentId;
use ledger_store::StoreError;
/// The book's id, as the commands given to agents name it: its folder's
/// name in the data root.
@@ -34,6 +35,56 @@ pub(crate) fn named(
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
/// `[books]` cash, financing, and investing roles.
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
}
/// 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())?;
}
let mut out = PurgeTinOut {
preview: None,
status: "preview".into(),
party_id: id,
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) {
return Ok(Json(out));
}
+8
View File
@@ -94,6 +94,12 @@ impl BooksServer {
members,
})
};
let digest = self.preview_of(
&store,
"books.close_period",
p.confirmed,
p.preview.as_deref(),
)?;
if !p.confirmed {
// Preview: run the real close on a copy of the ledger, so the
// preview is exactly what confirming would post.
@@ -107,6 +113,7 @@ impl BooksServer {
closing_entry: None,
lines: describe(&scratch, closing),
members: split(&scratch),
preview: Some(digest),
}));
}
let closing = store
@@ -119,6 +126,7 @@ impl BooksServer {
closing_entry: closing,
lines,
members: split(store.ledger()),
preview: Some(digest),
}))
}
@@ -266,7 +266,10 @@ impl BooksServer {
cleared_lines,
problems,
reconciliation: None,
preview: None,
};
let digest = self.preview_of(&store, "books.reconcile", confirmed, p.preview.as_deref())?;
out.preview = Some(digest);
if !confirmed {
return Ok(Json(out));
}
+147 -16
View File
@@ -26,11 +26,14 @@ fn control_problems(out: &ControlCheckOut) -> Vec<String> {
v
}
/// Net movement of one account over entries in `[from, to]`, normal side.
fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal {
let movement: Decimal = ledger
.posted_entries()
.filter(|e| e.date() >= from && e.date() <= to)
/// An account's net movement over `entries` in its normal direction.
fn movement_over(
ledger: &Ledger,
account: AccountId,
entries: &[&ledger_core::JournalEntry],
) -> Decimal {
let movement: Decimal = entries
.iter()
.flat_map(|e| e.lines())
.filter(|l| l.account == account)
.map(|l| match l.side {
@@ -83,6 +86,7 @@ fn cash_flow_out(stmt: &ledger_core::CashFlowStatement) -> CashFlowOut {
};
let r = &stmt.operating_reconciliation;
CashFlowOut {
as_of_status: None,
method: "direct".to_string(),
start: stmt.start.to_string(),
end: stmt.end.to_string(),
@@ -278,8 +282,10 @@ impl BooksServer {
) -> Result<Json<TrialBalanceOut>, String> {
let store = self.lock()?;
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);
Ok(Json(TrialBalanceOut {
as_of_status,
as_of: as_of.to_string(),
lines: tb
.lines
@@ -322,7 +328,9 @@ impl BooksServer {
p.end.as_deref(),
class_id,
)?;
let as_of_status = self.report_status(&store, pl.end, p.strict.unwrap_or(false))?;
Ok(Json(ProfitAndLossOut {
as_of_status,
start: pl.start.to_string(),
end: pl.end.to_string(),
revenue: money(pl.total_revenue),
@@ -349,7 +357,9 @@ impl BooksServer {
let p = params.0;
let store = self.lock()?;
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 {
as_of_status,
as_of: bs.as_of.to_string(),
assets: money(bs.total_assets),
liabilities: money(bs.total_liabilities),
@@ -386,7 +396,10 @@ impl BooksServer {
&p.financing_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(
@@ -758,19 +771,36 @@ impl BooksServer {
params: Parameters<DigestParams>,
) -> Result<Json<DigestOut>, String> {
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 as_of = p.as_of.as_deref().map(parse_date).transpose()?;
let end = as_of.unwrap_or(today);
let store = self.lock()?;
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();
for e in ledger.posted_entries() {
if e.date() < since {
continue;
}
for e in &selected {
let receipts = store
.list_attachments(Some(e.id()))
.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 {
entry_id: e.id(),
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)));
// 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 zero_net_accounts = Vec::new();
for a in ledger.chart().all() {
if a.placeholder {
continue;
}
let m = movement_in(ledger, a.id, since, today);
let m = movement_over(ledger, a.id, &in_range);
if !m.is_zero() {
totals.push(DigestTotal {
code: a.code.clone(),
name: a.name.clone(),
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));
let stale_rules = ledger_core::rules::stale_rules(today)
.into_iter()
@@ -811,7 +861,9 @@ impl BooksServer {
.tail_repairs()
.map_err(|e| e.to_string())?
.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.
.map(|(at, bytes, saved_as)| JournalRepair {
at,
@@ -832,11 +884,40 @@ impl BooksServer {
today,
&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 {
since_seq: p.since_seq,
head_seq: store.head().0,
zero_net_accounts,
drafts_by_state,
reconciliation_tracked,
never_reconciled,
unreconciled_accounts,
uncategorized_accounts,
control_problems,
since: since.to_string(),
since: since.map(|d| d.to_string()).unwrap_or_default(),
entries,
totals_by_account: totals,
stale_rules,
@@ -946,6 +1027,7 @@ impl BooksServer {
let store = self.lock()?;
let ledger = store.ledger();
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).
let only = match &p.class {
Some(c) => Some(resolve_class(&store, c)?),
@@ -1018,6 +1100,7 @@ impl BooksServer {
})
.collect();
Ok(Json(ProfitAndLossByClassOut {
as_of_status,
start: start.to_string(),
end: end.to_string(),
classes,
@@ -1063,7 +1146,55 @@ impl BooksServer {
) -> Result<Json<ExportCpaPackageOut>, String> {
let p = params.0;
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);
// The vault, opened once and never creating a key: locked, each
// 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())?
.1)
};
let digest = self.preview_of(
&store,
"books.run_schedules",
p.confirmed,
p.preview.as_deref(),
)?;
if !p.confirmed {
let postings = due
.into_iter()
@@ -205,6 +211,7 @@ impl BooksServer {
status: "preview".to_string(),
postings,
skipped,
preview: Some(digest),
}));
}
let mut postings = Vec::new();
@@ -225,6 +232,7 @@ impl BooksServer {
status: "posted".to_string(),
postings,
skipped,
preview: Some(digest),
}))
}
+43 -3
View File
@@ -296,6 +296,7 @@ fn payment_out(
_ => (None, None),
};
Ok(Json(PaymentOut {
preview: None,
entry_id,
documents,
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)")]
impl BooksServer {
#[tool(
@@ -324,9 +339,25 @@ impl BooksServer {
let p = params.0;
let mut store = self.lock()?;
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;
out.warnings = warnings;
out.preview = Some(digest);
Ok(Json(out))
}
@@ -346,9 +377,18 @@ impl BooksServer {
) -> Result<Json<PaymentOut>, String> {
let p = params.0;
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())?;
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(
+10 -1
View File
@@ -18,6 +18,7 @@ fn write_off_out(
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("invoice {invoice} not found"))?;
Ok(WriteOffInvoiceOut {
preview: None,
entry_id,
document: WriteOffDocumentOut {
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) {
// The engine on a clone: every refusal the post would give, and
// the figures the write-off would leave. Nothing is written.
@@ -84,7 +91,9 @@ impl BooksServer {
probe
.write_off(p.invoice, date, amount, &p.reason)
.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() {
Some(upload_id) => {
+337 -8
View File
@@ -191,6 +191,11 @@ pub struct ListSchedulesOut {
/// Parameters for `books.run_schedules`.
#[derive(Debug, Deserialize, JsonSchema)]
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).
pub through: Option<String>,
/// Without true, a preview: nothing posts.
@@ -227,6 +232,10 @@ pub struct ScheduleSkippedOut {
/// Result of `books.run_schedules`.
#[derive(Debug, Serialize, JsonSchema)]
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".
pub status: String,
/// Postings due (preview) or made.
@@ -1000,6 +1009,12 @@ pub struct TrialBalanceLineOut {
/// Parameters for `books.trial_balance`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
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`]).
#[serde(default)]
#[schemars(description = AS_OF_DOC)]
@@ -1009,6 +1024,10 @@ pub struct TrialBalanceParams {
/// Result of `books.trial_balance`.
#[derive(Debug, Serialize, JsonSchema)]
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.
pub as_of: String,
/// Lines in code order.
@@ -1024,6 +1043,12 @@ pub struct TrialBalanceOut {
/// Parameters for `books.profit_and_loss`.
#[derive(Debug, Deserialize, JsonSchema)]
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`]).
#[schemars(description = START_DOC)]
pub start: Option<String>,
@@ -1039,6 +1064,10 @@ pub struct ProfitAndLossParams {
/// Result of `books.profit_and_loss`.
#[derive(Debug, Serialize, JsonSchema)]
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.
pub start: String,
/// Period end, YYYY-MM-DD.
@@ -1069,6 +1098,12 @@ pub struct StatementLineOut {
/// Parameters for `books.cash_flow`.
#[derive(Debug, Deserialize, JsonSchema)]
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`]).
#[schemars(description = START_DOC)]
pub start: Option<String>,
@@ -1229,6 +1264,10 @@ pub struct CashFlowWarningOut {
/// Result of `books.cash_flow`; `books.export` writes the same JSON.
#[derive(Debug, Serialize, JsonSchema)]
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).
pub method: String,
/// Period start, YYYY-MM-DD.
@@ -1358,6 +1397,12 @@ pub struct ImportChartCsvOut {
/// Parameters for `books.balance_sheet`.
#[derive(Debug, Deserialize, JsonSchema)]
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`]).
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
@@ -1366,6 +1411,10 @@ pub struct BalanceSheetParams {
/// Result of `books.balance_sheet`.
#[derive(Debug, Serialize, JsonSchema)]
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.
pub as_of: String,
/// Total assets, decimal string.
@@ -1561,6 +1610,11 @@ pub struct PreviewReceipt {
/// Parameters for `books.post_entry`.
#[derive(Debug, Deserialize, JsonSchema)]
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.
pub entry_id: u64,
/// 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.
#[derive(Debug, Serialize, JsonSchema)]
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".
pub status: String,
/// The entry id.
@@ -1652,6 +1710,11 @@ pub struct PartySuggestionOut {
/// Parameters for `books.reverse_entry`.
#[derive(Debug, Deserialize, JsonSchema)]
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.
pub entry_id: u64,
/// Date of the reversing entry, YYYY-MM-DD.
@@ -1665,6 +1728,10 @@ pub struct ReverseEntryParams {
/// Result of `books.reverse_entry`.
#[derive(Debug, Serialize, JsonSchema)]
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".
pub status: String,
/// The new reversing entry's id.
@@ -2040,8 +2107,19 @@ pub struct SearchEntriesOut {
/// Parameters for `books.digest`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DigestParams {
/// Include entries posted on or after this date, YYYY-MM-DD.
pub since: String,
/// Include entries dated on or after this date, YYYY-MM-DD (by entry
/// 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.
@@ -2080,8 +2158,27 @@ pub struct DigestTotal {
/// Result of `books.digest`: what changed, for human review.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DigestOut {
/// The since date, YYYY-MM-DD.
/// The since date, YYYY-MM-DD (empty when asked by `since_seq`).
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.
pub entries: Vec<DigestEntry>,
/// Net movement per account over the period.
@@ -2117,7 +2214,7 @@ pub struct DigestOut {
}
/// An account due for reconciliation (`books.digest`).
#[derive(Debug, Serialize, JsonSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct UnreconciledAccountOut {
/// Account code.
pub code: String,
@@ -2741,6 +2838,11 @@ pub struct SkippedDepreciation {
/// Parameters for `books.dispose_asset`.
#[derive(Debug, Deserialize, JsonSchema)]
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`).
pub asset_id: Option<u64>,
/// The asset's tag, instead of its id.
@@ -2771,6 +2873,10 @@ pub struct DisposeAssetParams {
/// Result of `books.dispose_asset`.
#[derive(Debug, Serialize, JsonSchema)]
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".
pub status: String,
/// Asset id.
@@ -2864,6 +2970,11 @@ pub struct CreatePeriodOut {
/// Parameters for `books.close_period`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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.
pub period_id: u64,
/// Without confirmed=true, returns a preview (closing lines) and closes
@@ -2875,6 +2986,10 @@ pub struct ClosePeriodParams {
/// Output of `books.close_period`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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".
pub status: String,
/// The period id.
@@ -2983,6 +3098,11 @@ pub struct MarkClearedOut {
/// `statement_balance` for a check against any balance.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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
/// statement_attachment, optional (it must be the statement's).
pub account: Option<String>,
@@ -3021,6 +3141,10 @@ pub struct ReconcileLineOut {
/// Output of `books.reconcile`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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).
pub status: String,
/// Account code.
@@ -3134,6 +3258,11 @@ pub struct BillToParam {
/// `confirmed`, and posts with it (A-1).
#[derive(Debug, Clone, Deserialize, JsonSchema)]
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
/// that two customers answer to is refused.
pub customer: Option<String>,
@@ -3164,6 +3293,11 @@ pub struct CreateInvoiceParams {
/// and posts with it.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
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
/// two vendors answer to is refused.
pub vendor: Option<String>,
@@ -3240,6 +3374,15 @@ pub struct FeeParam {
/// Parameters for `books.receive_payment` / `books.pay_bill`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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
/// of `invoice` / `bill`.
#[serde(default)]
@@ -3311,6 +3454,10 @@ pub struct PaidDocumentOut {
/// Output of `books.receive_payment` / `books.pay_bill`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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.
pub entry_id: u64,
/// Every document it settled, in application order.
@@ -4165,6 +4312,10 @@ pub struct ClassPnlOut {
/// Output of `books.profit_and_loss_by_class`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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.
pub start: String,
/// Period end, YYYY-MM-DD.
@@ -4180,6 +4331,12 @@ pub struct ProfitAndLossByClassOut {
/// Parameters for `books.export_cpa_package`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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
/// fiscal year that holds it, through it.
#[schemars(description = AS_OF_DOC)]
@@ -4477,6 +4634,11 @@ pub struct GetW9Out {
/// Parameters for `books.purge_tin`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
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.
pub party: String,
/// Why (retention period over, the payee asked, a wrong TIN):
@@ -4490,6 +4652,10 @@ pub struct PurgeTinParams {
/// Result of `books.purge_tin`.
#[derive(Debug, Clone, Serialize, JsonSchema)]
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".
pub status: String,
/// The party.
@@ -5174,9 +5340,11 @@ pub struct IssuedFileOut {
/// posted invoice.
#[derive(Debug, Clone, Serialize, JsonSchema)]
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")]
pub preview: Option<bool>,
pub preview: Option<String>,
/// The invoice's id (confirm).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
@@ -5219,9 +5387,11 @@ pub struct InvoiceOut {
/// posted bill.
#[derive(Debug, Clone, Serialize, JsonSchema)]
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")]
pub preview: Option<bool>,
pub preview: Option<String>,
/// The bill's id (confirm).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
@@ -5331,6 +5501,11 @@ pub struct MarkSentOut {
/// Parameters for `books.write_off_invoice`.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
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).
pub invoice: u64,
/// 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`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
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).
pub entry_id: u64,
/// The invoice's derived figures after it.
@@ -5527,6 +5706,11 @@ pub struct DuplicateGroupOut {
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
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.
pub confirmed: Option<bool>,
}
@@ -5534,6 +5718,10 @@ pub struct ConsolidateDuplicatesParams {
/// Result of `books.consolidate_duplicates`.
#[derive(Debug, Serialize, JsonSchema)]
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".
pub status: String,
/// Each set of attachments holding one file's bytes.
@@ -5541,3 +5729,144 @@ pub struct ConsolidateDuplicatesOut {
/// How many attachments were (or would be) withdrawn.
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,
}