feat(drafts): the drafts tools, the preview digest on every confirm, the digest tool (P4)
Accounting reason: a confirm must act on the thing that was previewed. Every tool with confirmed (post_entry, reverse_entry, run_schedules, dispose_asset, reconcile, close_period, create_invoice, create_bill, write_off_invoice, purge_tin, consolidate_duplicates, and the S6 settlement tools receive_payment and pay_bill, which gain confirmed and preview on a clone of the ledger) returns preview (SHA-256 of the canonical preview) and refuses a confirm whose preview is missing or stale (stale_preview, before anything is written). A draft's digest is its own state and the settings seq; an operation's is the tool, its arguments as sent, the settings seq and the journal head seq, so another session's write forces a re-preview (D-50). New tools books.list_drafts, books.set_draft_state, books.discard_draft, books.preview_entry; reports carry as_of_status (drafts open on or before their end, unreconciled accounts, settings problems) and take strict; the CPA package is refused while a draft dated in it is open unless allow_open_drafts gives a reason, journaled as a decision; books.digest takes since_seq (what was posted since a journal line, backdated entries included: defect 6) and lists zero_net_accounts, drafts_by_state and the reconciliation split. Needs attention gains 'draft awaiting receipt' beside 'draft not posted'; the drafts handbook is rewritten. Every over-the-wire harness confirms the way an agent does. Docs: mcp-interface, rules B-43, parity, testing (with the For-P12 paragraph), architecture, and the unit's As-built block. Tests: drafts.rs (a_draft_waits_for_its_receipt_and_posts, the_gate_moved_and_the_mcp_error_is_unchanged, a_reversal_needs_no_receipt_of_its_own, discard_unblocks_the_close_over_the_wire, report_warns_on_draft_before_as_of, cpa_package_refuses_open_draft, stale_preview_refused, every_confirm_tool_takes_a_preview, digest_since_seq_is_posting_time, legacy_reversed_replays_identically, gui_and_server_gate_agree), attention_parity.rs, format_upgrade.rs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
7299cf90f2
commit
6a66a2855b
49 files changed
+2526
-202
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user