From e5f63b0d485ffcbcbea3915f8520c2eb665d9bfc Mon Sep 17 00:00:00 2001 From: Stephen Waits <1+swaits@noreply.git.swaits.com> Date: Sun, 11 Oct 2026 01:37:27 +0000 Subject: [PATCH] First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) --- crates/ledger-mcp/src/server.rs | 1089 +++++++++++++++++++++++++++++++ 1 file changed, 1089 insertions(+) create mode 100644 crates/ledger-mcp/src/server.rs diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs new file mode 100644 index 0000000..db9d8de --- /dev/null +++ b/crates/ledger-mcp/src/server.rs @@ -0,0 +1,1089 @@ +//! The MCP server: narrow typed tools over the store, with the receipt +//! policy enforced server-side. + +use crate::policy::ReceiptPolicy; +use crate::types::*; +use chrono::NaiveDate; +use ledger_core::{AccountId, AccountType, JournalEntry, Ledger, Money, Side, reports}; +use ledger_store::Store; +use rmcp::handler::server::wrapper::{Json, Parameters}; +use rmcp::model::{Implementation, ServerCapabilities, ServerConfig}; +use rmcp::{ServerHandler, tool, tool_handler, tool_router}; +use rust_decimal::Decimal; +use std::sync::{Arc, Mutex}; + +/// The agent operating contract, delivered via initialize. +const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact."; + +/// Max receipt/document upload: 25 MB. +const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; + +/// The server. One store, one receipt policy, shared across tool calls. +/// `Store` is `!Sync`, so access is serialized through a mutex; the server +/// is single-writer by construction. +#[derive(Clone)] +pub struct BooksServer { + store: Arc>, + policy: ReceiptPolicy, +} + +impl BooksServer { + /// Build the server around an open store. + pub fn new(store: Store, policy: ReceiptPolicy) -> Self { + Self { + store: Arc::new(Mutex::new(store)), + policy, + } + } + + fn lock(&self) -> Result, String> { + self.store + .lock() + .map_err(|_| "store lock poisoned".to_string()) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn parse_date(s: &str) -> Result { + NaiveDate::parse_from_str(s, "%Y-%m-%d") + .map_err(|_| format!("bad date {s:?}: expected YYYY-MM-DD")) +} + +fn parse_amount(s: &str) -> Result { + s.parse::() + .map_err(|_| format!("bad amount {s:?}: expected a decimal string like \"123.45\"")) +} + +fn parse_side(s: &str) -> Result { + match s.to_lowercase().as_str() { + "debit" => Ok(Side::Debit), + "credit" => Ok(Side::Credit), + _ => Err(format!("bad side {s:?}: expected \"debit\" or \"credit\"")), + } +} + +fn parse_account_type(s: &str) -> Result { + match s.to_lowercase().as_str() { + "asset" => Ok(AccountType::Asset), + "liability" => Ok(AccountType::Liability), + "equity" => Ok(AccountType::Equity), + "revenue" => Ok(AccountType::Revenue), + "expense" => Ok(AccountType::Expense), + _ => Err(format!( + "bad account_type {s:?}: expected asset, liability, equity, revenue, or expense" + )), + } +} + +/// Resolve an account reference: a numeric id, or an account code. +fn resolve_account(store: &Store, account: &str) -> Result { + if let Ok(id) = account.parse::() + && store.ledger().chart().get(id).is_some() + { + return Ok(id); + } + store + .ledger() + .chart() + .find_by_code(account) + .map(|a| a.id) + .ok_or_else(|| format!("unknown account {account:?}: use an id or code from books.chart")) +} + +fn money(n: Decimal) -> String { + // Decimal's Display never emits floats or "-0" for exact values. + n.to_string() +} + +/// Preview lines with resulting balances for raw (account, side, amount) lines. +fn preview_lines_for( + store: &Store, + lines: &[(AccountId, Side, Money)], +) -> Result, String> { + let ledger = store.ledger(); + let mut out = Vec::with_capacity(lines.len()); + for (account_id, side, amount) in lines { + let account = ledger + .chart() + .get(*account_id) + .ok_or_else(|| format!("unknown account {account_id}"))?; + let before = ledger + .account_balance(*account_id) + .map_err(|e| e.to_string())?; + let normal = account.account_type.normal_side(); + let delta = if *side == normal { + amount.amount + } else { + -amount.amount + }; + let after = before + delta; + out.push(PreviewLine { + account_id: *account_id, + code: account.code.clone(), + name: account.name.clone(), + side: match side { + Side::Debit => "debit".to_string(), + Side::Credit => "credit".to_string(), + }, + amount: money(amount.amount), + balance_before: money(if before.is_zero() { + Decimal::ZERO + } else { + before + }), + balance_after: money(if after.is_zero() { + Decimal::ZERO + } else { + after + }), + }); + } + Ok(out) +} + +/// Preview lines with resulting balances for an entry. +fn preview_lines(store: &Store, entry: &JournalEntry) -> Result, String> { + let lines: Vec<(AccountId, Side, Money)> = entry + .lines() + .iter() + .map(|l| (l.account, l.side, l.amount)) + .collect(); + preview_lines_for(store, &lines) +} + +fn preview_receipts(store: &Store, entry_id: u64) -> Result, String> { + Ok(store + .list_attachments(Some(entry_id)) + .map_err(|e| e.to_string())? + .into_iter() + .map(|a| PreviewReceipt { + attachment_id: a.id, + file_name: a.file_name, + sha256: a.blob_sha, + }) + .collect()) +} + +/// Net movement of one account over entries on or before `through`, in the +/// account's normal side. +fn movement_through(ledger: &Ledger, account: AccountId, through: NaiveDate) -> Decimal { + let movement: Decimal = ledger + .posted_entries() + .filter(|e| e.date() <= through) + .flat_map(|e| e.lines()) + .filter(|l| l.account == account) + .map(|l| match l.side { + Side::Debit => l.amount.amount, + Side::Credit => -l.amount.amount, + }) + .sum(); + let account = ledger.chart().get(account).expect("account exists"); + match account.account_type.normal_side() { + Side::Debit => movement, + Side::Credit => -movement, + } +} + +/// 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) + .flat_map(|e| e.lines()) + .filter(|l| l.account == account) + .map(|l| match l.side { + Side::Debit => l.amount.amount, + Side::Credit => -l.amount.amount, + }) + .sum(); + let account = ledger.chart().get(account).expect("account exists"); + match account.account_type.normal_side() { + Side::Debit => movement, + Side::Credit => -movement, + } +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +#[tool_router] +impl BooksServer { + #[tool( + name = "books.chart", + description = "List the chart of accounts with current balances. Start here: every line you post must use a real account from this chart.", + annotations(read_only_hint = true) + )] + async fn chart(&self) -> Result, String> { + let store = self.lock()?; + let ledger = store.ledger(); + let mut accounts = Vec::new(); + let mut all: Vec<_> = ledger.chart().all().collect(); + all.sort_by(|a, b| a.code.cmp(&b.code)); + for a in all { + let balance = ledger.account_balance(a.id).map_err(|e| e.to_string())?; + accounts.push(ChartAccount { + id: a.id, + code: a.code.clone(), + name: a.name.clone(), + account_type: format!("{:?}", a.account_type).to_lowercase(), + parent: a.parent, + placeholder: a.placeholder, + balance: money(if balance.is_zero() { + Decimal::ZERO + } else { + balance + }), + }); + } + Ok(Json(ChartOut { + accounts, + base_currency: store.base_currency().code().to_string(), + })) + } + + #[tool( + name = "books.add_account", + description = "Add an account to the chart. Codes must be unique; check books.chart first." + )] + async fn add_account( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = store + .add_account( + &p.code, + &p.name, + parse_account_type(&p.account_type)?, + p.parent, + p.placeholder.unwrap_or(false), + ) + .map_err(|e| e.to_string())?; + Ok(Json(AddAccountOut { account_id: id })) + } + + #[tool( + name = "books.trial_balance", + description = "The trial balance: every account with a non-zero balance, debit and credit columns and totals. Debits must equal credits.", + annotations(read_only_hint = true) + )] + async fn trial_balance(&self) -> Result, String> { + let store = self.lock()?; + let tb = reports::trial_balance(store.ledger()); + Ok(Json(TrialBalanceOut { + lines: tb + .lines + .iter() + .map(|l| TrialBalanceLineOut { + code: l.code.clone(), + name: l.name.clone(), + debit: money(l.debit), + credit: money(l.credit), + }) + .collect(), + total_debits: money(tb.total_debits), + total_credits: money(tb.total_credits), + balanced: tb.balanced(), + })) + } + + #[tool( + name = "books.profit_and_loss", + description = "Profit and loss for a period: revenue, expenses, net income. Dates are YYYY-MM-DD; omit for all time.", + annotations(read_only_hint = true) + )] + async fn profit_and_loss( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let ledger = store.ledger(); + let start = match p.start { + Some(s) => parse_date(&s)?, + None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(), + }; + let end = match p.end { + Some(s) => parse_date(&s)?, + None => chrono::Utc::now().date_naive(), + }; + if end < start { + return Err("end is before start".to_string()); + } + let mut revenue = Decimal::ZERO; + let mut expenses = Decimal::ZERO; + for a in ledger.chart().all() { + if a.placeholder { + continue; + } + match a.account_type { + AccountType::Revenue => revenue += movement_in(ledger, a.id, start, end), + AccountType::Expense => expenses += movement_in(ledger, a.id, start, end), + _ => {} + } + } + Ok(Json(ProfitAndLossOut { + start: start.to_string(), + end: end.to_string(), + revenue: money(revenue), + expenses: money(expenses), + net_income: money(revenue - expenses), + })) + } + + #[tool( + name = "books.balance_sheet", + description = "The balance sheet as of a date: assets, liabilities, equity (including current earnings). Omit as_of for today.", + annotations(read_only_hint = true) + )] + async fn balance_sheet( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let ledger = store.ledger(); + let as_of = match p.as_of { + Some(s) => parse_date(&s)?, + None => chrono::Utc::now().date_naive(), + }; + let mut assets = Decimal::ZERO; + let mut liabilities = Decimal::ZERO; + let mut equity = Decimal::ZERO; + let mut revenue = Decimal::ZERO; + let mut expenses = Decimal::ZERO; + for a in ledger.chart().all() { + if a.placeholder { + continue; + } + let m = movement_through(ledger, a.id, as_of); + match a.account_type { + AccountType::Asset => assets += m, + AccountType::Liability => liabilities += m, + AccountType::Equity => equity += m, + AccountType::Revenue => revenue += m, + AccountType::Expense => expenses += m, + } + } + let current_earnings = revenue - expenses; + equity += current_earnings; + Ok(Json(BalanceSheetOut { + as_of: as_of.to_string(), + assets: money(assets), + liabilities: money(liabilities), + equity: money(equity), + current_earnings: money(current_earnings), + balanced: assets == liabilities + equity, + })) + } + + #[tool( + name = "books.draft_entry", + description = "Start a draft journal entry. Drafts are not journaled until posted; add lines with books.add_line." + )] + async fn draft_entry( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let id = store + .create_entry(parse_date(&p.date)?, &p.description) + .map_err(|e| e.to_string())?; + Ok(Json(DraftEntryOut { + entry_id: id, + status: "draft".to_string(), + })) + } + + #[tool( + name = "books.add_line", + description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting." + )] + async fn add_line( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let account = resolve_account(&store, &p.account)?; + let amount = parse_amount(&p.amount)?; + if amount <= Decimal::ZERO { + return Err("amount must be positive".to_string()); + } + let currency = store.base_currency(); + store + .add_line( + p.entry_id, + account, + parse_side(&p.side)?, + Money::new(amount, currency), + ) + .map_err(|e| e.to_string())?; + Ok(Json(AddLineOut { ok: true })) + } + + #[tool( + name = "books.post_entry", + description = "Post a draft entry. Without confirmed=true this returns a preview (lines, resulting balances, receipt checklist) and posts nothing. With confirmed=true it posts after enforcing the receipt policy." + )] + async fn post_entry( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let entry = store + .ledger() + .get_entry(p.entry_id) + .ok_or_else(|| format!("unknown entry {}", p.entry_id))? + .clone(); + if entry.status() != ledger_core::EntryStatus::Draft { + return Err(format!("entry {} is not a draft", p.entry_id)); + } + // Pure validation before anything is journaled. + entry + .validate(store.ledger().chart()) + .map_err(|e| e.to_string())?; + let total = ReceiptPolicy::entry_debits(&entry); + if let Some(expected) = p.expected_total { + let want = parse_amount(&expected)?; + if total != want { + return Err(format!( + "expected_total mismatch: entry totals {total}, expected {want}" + )); + } + } + let receipt_required = self.policy.receipt_required(store.ledger(), &entry); + let receipts = preview_receipts(&store, p.entry_id)?; + let receipt_missing = receipt_required && receipts.is_empty(); + let out = PostEntryOut { + status: "preview".to_string(), + entry_id: p.entry_id, + date: entry.date().to_string(), + description: entry.description().to_string(), + total: money(total), + lines: preview_lines(&store, &entry)?, + receipts, + receipt_required, + receipt_missing, + }; + if !p.confirmed.unwrap_or(false) { + return Ok(Json(out)); + } + if receipt_missing { + return Err(format!( + "receipt_required: entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting", + p.entry_id, + money(total), + self.policy.describe() + )); + } + store.post_entry(p.entry_id).map_err(|e| e.to_string())?; + let mut posted = out; + posted.status = "posted".to_string(); + Ok(Json(posted)) + } + + #[tool( + name = "books.reverse_entry", + description = "Reverse a posted entry with an offsetting entry. Without confirmed=true returns a preview; reversals are exempt from the receipt policy." + )] + async fn reverse_entry( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let orig = store + .ledger() + .get_entry(p.entry_id) + .ok_or_else(|| format!("unknown entry {}", p.entry_id))? + .clone(); + if orig.status() != ledger_core::EntryStatus::Posted { + return Err(format!("entry {} is not posted", p.entry_id)); + } + let date = parse_date(&p.date)?; + // Build the preview from the flipped lines without mutating. + let flipped: Vec<(AccountId, Side, Money)> = orig + .lines() + .iter() + .map(|l| { + let side = match l.side { + Side::Debit => Side::Credit, + Side::Credit => Side::Debit, + }; + (l.account, side, l.amount) + }) + .collect(); + let out = ReverseEntryOut { + status: "preview".to_string(), + reversal_id: 0, // filled in on post + reverses: p.entry_id, + lines: preview_lines_for(&store, &flipped)?, + }; + if !p.confirmed.unwrap_or(false) { + return Ok(Json(out)); + } + let reversal_id = store + .reverse_entry(p.entry_id, date, &p.description) + .map_err(|e| e.to_string())?; + let mut posted = out; + posted.status = "posted".to_string(); + posted.reversal_id = reversal_id; + Ok(Json(posted)) + } + + #[tool( + name = "books.attach_receipt", + description = "Attach a receipt (base64 file bytes) to an entry, or file it unlinked and link later with books.link_receipt. Files are content-addressed and deduplicated." + )] + async fn attach_receipt( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &p.content_base64, + ) + .map_err(|_| "content_base64 is not valid base64".to_string())?; + if bytes.len() > MAX_UPLOAD_BYTES { + return Err(format!( + "file is {} bytes; the limit is {MAX_UPLOAD_BYTES}", + bytes.len() + )); + } + let mut store = self.lock()?; + let (attachment_id, sha256, deduplicated) = store + .attach_receipt( + p.entry_id, + &p.file_name, + &p.mime_type, + &bytes, + p.description.as_deref(), + ) + .map_err(|e| e.to_string())?; + Ok(Json(AttachReceiptOut { + attachment_id, + sha256, + deduplicated, + })) + } + + #[tool( + name = "books.file_document", + description = "File a formation, legal, tax, or accounting document into the content-addressed store." + )] + async fn file_document( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + match p.category.as_str() { + "formation" | "legal" | "tax" | "accounting" | "other" => {} + _ => { + return Err(format!( + "bad category {:?}: expected formation, legal, tax, accounting, or other", + p.category + )); + } + } + let bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &p.content_base64, + ) + .map_err(|_| "content_base64 is not valid base64".to_string())?; + if bytes.len() > MAX_UPLOAD_BYTES { + return Err(format!( + "file is {} bytes; the limit is {MAX_UPLOAD_BYTES}", + bytes.len() + )); + } + let mut store = self.lock()?; + let (attachment_id, sha256, deduplicated) = store + .file_document( + &p.category, + &p.file_name, + &p.mime_type, + &bytes, + p.description.as_deref(), + ) + .map_err(|e| e.to_string())?; + Ok(Json(AttachReceiptOut { + attachment_id, + sha256, + deduplicated, + })) + } + + #[tool( + name = "books.link_receipt", + description = "Link a previously filed (unlinked) receipt to an entry." + )] + async fn link_receipt( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + store + .link_receipt(p.attachment_id, p.entry_id) + .map_err(|e| e.to_string())?; + Ok(Json(LinkReceiptOut { ok: true })) + } + + #[tool( + name = "books.list_receipts", + description = "List receipts and filed documents, optionally filtered to one entry.", + annotations(read_only_hint = true) + )] + async fn list_receipts( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let receipts = store + .list_attachments(p.entry_id) + .map_err(|e| e.to_string())? + .into_iter() + .map(|a| ReceiptOut { + attachment_id: a.id, + sha256: a.blob_sha, + entry_id: a.entry_id, + category: a.category, + file_name: a.file_name, + mime_type: a.mime_type, + size_bytes: a.size_bytes, + description: a.description, + }) + .collect(); + Ok(Json(ListReceiptsOut { receipts })) + } + + #[tool( + name = "books.get_receipt", + description = "Fetch a receipt or document's metadata and base64 bytes. Bytes are hash-verified on read.", + annotations(read_only_hint = true) + )] + async fn get_receipt( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let meta = store + .list_attachments(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|a| a.id == p.attachment_id) + .ok_or_else(|| format!("unknown attachment {}", p.attachment_id))?; + let bytes = store + .attachment_bytes(p.attachment_id) + .map_err(|e| e.to_string())?; + Ok(Json(GetReceiptOut { + attachment_id: meta.id, + sha256: meta.blob_sha, + entry_id: meta.entry_id, + category: meta.category, + file_name: meta.file_name, + mime_type: meta.mime_type, + size_bytes: meta.size_bytes, + content_base64: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &bytes, + ), + })) + } + + #[tool( + name = "books.get_entry", + description = "Full detail of one entry: lines, totals, status, and attached receipts.", + annotations(read_only_hint = true) + )] + async fn get_entry( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let entry = store + .ledger() + .get_entry(p.entry_id) + .ok_or_else(|| format!("unknown entry {}", p.entry_id))? + .clone(); + let lines = entry + .lines() + .iter() + .map(|l| { + let a = store.ledger().chart().get(l.account).unwrap(); + EntryLineOut { + account_id: l.account, + code: a.code.clone(), + name: a.name.clone(), + side: match l.side { + Side::Debit => "debit".to_string(), + Side::Credit => "credit".to_string(), + }, + amount: money(l.amount.amount), + } + }) + .collect(); + Ok(Json(GetEntryOut { + entry_id: p.entry_id, + date: entry.date().to_string(), + description: entry.description().to_string(), + status: match entry.status() { + ledger_core::EntryStatus::Draft => "draft".to_string(), + ledger_core::EntryStatus::Posted => "posted".to_string(), + }, + reversal_of: entry.reversal_of(), + lines, + total: money(ReceiptPolicy::entry_debits(&entry)), + receipts: preview_receipts(&store, p.entry_id)?, + })) + } + + #[tool( + name = "books.search_entries", + description = "Search posted entries by account, date range, and description text. Newest first.", + annotations(read_only_hint = true) + )] + async fn search_entries( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let rows = store + .search_entries( + p.account_id, + p.date_from.as_deref(), + p.date_to.as_deref(), + p.text.as_deref(), + p.limit.unwrap_or(50).min(500), + p.offset.unwrap_or(0), + ) + .map_err(|e| e.to_string())?; + let mut entries = Vec::with_capacity(rows.len()); + for r in rows { + let e = store + .ledger() + .get_entry(r.id) + .ok_or_else(|| format!("entry {} missing from ledger", r.id))?; + let total = ReceiptPolicy::entry_debits(e); + let receipt_count = store + .list_attachments(Some(r.id)) + .map_err(|e| e.to_string())? + .len() as u64; + entries.push(EntrySummary { + entry_id: r.id, + date: r.date, + description: r.description, + total: money(total), + reversal_of: r.reversal_of, + receipt_count, + }); + } + Ok(Json(SearchEntriesOut { entries })) + } + + #[tool( + name = "books.digest", + description = "What changed since a date: entries posted, per-account movement, and receipt coverage. Built for human review.", + annotations(read_only_hint = true) + )] + async fn digest(&self, params: Parameters) -> Result, String> { + let p = params.0; + let since = parse_date(&p.since)?; + let today = chrono::Utc::now().date_naive(); + let store = self.lock()?; + let ledger = store.ledger(); + let mut entries = Vec::new(); + for e in ledger.posted_entries() { + if e.date() < since { + continue; + } + let receipts = store + .list_attachments(Some(e.id())) + .map_err(|e| e.to_string())?; + let required = self.policy.receipt_required(ledger, e); + entries.push(DigestEntry { + entry_id: e.id(), + date: e.date().to_string(), + description: e.description().to_string(), + total: money(ReceiptPolicy::entry_debits(e)), + reversal_of: e.reversal_of(), + receipt_count: receipts.len() as u64, + receipt_required: required, + receipt_missing: required && receipts.is_empty(), + }); + } + entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id))); + let mut totals = Vec::new(); + for a in ledger.chart().all() { + if a.placeholder { + continue; + } + let m = movement_in(ledger, a.id, since, today); + if !m.is_zero() { + totals.push(DigestTotal { + code: a.code.clone(), + name: a.name.clone(), + movement: money(m), + }); + } + } + totals.sort_by(|a, b| a.code.cmp(&b.code)); + Ok(Json(DigestOut { + since: since.to_string(), + entries, + totals_by_account: totals, + })) + } + + #[tool( + name = "books.export", + description = "Export a report as CSV or JSON. Reports: journal (every posted entry with lines), trial_balance, profit_and_loss, balance_sheet.", + annotations(read_only_hint = true) + )] + async fn export(&self, params: Parameters) -> Result, String> { + let p = params.0; + let format = p.format.to_lowercase(); + if format != "csv" && format != "json" { + return Err(format!( + "bad format {:?}: expected \"csv\" or \"json\"", + p.format + )); + } + let report = p.report.to_lowercase(); + let content = match report.as_str() { + "journal" => self.export_journal(&format)?, + "trial_balance" => self.export_trial_balance(&format)?, + "profit_and_loss" => self.export_pnl(&format, p.start.as_deref(), p.end.as_deref())?, + "balance_sheet" => self.export_balance_sheet(&format, p.as_of.as_deref())?, + _ => { + return Err(format!( + "bad report {:?}: expected journal, trial_balance, profit_and_loss, or balance_sheet", + p.report + )); + } + }; + Ok(Json(ExportOut { + format, + report, + content, + })) + } +} + +// --------------------------------------------------------------------------- +// Export builders (called under the tool lock via &self) +// --------------------------------------------------------------------------- + +impl BooksServer { + fn export_journal(&self, format: &str) -> Result { + let store = self.lock()?; + let ledger = store.ledger(); + if format == "json" { + let rows: Vec = ledger + .posted_entries() + .map(|e| { + serde_json::json!({ + "entry_id": e.id(), + "date": e.date().to_string(), + "description": e.description(), + "reversal_of": e.reversal_of(), + "lines": e.lines().iter().map(|l| { + let a = ledger.chart().get(l.account).unwrap(); + serde_json::json!({ + "account_code": a.code, + "account_name": a.name, + "side": match l.side { Side::Debit => "debit", Side::Credit => "credit" }, + "amount": money(l.amount.amount), + "currency": l.amount.currency.code(), + }) + }).collect::>(), + }) + }) + .collect(); + return serde_json::to_string_pretty(&rows).map_err(|e| e.to_string()); + } + let mut csv = String::from( + "entry_id,date,description,reversal_of,account_code,account_name,side,amount,currency\n", + ); + for e in ledger.posted_entries() { + for l in e.lines() { + let a = ledger.chart().get(l.account).unwrap(); + csv.push_str(&format!( + "{},{},{},{},{},{},{},{},{}\n", + e.id(), + e.date(), + csv_escape(e.description()), + e.reversal_of().map(|v| v.to_string()).unwrap_or_default(), + a.code, + csv_escape(&a.name), + match l.side { + Side::Debit => "debit", + Side::Credit => "credit", + }, + money(l.amount.amount), + l.amount.currency.code(), + )); + } + } + Ok(csv) + } + + fn export_trial_balance(&self, format: &str) -> Result { + let store = self.lock()?; + let tb = reports::trial_balance(store.ledger()); + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "lines": tb.lines.iter().map(|l| serde_json::json!({ + "code": l.code, "name": l.name, + "debit": money(l.debit), "credit": money(l.credit), + })).collect::>(), + "total_debits": money(tb.total_debits), + "total_credits": money(tb.total_credits), + "balanced": tb.balanced(), + })) + .map_err(|e| e.to_string()); + } + let mut csv = String::from("code,name,debit,credit\n"); + for l in &tb.lines { + csv.push_str(&format!( + "{},{},{},{}\n", + l.code, + csv_escape(&l.name), + money(l.debit), + money(l.credit) + )); + } + csv.push_str(&format!( + "TOTAL,,{},{}", + money(tb.total_debits), + money(tb.total_credits) + )); + Ok(csv) + } + + fn export_pnl( + &self, + format: &str, + start: Option<&str>, + end: Option<&str>, + ) -> Result { + let store = self.lock()?; + let ledger = store.ledger(); + let start_d = match start { + Some(s) => parse_date(s)?, + None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(), + }; + let end_d = match end { + Some(s) => parse_date(s)?, + None => chrono::Utc::now().date_naive(), + }; + let mut revenue = Decimal::ZERO; + let mut expenses = Decimal::ZERO; + for a in ledger.chart().all() { + if a.placeholder { + continue; + } + match a.account_type { + AccountType::Revenue => revenue += movement_in(ledger, a.id, start_d, end_d), + AccountType::Expense => expenses += movement_in(ledger, a.id, start_d, end_d), + _ => {} + } + } + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "start": start_d.to_string(), "end": end_d.to_string(), + "revenue": money(revenue), "expenses": money(expenses), + "net_income": money(revenue - expenses), + })) + .map_err(|e| e.to_string()); + } + Ok(format!( + "metric,amount\nrevenue,{}\nexpenses,{}\nnet_income,{}\n", + money(revenue), + money(expenses), + money(revenue - expenses) + )) + } + + fn export_balance_sheet(&self, format: &str, as_of: Option<&str>) -> Result { + let store = self.lock()?; + let ledger = store.ledger(); + let as_of_d = match as_of { + Some(s) => parse_date(s)?, + None => chrono::Utc::now().date_naive(), + }; + let mut assets = Decimal::ZERO; + let mut liabilities = Decimal::ZERO; + let mut equity = Decimal::ZERO; + let mut revenue = Decimal::ZERO; + let mut expenses = Decimal::ZERO; + for a in ledger.chart().all() { + if a.placeholder { + continue; + } + let m = movement_through(ledger, a.id, as_of_d); + match a.account_type { + AccountType::Asset => assets += m, + AccountType::Liability => liabilities += m, + AccountType::Equity => equity += m, + AccountType::Revenue => revenue += m, + AccountType::Expense => expenses += m, + } + } + let current_earnings = revenue - expenses; + equity += current_earnings; + if format == "json" { + return serde_json::to_string_pretty(&serde_json::json!({ + "as_of": as_of_d.to_string(), + "assets": money(assets), "liabilities": money(liabilities), + "equity": money(equity), "current_earnings": money(current_earnings), + "balanced": assets == liabilities + equity, + })) + .map_err(|e| e.to_string()); + } + Ok(format!( + "metric,amount\nassets,{}\nliabilities,{}\nequity,{}\ncurrent_earnings,{}\n", + money(assets), + money(liabilities), + money(equity), + money(current_earnings) + )) + } +} + +fn csv_escape(s: &str) -> String { + if s.contains([',', '"', '\n']) { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +// --------------------------------------------------------------------------- +// ServerHandler +// --------------------------------------------------------------------------- + +#[tool_handler] +impl ServerHandler for BooksServer { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions(INSTRUCTIONS) + .with_server_info(Implementation::new( + "wanderbooks", + env!("CARGO_PKG_VERSION"), + )) + } +}