//! The MCP server: narrow typed tools over the store, with the receipt //! policy enforced server-side. use crate::company::Company; use crate::policy::ReceiptPolicy; use crate::types::*; use chrono::NaiveDate; use ledger_core::{ AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry, Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value, depreciation_schedule, reports, }; use ledger_import::ReportMeta; use ledger_store::Store; use rmcp::handler::server::wrapper::{Json, Parameters}; use rmcp::model::{ Implementation, ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig, }; use rmcp::service::{RequestContext, RoleServer}; 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- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\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.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA."; /// 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, company: Company, } 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, company: Company::default(), } } /// Build the server around a shared store (for HTTP: each connection /// gets a handle to the same store). pub fn new_shared(store: Arc>, policy: ReceiptPolicy) -> Self { Self { store, policy, company: Company::default(), } } /// Set the company (name for report headers, fiscal year). pub fn with_company(mut self, company: Company) -> Self { self.company = company; self } /// Header and provenance for a rendered report. fn report_meta(&self, store: &Store) -> ReportMeta { ReportMeta { company: self.company.name.clone(), basis: self.company.basis.clone(), prepared_on: today(), journal_seq: store.journal_seq(), } } /// Members as reports name them (empty unless a multi-member LLC). fn report_members(&self) -> Vec { self.company .members .iter() .map(|m| ledger_import::Member { name: m.name.clone(), capital_code: m.capital_account.clone(), }) .collect() } /// Members with their capital accounts resolved in the chart, for the /// statement of members' equity. fn member_capitals(&self, ledger: &Ledger) -> Result, String> { if self.company.members.is_empty() { return Err( "no members configured: add [[company.members]] (name, share, capital_account) to wanderbooks.toml" .to_string(), ); } self.company .members .iter() .map(|m| { let account = ledger .chart() .find_by_code(&m.capital_account) .ok_or_else(|| { format!( "member {}: unknown capital account {}", m.name, m.capital_account ) })?; Ok(ledger_core::MemberCapital { name: m.name.clone(), capital_account: account.id, share: m.share, }) }) .collect() } /// The fiscal year's earnings through `as_of`, for the balance sheet: /// net income (loss) year to date (closing entries excluded), and the /// part of it still unclosed (closing entries included, so months /// already closed into equity drop out). fn year_earnings( &self, ledger: &Ledger, as_of: NaiveDate, ) -> Result { let (fy_start, _) = self.company.fiscal_year_containing(as_of); let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None) .map_err(|e| e.to_string())? .net_income(); let mut unclosed = Decimal::ZERO; for a in ledger.chart().all().filter(|a| !a.placeholder) { match a.account_type { AccountType::Revenue => unclosed += movement_in(ledger, a.id, fy_start, as_of), AccountType::Expense => unclosed -= movement_in(ledger, a.id, fy_start, as_of), _ => {} } } Ok(ledger_import::YearEarnings { net_income_ytd, unclosed, }) } fn lock(&self) -> Result, String> { self.store .lock() .map_err(|_| "store lock poisoned".to_string()) } /// Fail with the structured `receipt_required` error when the receipt /// policy requires this posted entry to keep at least one receipt. /// Drafts pass (posting enforces the policy later); reversals pass (they /// are exempt: the original carries the evidence). fn ensure_receipt_not_required(&self, store: &Store, entry_id: u64) -> Result<(), String> { let entry = store .ledger() .get_entry(entry_id) .ok_or_else(|| format!("unknown entry {entry_id}"))?; if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() { return Ok(()); } let split_group = self.policy.split_group(store.ledger(), entry_id, entry); if !self.policy.receipt_required(store.ledger(), entry) && split_group.is_empty() { return Ok(()); } let total = ReceiptPolicy::entry_debits(entry); Err(ReceiptRequiredError { code: "receipt_required".to_string(), message: format!( "entry {entry_id} moves {} in debits ({}); unlinking its last receipt would leave it without one.", money(total), self.policy.describe() ), entry_id: Some(entry_id), asset_id: None, debits: total.to_string(), policy: self.policy.describe(), remedy_tool: "books.attach_receipt".to_string(), remedy_hint: format!( "attach the replacement receipt to entry {entry_id} with books.attach_receipt first, then retry books.unlink_receipt" ), split_group, } .to_tool_error()) } } // --------------------------------------------------------------------------- // 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\"")), } } /// Parse a calendar-month period "YYYY-MM". fn parse_period_mcp(period: &str) -> Result<(i32, u32), String> { let err = || format!("bad period {period:?}: expected YYYY-MM"); let mut parts = period.split('-'); let year: i32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?; let month: u32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?; if parts.next().is_some() || year < 1 || !(1..=12).contains(&month) { return Err(err()); } Ok((year, month)) } /// Last calendar day of a month. fn last_day_of_month(year: i32, month: u32) -> NaiveDate { let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid period"); first .checked_add_months(chrono::Months::new(1)) .expect("month arithmetic") .pred_opt() .expect("month arithmetic") } 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")) } /// Resolve a class by id or name (case-insensitive). fn resolve_class(store: &Store, class: &str) -> Result { if let Ok(id) = class.parse::() && store.ledger().get_class(id).is_some() { return Ok(id); } store .ledger() .find_class_by_name(class) .map(|c| c.id) .ok_or_else(|| { format!("unknown class {class:?}: use an id or name from books.list_classes") }) } /// A money amount as a decimal string with at least two places: "0.00", /// "207.68", "1000.00". Padding only, never rounding: an exact amount with /// more places (an FX-converted line) keeps them. Never "-0". fn money(n: Decimal) -> String { let mut n = if n.is_zero() { Decimal::ZERO } else { n }; if n.scale() < 2 { n.rescale(2); } 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()) } /// Build the cash flow statement from account codes, as the exports need it: /// `start` defaults to the beginning of time, `end` to today, and at least /// one cash account is required (the statement reconciles to them). fn cash_flow_statement( ledger: &Ledger, start: Option<&str>, end: Option<&str>, cash_codes: &[String], financing_codes: &[String], ) -> Result { let resolve = |codes: &[String], what: &str| -> Result, String> { codes .iter() .map(|code| { ledger .chart() .find_by_code(code) .map(|a| a.id) .ok_or_else(|| format!("unknown {what} account code: {code}")) }) .collect() }; let cash_accounts = resolve(cash_codes, "cash")?; if cash_accounts.is_empty() { return Err("cash_accounts is required for the cash flow statement".to_string()); } let financing_accounts = resolve(financing_codes, "financing")?; let (start, end) = statement_range(ledger, start, end)?; ledger_core::cash_flow( ledger, &ledger_core::CashFlowParams { start, end, cash_accounts, financing_accounts, }, ) .map_err(|e| e.to_string()) } /// 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, } } /// Today in the books' local time zone. Not UTC: in the evening (US /// time) UTC is already tomorrow, and "through today" must not include it. fn today() -> NaiveDate { chrono::Local::now().date_naive() } /// Resolve a statement's date range: `end` defaults to today and `start` to /// the first posted entry (where "from the beginning" really begins, so no /// statement says "0001-01-01"), or to `end` for empty books. fn statement_range( ledger: &Ledger, start: Option<&str>, end: Option<&str>, ) -> Result<(NaiveDate, NaiveDate), String> { let end = match end { Some(s) => parse_date(s)?, None => today(), }; let start = match start { Some(s) => parse_date(s)?, None => ledger .posted_entries() .map(|e| e.date()) .min() .map_or(end, |first| first.min(end)), }; if end < start { return Err("end is before start".to_string()); } Ok((start, end)) } /// Profit and loss for a range (see [`statement_range`]), optionally one class. fn pnl_statement( ledger: &Ledger, start: Option<&str>, end: Option<&str>, class_id: Option, ) -> Result { let (start, end) = statement_range(ledger, start, end)?; ledger_core::profit_and_loss_for_period(ledger, start, end, class_id).map_err(|e| e.to_string()) } /// Balance sheet as of a date (default today). fn bs_statement( ledger: &Ledger, as_of: Option<&str>, ) -> Result { let as_of = match as_of { Some(s) => parse_date(s)?, None => today(), }; ledger_core::balance_sheet_as_of(ledger, as_of).map_err(|e| e.to_string()) } fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec { lines .iter() .map(|l| StatementLineOut { code: l.code.clone(), name: l.name.clone(), amount: money(l.amount), }) .collect() } // --------------------------------------------------------------------------- // 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 as of a date (default today): 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, params: Parameters, ) -> Result, String> { let store = self.lock()?; let as_of = match ¶ms.0.as_of { Some(s) => parse_date(s)?, None => today(), }; let tb = reports::trial_balance_as_of(store.ledger(), as_of); Ok(Json(TrialBalanceOut { as_of: as_of.to_string(), 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. Pass class (name or id) to filter to one division.", annotations(read_only_hint = true) )] async fn profit_and_loss( &self, params: Parameters, ) -> Result, String> { let p = params.0; let store = self.lock()?; let class_id = match &p.class { Some(c) => Some(resolve_class(&store, c)?), None => None, }; let pl = pnl_statement( store.ledger(), p.start.as_deref(), p.end.as_deref(), class_id, )?; Ok(Json(ProfitAndLossOut { start: pl.start.to_string(), end: pl.end.to_string(), revenue: money(pl.total_revenue), expenses: money(pl.total_expenses), net_income: money(pl.net_income()), revenue_accounts: lines_out(&pl.revenue), expense_accounts: lines_out(&pl.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 bs = bs_statement(store.ledger(), p.as_of.as_deref())?; Ok(Json(BalanceSheetOut { as_of: bs.as_of.to_string(), assets: money(bs.total_assets), liabilities: money(bs.total_liabilities), equity: money(bs.total_equity), current_earnings: money(bs.current_earnings), balanced: bs.balanced(), asset_accounts: lines_out(&bs.assets), liability_accounts: lines_out(&bs.liabilities), equity_accounts: lines_out(&bs.equity), })) } #[tool( name = "books.cash_flow", description = "Cash flow statement (indirect method) for a period: net income, depreciation add-back, working-capital changes, investing (asset purchases, disposal proceeds), financing. The three sections reconcile to the net change in the given cash accounts.", annotations(read_only_hint = true) )] async fn cash_flow( &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 => today(), }; // Resolve cash and financing account codes to ids. let mut cash_accounts = Vec::new(); for code in &p.cash_accounts { let id = ledger .chart() .find_by_code(code) .map(|a| a.id) .ok_or_else(|| format!("unknown cash account code: {code}"))?; cash_accounts.push(id); } if cash_accounts.is_empty() { return Err("at least one cash account code is required".to_string()); } let mut financing_accounts = Vec::new(); for code in &p.financing_accounts { let id = ledger .chart() .find_by_code(code) .map(|a| a.id) .ok_or_else(|| format!("unknown financing account code: {code}"))?; financing_accounts.push(id); } let stmt = ledger_core::cash_flow( ledger, &ledger_core::CashFlowParams { start, end, cash_accounts, financing_accounts, }, ) .map_err(|e| e.to_string())?; let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut { label: l.label.clone(), amount: money(l.amount), }; Ok(Json(CashFlowOut { start: stmt.start.to_string(), end: stmt.end.to_string(), net_income: money(stmt.net_income), depreciation: money(stmt.depreciation), working_capital: stmt.working_capital.iter().map(line).collect(), cash_from_operations: money(stmt.cash_from_operations), investing: stmt.investing.iter().map(line).collect(), cash_from_investing: money(stmt.cash_from_investing), financing: stmt.financing.iter().map(line).collect(), cash_from_financing: money(stmt.cash_from_financing), net_change_in_cash: money(stmt.net_change_in_cash), cash_beginning: money(stmt.cash_beginning), cash_ending: money(stmt.cash_ending), })) } #[tool( name = "books.draft_entry", description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports 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. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate. Pass class (name or id) to tag the line for divisional tracking." )] 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 side = parse_side(&p.side)?; // Foreign-currency line? if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() { let fc = p .foreign_currency .ok_or("foreign_currency is required for fx lines")?; let fa = p .foreign_amount .ok_or("foreign_amount is required for fx lines")?; let rate = p.fx_rate.ok_or("fx_rate is required for fx lines")?; let currency = Currency::new(&fc).map_err(|e| e.to_string())?; let foreign_amount = parse_amount(&fa)?; let fx_rate = parse_amount(&rate)?; store .add_line_fx(p.entry_id, account, side, currency, foreign_amount, fx_rate) .map_err(|e| e.to_string())?; } else { 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, side, Money::new(amount, currency)) .map_err(|e| e.to_string())?; } // Class tag, if requested. The line just added is the last one. if let Some(class_ref) = p.class { let class_id = resolve_class(&store, &class_ref)?; let line_index = store .ledger() .get_entry(p.entry_id) .ok_or_else(|| format!("unknown entry {}", p.entry_id))? .lines() .len() .checked_sub(1) .ok_or_else(|| "entry has no lines".to_string())?; store .set_line_class(p.entry_id, line_index, Some(class_id)) .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 mut receipt_required = self.policy.receipt_required(store.ledger(), &entry); 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.policy.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 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, split_detected: split_group.clone(), }; if !p.confirmed.unwrap_or(false) { return Ok(Json(out)); } 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.policy.describe(), split_note ), entry_id: Some(p.entry_id), asset_id: None, debits: total.to_string(), policy: self.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())?; 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.unlink_receipt", description = "Detach a receipt from its entry, with a reason recorded in the journal. The receipt stays filed (unlinked); link it elsewhere or withdraw it with books.withdraw_attachment. The receipt policy still holds: an entry that requires a receipt cannot be left with none, so to replace a receipt attach the new one first, then unlink the old." )] async fn unlink_receipt( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let attachment = 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))?; if let Some(entry_id) = attachment.entry_id { let remaining = store .list_attachments(Some(entry_id)) .map_err(|e| e.to_string())? .len() - 1; if remaining == 0 { self.ensure_receipt_not_required(&store, entry_id)?; } } let entry_id = store .unlink_receipt(p.attachment_id, &p.reason) .map_err(|e| e.to_string())?; let remaining_receipts = store .list_attachments(Some(entry_id)) .map_err(|e| e.to_string())? .len() as u64; Ok(Json(UnlinkReceiptOut { ok: true, entry_id, remaining_receipts, })) } #[tool( name = "books.withdraw_attachment", description = "Withdraw an unlinked receipt or a filed document, with a reason recorded in the journal. It drops out of books.list_receipts and can never be linked again. Nothing is deleted: the bytes stay in the blob store and the journal keeps the filing and the withdrawal. Unlink a linked receipt first." )] async fn withdraw_attachment( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; store .withdraw_attachment(p.attachment_id, &p.reason) .map_err(|e| e.to_string())?; Ok(Json(WithdrawAttachmentOut { ok: true })) } #[tool( name = "books.list_receipts", description = "List receipts and filed documents, optionally filtered to one entry. Withdrawn attachments are hidden unless include_withdrawn=true.", annotations(read_only_hint = true) )] async fn list_receipts( &self, params: Parameters, ) -> Result, String> { let p = params.0; let store = self.lock()?; let include_withdrawn = p.include_withdrawn.unwrap_or(false); let receipts = store .list_attachments(p.entry_id) .map_err(|e| e.to_string())? .into_iter() .filter(|a| include_withdrawn || a.withdrawn_at.is_none()) .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, withdrawn: a.withdrawn_at.is_some(), withdrawn_reason: a.withdrawn_reason, }) .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 = today(); 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.import_bank_csv", description = "Import a QuickBooks-style bank/credit-card CSV (Date,Description,Amount or Date,Description,Expense,Income). Creates one DRAFT entry per transaction: bank account on one side, categorized offset on the other (via rules or default_offset_code). Review with books.digest, then post." )] async fn import_bank_csv( &self, params: Parameters, ) -> Result, String> { let p = params.0; let txns = ledger_import::parse_bank_csv(&p.csv_text).map_err(|e| e.to_string())?; let mut store = self.lock()?; let bank = store .ledger() .chart() .find_by_code(&p.bank_account_code) .map(|a| (a.id, a.code.clone())) .ok_or_else(|| format!("unknown bank account code: {}", p.bank_account_code))?; let default_offset = store .ledger() .chart() .find_by_code(&p.default_offset_code) .map(|a| (a.id, a.code.clone())) .ok_or_else(|| format!("unknown offset account code: {}", p.default_offset_code))?; // Resolve rule account codes upfront. let mut rules: Vec<(String, (ledger_core::AccountId, String))> = Vec::new(); for (pattern, code) in &p.rules { let (id, c) = store .ledger() .chart() .find_by_code(code) .map(|a| (a.id, a.code.clone())) .ok_or_else(|| format!("unknown rule account code: {code}"))?; rules.push((pattern.to_lowercase(), (id, c))); } let mut imported = Vec::new(); for txn in txns { let desc_lower = txn.description.to_lowercase(); let (offset_id, offset_code) = rules .iter() .find(|(pat, _)| desc_lower.contains(pat)) .map(|(_, v)| v.clone()) .unwrap_or_else(|| default_offset.clone()); let amount = Money::new(txn.amount.abs(), store.ledger().base_currency()); let entry_id = store .create_entry(txn.date, &format!("Bank import — {}", txn.description)) .map_err(|e| e.to_string())?; // Money in: debit bank, credit offset. Money out: reverse. let (bank_side, offset_side) = if txn.amount > Decimal::ZERO { (Side::Debit, Side::Credit) } else { (Side::Credit, Side::Debit) }; store .add_line(entry_id, bank.0, bank_side, amount) .map_err(|e| e.to_string())?; store .add_line(entry_id, offset_id, offset_side, amount) .map_err(|e| e.to_string())?; imported.push(ImportedTxnOut { entry_id, date: txn.date.to_string(), description: txn.description.clone(), amount: money(txn.amount), offset_account: offset_code, }); } let count = imported.len(); Ok(Json(ImportBankCsvOut { imported, count })) } #[tool( name = "books.import_chart_csv", description = "Import a chart of accounts from CSV (header: code,name,type,parent_code). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported." )] async fn import_chart_csv( &self, params: Parameters, ) -> Result, String> { let p = params.0; let rows = ledger_import::parse_chart_csv(&p.csv_text).map_err(|e| e.to_string())?; let mut store = self.lock()?; // Validate all rows first: nothing is half-imported. for row in &rows { if store.ledger().chart().find_by_code(&row.code).is_some() { return Err(format!("duplicate account code: {}", row.code)); } } let mut created = Vec::new(); for row in rows { let account_type = match row.account_type.as_str() { "asset" => AccountType::Asset, "liability" => AccountType::Liability, "equity" => AccountType::Equity, "revenue" => AccountType::Revenue, "expense" => AccountType::Expense, _ => return Err(format!("bad account type: {}", row.account_type)), }; let parent = match &row.parent_code { Some(code) => Some( store .ledger() .chart() .find_by_code(code) .map(|a| a.id) .ok_or_else(|| format!("unknown parent code: {code}"))?, ), None => None, }; store .add_account(&row.code, &row.name, account_type, parent, false) .map_err(|e| e.to_string())?; created.push(row.code.clone()); } let count = created.len(); Ok(Json(ImportChartCsvOut { created, count })) } #[tool( name = "books.import_local_file", description = "Import a file from the server's local filesystem into the blob store (receipt or document). Used for backfills: the agent downloads from Google Drive (or elsewhere) to a temp path, then calls this. Path must be absolute." )] async fn import_local_file( &self, params: Parameters, ) -> Result, String> { let p = params.0; let path = std::path::Path::new(&p.path); if !path.is_absolute() { return Err("path must be absolute".to_string()); } let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", p.path))?; if bytes.len() > 25 * 1024 * 1024 { return Err("file exceeds 25 MB limit".to_string()); } let file_name = p.file_name.unwrap_or_else(|| { path.file_name() .and_then(|n| n.to_str()) .unwrap_or("file") .to_string() }); let mime_type = mime_guess::from_path(path) .first_or_octet_stream() .to_string(); let mut store = self.lock()?; let (attachment_id, sha256, deduplicated) = match p.category.as_str() { "receipt" => store .attach_receipt( p.entry_id, &file_name, &mime_type, &bytes, p.description.as_deref(), ) .map_err(|e| e.to_string())?, "document" => { let cat = p.document_category.as_deref().unwrap_or("other"); store .file_document( cat, &file_name, &mime_type, &bytes, p.description.as_deref(), ) .map_err(|e| e.to_string())? } _ => return Err("category must be \"receipt\" or \"document\"".to_string()), }; Ok(Json(ImportLocalFileOut { attachment_id, sha256, deduplicated, file_name, })) } #[tool( name = "books.export", description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from wanderbooks.toml), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal). Formats: pdf and xlsx (base64) for the five statements; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet and trial_balance.", annotations(read_only_hint = true) )] async fn export(&self, params: Parameters) -> Result, String> { let p = params.0; let format = p.format.to_lowercase(); if !["csv", "json", "xlsx", "pdf", "iif"].contains(&format.as_str()) { return Err(format!( "bad format {:?}: expected \"csv\", \"json\", \"xlsx\", \"pdf\", or \"iif\"", p.format )); } let report = p.report.to_lowercase(); if format == "iif" && report != "journal" { return Err("iif format is only valid for the journal report".to_string()); } if format == "xlsx" || format == "pdf" { let bytes = if format == "xlsx" { self.export_xlsx(&report, &p)? } else { self.export_pdf(&report, &p)? }; return Ok(Json(ExportOut { format, report, content: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes), content_encoding: Some("base64".to_string()), })); } if format == "iif" { let content = self.export_iif()?; return Ok(Json(ExportOut { format, report, content, content_encoding: None, })); } let content = match report.as_str() { "journal" | "general_journal" => self.export_journal(&format)?, "trial_balance" => self.export_trial_balance(&format, p.as_of.as_deref())?, "general_ledger" => self.export_general_ledger(&format, &p)?, "members_equity" => self.export_members_equity(&format, &p)?, "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())?, "cash_flow" => self.export_cash_flow(&format, &p)?, _ => { return Err(format!( "bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, or members_equity", p.report )); } }; Ok(Json(ExportOut { format, report, content, content_encoding: None, })) } /// Lay out a financial statement for rendering, from export params. fn layout_statement( &self, ledger: &Ledger, report: &str, p: &ExportParams, ) -> Result { use ledger_import::report; let members = self.report_members(); Ok(match report { "profit_and_loss" => report::profit_and_loss(&pnl_statement( ledger, p.start.as_deref(), p.end.as_deref(), None, )?), "balance_sheet" => { let bs = bs_statement(ledger, p.as_of.as_deref())?; let earnings = self.year_earnings(ledger, bs.as_of)?; report::balance_sheet(&bs, earnings, &members) } "cash_flow" => report::cash_flow( &cash_flow_statement( ledger, p.start.as_deref(), p.end.as_deref(), &p.cash_accounts, &p.financing_accounts, )?, &members, ), "trial_balance" => { let as_of = match &p.as_of { Some(s) => parse_date(s)?, None => today(), }; report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of) } "members_equity" => { let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; let me = ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?) .map_err(|e| e.to_string())?; report::members_equity(&me) } _ => { return Err(format!( "bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, or members_equity" )); } }) } /// Render a report to XLSX bytes via ledger-import. fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result, String> { use ledger_import::{XlsxReport, report_to_xlsx}; let store = self.lock()?; let meta = self.report_meta(&store); let ledger = store.ledger(); let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); match report { "general_ledger" => { let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; let gl = ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?; xlsx(XlsxReport::GeneralLedger(&gl)) } "general_journal" | "journal" => { let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; xlsx(XlsxReport::GeneralJournal(ledger, start, end)) } _ => xlsx(XlsxReport::Statement( &self.layout_statement(ledger, report, p)?, )), } } /// Render a financial statement to PDF bytes via ledger-import. fn export_pdf(&self, report: &str, p: &ExportParams) -> Result, String> { if matches!(report, "general_ledger" | "general_journal" | "journal") { return Err(format!("{report} exports as xlsx, csv, or json, not pdf")); } let store = self.lock()?; let meta = self.report_meta(&store); let st = self.layout_statement(store.ledger(), report, p)?; ledger_import::statement_pdf(&st, &meta).map_err(|e| e.to_string()) } /// Render the journal as IIF text via ledger-import. fn export_iif(&self) -> Result { use ledger_import::{IifEntry, journal_to_iif}; let store = self.lock()?; let ledger = store.ledger(); let account_names = |id: ledger_core::AccountId| { ledger .chart() .get(id) .map(|a| a.name.clone()) .unwrap_or_default() }; let entries: Vec> = ledger .posted_entries() .map(|e| IifEntry { entry: e, account_names: &account_names, }) .collect(); Ok(journal_to_iif(&entries)) } // ------------------------------------------------------------------ // Fixed assets // ------------------------------------------------------------------ #[tool( name = "books.register_asset", description = "Register a fixed asset (equipment, vehicles, buildings). Links three accounts: cost (asset), accumulated depreciation (asset, contra), depreciation expense. Depreciation is straight-line, book basis only — tax depreciation (MACRS, Section 179) stays with the CPA." )] async fn register_asset( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let base = store.ledger().base_currency(); let cost_account = resolve_account(&store, &p.cost_account)?; let accum_depr_account = resolve_account(&store, &p.accum_depr_account)?; let expense_account = resolve_account(&store, &p.expense_account)?; let asset_id = store .register_asset(NewAsset { description: p.description, purchase_date: parse_date(&p.purchase_date)?, cost: Money::new(parse_amount(&p.cost)?, base), salvage_value: Money::new( parse_amount(p.salvage_value.as_deref().unwrap_or("0"))?, base, ), useful_life_months: p.useful_life_months, cost_account, accum_depr_account, expense_account, }) .map_err(|e| e.to_string())?; Ok(Json(RegisterAssetOut { asset_id })) } #[tool( name = "books.asset_schedule", description = "Month-by-month straight-line depreciation schedule for an asset: period, amount, accumulated, book value. Partial first and last months are prorated by days; the lifetime total is exactly cost minus salvage.", annotations(read_only_hint = true) )] async fn asset_schedule( &self, params: Parameters, ) -> Result, String> { let p = params.0; let store = self.lock()?; let asset = store .ledger() .get_asset(p.asset_id) .ok_or_else(|| format!("unknown asset {}", p.asset_id))? .clone(); // The schedule clamps to the depreciation end internally, so an // unbounded default just means "the whole life". let through = match p.through { Some(t) => parse_date(&t)?, None => NaiveDate::MAX, }; let schedule = depreciation_schedule(&asset, through) .into_iter() .map(|l| ScheduleLineOut { period: l.period, amount: money(l.amount), accumulated: money(l.accumulated), book_value: money(l.book_value), }) .collect(); Ok(Json(AssetScheduleOut { asset_id: asset.id, description: asset.description.clone(), cost: money(asset.cost.amount), salvage_value: money(asset.salvage_value.amount), useful_life_months: asset.useful_life_months, status: match asset.status { AssetStatus::Active => "active".to_string(), AssetStatus::Disposed { .. } => "disposed".to_string(), }, schedule, })) } #[tool( name = "books.list_assets", description = "List all registered fixed assets with accumulated depreciation and book value as of today.", annotations(read_only_hint = true) )] async fn list_assets(&self) -> Result, String> { let store = self.lock()?; let today = today(); let assets = store .ledger() .registered_assets() .map(|a| AssetSummary { asset_id: a.id, description: a.description.clone(), purchase_date: a.purchase_date.to_string(), cost: money(a.cost.amount), useful_life_months: a.useful_life_months, status: match a.status { AssetStatus::Active => "active".to_string(), AssetStatus::Disposed { .. } => "disposed".to_string(), }, accumulated: money(accumulated_depreciation(a, today)), book_value: money(book_value(a, today)), }) .collect(); Ok(Json(ListAssetsOut { assets })) } #[tool( name = "books.run_depreciation", description = "Post one month of depreciation (Dr Depreciation Expense / Cr Accumulated Depreciation) for one asset, or for every active asset when asset_id is omitted. Idempotent: re-running a posted period returns the original entry instead of double-posting. Depreciation is an allocation, not money movement, so it is exempt from the receipt policy; the registered asset and its schedule are the evidence." )] async fn run_depreciation( &self, params: Parameters, ) -> Result, String> { let p = params.0; let (py, pm) = parse_period_mcp(&p.period)?; let mut store = self.lock()?; let targets: Vec<(u64, String)> = match p.asset_id { Some(id) => { let a = store .ledger() .get_asset(id) .ok_or_else(|| format!("unknown asset {id}"))?; vec![(id, a.description.clone())] } None => store .ledger() .registered_assets() .filter(|a| a.status == AssetStatus::Active) .map(|a| (a.id, a.description.clone())) .collect(), }; let mut runs = Vec::with_capacity(targets.len()); let mut skipped = Vec::new(); for (id, description) in targets { // Run-all skips assets whose depreciable life doesn't touch the // period instead of failing the whole batch. if p.asset_id.is_none() && let Some(asset) = store.ledger().get_asset(id) { let period_end = last_day_of_month(py, pm); let touches = depreciation_schedule(asset, period_end) .iter() .any(|l| l.period == p.period); if !touches { skipped.push(SkippedDepreciation { asset_id: id, description, reason: format!( "period {} is outside the asset's depreciable life", p.period ), }); continue; } } let r = store .run_depreciation(id, &p.period) .map_err(|e| e.to_string())?; runs.push(DepreciationRunOut { asset_id: r.asset_id, description, period: r.period, amount: money(r.amount), entry_id: r.entry_id, already_posted: r.already_posted, }); } Ok(Json(RunDepreciationOut { runs, skipped })) } #[tool( name = "books.dispose_asset", description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids." )] async fn dispose_asset( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let date = parse_date(&p.date)?; let base = store.ledger().base_currency(); let proceeds = Money::new(parse_amount(&p.proceeds)?, base); let proceeds_account = resolve_account(&store, &p.proceeds_account)?; let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?; let asset = store .ledger() .get_asset(p.asset_id) .ok_or_else(|| format!("unknown asset {}", p.asset_id))? .clone(); let preview = store .dispose_preview(p.asset_id, date, proceeds) .map_err(|e| e.to_string())?; // The would-be disposal entry's lines, for the preview and the // receipt-policy check. let mut raw: Vec<(AccountId, Side, Money)> = vec![ ( asset.accum_depr_account, Side::Debit, Money::new(preview.accumulated, base), ), (proceeds_account, Side::Debit, proceeds), (asset.cost_account, Side::Credit, asset.cost), ]; if preview.gain_loss > Decimal::ZERO { raw.push(( gain_loss_account, Side::Credit, Money::new(preview.gain_loss, base), )); } else if preview.gain_loss < Decimal::ZERO { raw.push(( gain_loss_account, Side::Debit, Money::new(-preview.gain_loss, base), )); } // Disposals are money movement: the bill of sale is the receipt. let chart = store.ledger().chart(); let code_of = |id: AccountId| { chart .get(id) .map(|a| a.code.clone()) .ok_or_else(|| format!("unknown account {id}")) }; let codes: Vec = [ asset.accum_depr_account, proceeds_account, asset.cost_account, gain_loss_account, ] .iter() .map(|id| code_of(*id)) .collect::>()?; let code_refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect(); let debits = preview.accumulated + proceeds.amount + if preview.gain_loss < Decimal::ZERO { -preview.gain_loss } else { Decimal::ZERO }; let receipt_required = self.policy.receipt_required_for(&code_refs, debits); let out = DisposeAssetOut { status: "preview".to_string(), asset_id: p.asset_id, entry_id: 0, accumulated: money(preview.accumulated), book_value: money(preview.book_value), proceeds: money(proceeds.amount), gain_loss: money(preview.gain_loss), final_depreciation: money(preview.final_depreciation), lines: preview_lines_for(&store, &raw)?, receipt_required, }; if !p.confirmed.unwrap_or(false) { return Ok(Json(out)); } let attachment_ids = p.receipt_attachment_ids.unwrap_or_default(); if receipt_required && attachment_ids.is_empty() { return Err(ReceiptRequiredError { code: "receipt_required".to_string(), message: format!( "disposing {} moves {} in debits ({}); file the bill of sale with books.file_document, then retry with receipt_attachment_ids", asset.description, money(debits), self.policy.describe() ), entry_id: None, asset_id: Some(p.asset_id), debits: debits.to_string(), policy: self.policy.describe(), remedy_tool: "books.file_document".to_string(), remedy_hint: "file the bill of sale with books.file_document, then retry books.dispose_asset with confirmed=true and receipt_attachment_ids=[]" .to_string(), split_group: Vec::new(), } .to_tool_error()); } for aid in &attachment_ids { store.check_linkable(*aid).map_err(|e| e.to_string())?; } let entry_id = store .dispose_asset( p.asset_id, date, proceeds, proceeds_account, gain_loss_account, ) .map_err(|e| e.to_string())?; for aid in &attachment_ids { store .link_receipt(*aid, entry_id) .map_err(|e| e.to_string())?; } let mut posted = out; posted.status = "posted".to_string(); posted.entry_id = entry_id; Ok(Json(posted)) } // ------------------------------------------------------------------ // Periods // ------------------------------------------------------------------ #[tool( name = "books.create_period", description = "Create an accounting period (month, quarter, year). Dates are YYYY-MM-DD, inclusive." )] async fn create_period( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let id = store .create_period(&p.name, parse_date(&p.start)?, parse_date(&p.end)?) .map_err(|e| e.to_string())?; Ok(Json(CreatePeriodOut { period_id: id })) } #[tool( name = "books.close_period", description = "Close an accounting period. The closing entry, dated at period end, zeroes revenue and expense balances as of that date into Retained Earnings, or, for a multi-member LLC (members in wanderbooks.toml), into each member's capital account by share. Periods close in order. Without confirmed=true, returns the exact closing entry it would post and closes nothing. With confirmed=true, posts it and marks the period closed. Closed periods reject new entries." )] async fn close_period( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; // Verify the period exists. if store.ledger().get_period(p.period_id).is_none() { return Err(format!("unknown period {}", p.period_id)); } // A partnership closes into its members' capital accounts by share; // everyone else into Retained Earnings. let to = if self.company.members.is_empty() { ledger_core::CloseTo::RetainedEarnings } else { ledger_core::CloseTo::Members( self.member_capitals(store.ledger())? .into_iter() .map(|m| (m.capital_account, m.share)) .collect(), ) }; let describe = |ledger: &Ledger, eid: Option| -> Vec { let Some(entry) = eid.and_then(|e| ledger.get_entry(e)) else { return Vec::new(); }; entry .lines() .iter() .map(|line| { let code = ledger .chart() .get(line.account) .map(|a| a.code.clone()) .unwrap_or_default(); format!("{:?} {} {}", line.side, code, money(line.amount.amount)) }) .collect() }; if !p.confirmed { // Preview: run the real close on a copy of the ledger, so the // preview is exactly what confirming would post. let mut scratch = store.ledger().clone(); let closing = scratch .close_period_to(p.period_id, &to) .map_err(|e| e.to_string())?; return Ok(Json(ClosePeriodOut { status: "preview".to_string(), period_id: p.period_id, closing_entry: None, lines: describe(&scratch, closing), })); } let closing = store .close_period_to(p.period_id, &to) .map_err(|e| e.to_string())?; let lines = describe(store.ledger(), closing); Ok(Json(ClosePeriodOut { status: "closed".to_string(), period_id: p.period_id, closing_entry: closing, lines, })) } #[tool( name = "books.list_periods", description = "List all accounting periods with their status." )] async fn list_periods(&self) -> Result, String> { let store = self.lock()?; let periods = store .ledger() .list_periods() .into_iter() .map(|p| PeriodOut { id: p.id, name: p.name.clone(), start: p.start.to_string(), end: p.end.to_string(), status: match p.status { PeriodStatus::Open => "open".to_string(), PeriodStatus::Closed => "closed".to_string(), }, closing_entry: p.closing_entry, }) .collect(); Ok(Json(ListPeriodsOut { periods })) } // ------------------------------------------------------------------ // Bank reconciliation // ------------------------------------------------------------------ #[tool( name = "books.mark_cleared", description = "Mark a posted entry's line as cleared (or uncleared) for bank reconciliation. Only posted lines can be marked." )] async fn mark_cleared( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; store .mark_cleared(p.entry_id, p.line_index, p.cleared) .map_err(|e| e.to_string())?; Ok(Json(MarkClearedOut { ok: true })) } #[tool( name = "books.reconcile", description = "Reconcile a bank/cash account against a statement. Returns book balance, cleared balance, statement balance, the difference (zero means reconciled), and the list of uncleared lines." )] async fn reconcile( &self, params: Parameters, ) -> Result, String> { let p = params.0; let store = self.lock()?; let account = resolve_account(&store, &p.account)?; let report = store .ledger() .reconciliation_report( account, parse_date(&p.statement_date)?, parse_amount(&p.statement_balance)?, ) .map_err(|e| e.to_string())?; let code = store .ledger() .chart() .get(account) .map(|a| a.code.clone()) .unwrap_or_default(); let uncleared = report .uncleared_lines .into_iter() .map(|l| UnclearedLineOut { entry_id: l.entry_id, line_index: l.line_index, date: l.date.to_string(), description: l.description, side: match l.side { Side::Debit => "debit".to_string(), Side::Credit => "credit".to_string(), }, amount: l.amount.to_string(), }) .collect(); Ok(Json(ReconcileOut { account_code: code, book_balance: report.book_balance.to_string(), cleared_balance: report.cleared_balance.to_string(), statement_balance: report.statement_balance.to_string(), difference: report.difference.to_string(), uncleared_lines: uncleared, })) } // ------------------------------------------------------------------ // A/R and A/P // ------------------------------------------------------------------ #[tool( name = "books.add_customer", description = "Add a customer for accounts receivable." )] async fn add_customer( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let id = store.add_customer(&p.name).map_err(|e| e.to_string())?; Ok(Json(AddPartyOut { id })) } #[tool( name = "books.add_vendor", description = "Add a vendor for accounts payable." )] async fn add_vendor( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let id = store.add_vendor(&p.name).map_err(|e| e.to_string())?; Ok(Json(AddPartyOut { id })) } #[tool( name = "books.create_invoice", description = "Create a sales invoice. Auto-posts Dr Accounts Receivable / Cr Revenue. Lines are {description, amount} with decimal-string amounts." )] async fn create_invoice( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let lines: Vec = p .lines .into_iter() .map(|l| { Ok(DocumentLine { description: l.description, amount: parse_amount(&l.amount)?, }) }) .collect::>()?; let id = store .create_invoice( p.party_id, parse_date(&p.date)?, parse_date(&p.due_date)?, lines, ) .map_err(|e| e.to_string())?; let inv = store .ledger() .get_invoice(id) .ok_or_else(|| format!("invoice {id} not found after creation"))?; Ok(Json(CreateDocumentOut { id, total: inv.total().to_string(), entry_id: inv.entry_id, })) } #[tool( name = "books.create_bill", description = "Create a vendor bill. Auto-posts Dr General Expenses / Cr Accounts Payable. Lines are {description, amount} with decimal-string amounts." )] async fn create_bill( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let lines: Vec = p .lines .into_iter() .map(|l| { Ok(DocumentLine { description: l.description, amount: parse_amount(&l.amount)?, }) }) .collect::>()?; let id = store .create_bill( p.party_id, parse_date(&p.date)?, parse_date(&p.due_date)?, lines, ) .map_err(|e| e.to_string())?; let bill = store .ledger() .get_bill(id) .ok_or_else(|| format!("bill {id} not found after creation"))?; Ok(Json(CreateDocumentOut { id, total: bill.total().to_string(), entry_id: bill.entry_id, })) } #[tool( name = "books.receive_payment", description = "Record a customer payment against an invoice. Posts Dr Cash / Cr A/R." )] async fn receive_payment( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let cash = resolve_account(&store, &p.cash_account)?; let entry_id = store .receive_payment( p.document_id, parse_date(&p.date)?, parse_amount(&p.amount)?, cash, ) .map_err(|e| e.to_string())?; let inv = store .ledger() .get_invoice(p.document_id) .ok_or_else(|| format!("invoice {} not found", p.document_id))?; Ok(Json(PaymentOut { entry_id, remaining: inv.balance().to_string(), status: match inv.status { DocumentStatus::Open => "open".to_string(), DocumentStatus::Partial => "partial".to_string(), DocumentStatus::Paid => "paid".to_string(), }, })) } #[tool( name = "books.pay_bill", description = "Pay a vendor bill. Posts Dr A/P / Cr Cash." )] async fn pay_bill( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let cash = resolve_account(&store, &p.cash_account)?; let entry_id = store .pay_bill( p.document_id, parse_date(&p.date)?, parse_amount(&p.amount)?, cash, ) .map_err(|e| e.to_string())?; let bill = store .ledger() .get_bill(p.document_id) .ok_or_else(|| format!("bill {} not found", p.document_id))?; Ok(Json(PaymentOut { entry_id, remaining: bill.balance().to_string(), status: match bill.status { DocumentStatus::Open => "open".to_string(), DocumentStatus::Partial => "partial".to_string(), DocumentStatus::Paid => "paid".to_string(), }, })) } #[tool( name = "books.ar_aging", description = "A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+)." )] async fn ar_aging(&self, params: Parameters) -> Result, String> { let p = params.0; let store = self.lock()?; let rows = store .ledger() .ar_aging(parse_date(&p.as_of)?) .into_iter() .map(|r| { let total = r.total().to_string(); AgingRowOut { name: r.name, current: r.current.to_string(), days_30: r.days_30.to_string(), days_60: r.days_60.to_string(), days_90: r.days_90.to_string(), days_90_plus: r.days_90_plus.to_string(), total, } }) .collect(); Ok(Json(AgingOut { rows })) } #[tool( name = "books.ap_aging", description = "A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+)." )] async fn ap_aging(&self, params: Parameters) -> Result, String> { let p = params.0; let store = self.lock()?; let rows = store .ledger() .ap_aging(parse_date(&p.as_of)?) .into_iter() .map(|r| { let total = r.total().to_string(); AgingRowOut { name: r.name, current: r.current.to_string(), days_30: r.days_30.to_string(), days_60: r.days_60.to_string(), days_90: r.days_90.to_string(), days_90_plus: r.days_90_plus.to_string(), total, } }) .collect(); Ok(Json(AgingOut { rows })) } // ------------------------------------------------------------------ // Foreign exchange // ------------------------------------------------------------------ #[tool( name = "books.set_fx_rate", description = "Record a historical FX rate: how much base currency one unit of the foreign currency buys on the given date. For reference; lines store their own rate at entry time." )] async fn set_fx_rate( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let currency = Currency::new(&p.currency).map_err(|e| e.to_string())?; store .set_fx_rate(currency, parse_date(&p.date)?, parse_amount(&p.rate)?) .map_err(|e| e.to_string())?; Ok(Json(SetFxRateOut { ok: true })) } // ------------------------------------------------------------------ // Payroll (accounting only — the caller supplies every number; no tax tables) // ------------------------------------------------------------------ #[tool( name = "books.run_payroll", description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats." )] async fn run_payroll( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let cash = resolve_account(&store, &p.cash_account)?; let lines: Vec = p .lines .into_iter() .map(|l| { Ok(ledger_core::PayrollLine { employee_name: l.employee_name, gross_pay: parse_amount(&l.gross_pay)?, federal_withholding: parse_amount(&l.federal_withholding)?, state_withholding: parse_amount(&l.state_withholding)?, ss_employee: parse_amount(&l.ss_employee)?, medicare_employee: parse_amount(&l.medicare_employee)?, ss_employer: parse_amount(&l.ss_employer)?, medicare_employer: parse_amount(&l.medicare_employer)?, other_deductions: parse_amount(&l.other_deductions)?, }) }) .collect::>()?; let run_id = store .run_payroll(parse_date(&p.date)?, lines, cash) .map_err(|e| e.to_string())?; let run = store .ledger() .get_payroll_run(run_id) .ok_or_else(|| format!("payroll run {run_id} not found after creation"))?; let total_gross: Decimal = run.lines.iter().map(|l| l.gross_pay).sum(); let total_net: Decimal = run.lines.iter().map(|l| l.net_pay()).sum(); Ok(Json(RunPayrollOut { run_id, entry_id: run.entry_id, total_gross: money(total_gross), total_net: money(total_net), })) } #[tool( name = "books.remit_payroll_taxes", description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry." )] async fn remit_payroll_taxes( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let cash = resolve_account(&store, &p.cash_account)?; // Total before remitting, for the response. let mut total = Decimal::ZERO; for code in ["2110", "2120", "2130", "2140", "2150"] { if let Some(a) = store.ledger().chart().find_by_code(code) { total += store .ledger() .account_balance(a.id) .map_err(|e| e.to_string())?; } } let entry_id = store .remit_payroll_taxes(parse_date(&p.date)?, cash) .map_err(|e| e.to_string())?; Ok(Json(RemitPayrollTaxesOut { entry_id, total: money(total), })) } #[tool( name = "books.list_payroll_runs", description = "List payroll runs in id order: date, employee count, totals, and the posted entry id.", annotations(read_only_hint = true) )] async fn list_payroll_runs(&self) -> Result, String> { let store = self.lock()?; let runs = store .list_payroll_runs() .into_iter() .map(|r| { let total_gross: Decimal = r.lines.iter().map(|l| l.gross_pay).sum(); PayrollRunOut { id: r.id, date: r.date.to_string(), employee_count: r.lines.len(), total_gross: money(total_gross), entry_id: r.entry_id, } }) .collect(); Ok(Json(ListPayrollRunsOut { runs })) } // ------------------------------------------------------------------ // Classes (divisional tracking) // ------------------------------------------------------------------ #[tool( name = "books.add_class", description = "Add a tracking class for divisional P&L (e.g. an app, website, or channel). Tag journal lines with it via books.add_line's class param, then report per division." )] async fn add_class( &self, params: Parameters, ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let id = store.add_class(&p.name).map_err(|e| e.to_string())?; Ok(Json(AddClassOut { id })) } #[tool( name = "books.list_classes", description = "List tracking classes in id order.", annotations(read_only_hint = true) )] async fn list_classes(&self) -> Result, String> { let store = self.lock()?; let classes = store .list_classes() .into_iter() .map(|c| ClassOut { id: c.id, name: c.name.clone(), }) .collect(); Ok(Json(ListClassesOut { classes })) } #[tool( name = "books.profit_and_loss_by_class", description = "P&L broken down by tracking class for a period, plus an unclassified row for untagged lines. Dates are YYYY-MM-DD; omit for all time.", annotations(read_only_hint = true) )] async fn profit_and_loss_by_class( &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 => today(), }; if end < start { return Err("end is before start".to_string()); } // Per-class P&L over the date range: accumulate line by line. use std::collections::HashMap; #[derive(Default)] struct Acc { revenue: Decimal, expenses: Decimal, } let mut by_class: HashMap, Acc> = HashMap::new(); for entry in ledger.posted_entries() { if entry.date() < start || entry.date() > end { continue; } for line in entry.lines() { let account = match ledger.chart().get(line.account) { Some(a) if !a.placeholder => a, _ => continue, }; let signed = match line.side { Side::Debit => line.amount.amount, Side::Credit => -line.amount.amount, }; // Normal-side positive. let normal = match account.account_type.normal_side() { Side::Debit => signed, Side::Credit => -signed, }; let acc = by_class.entry(line.class_id).or_default(); match account.account_type { AccountType::Revenue => acc.revenue += normal, AccountType::Expense => acc.expenses += normal, _ => {} } } } // Sort: named classes by name, unclassified last. let mut rows: Vec<(Option, Acc)> = by_class .into_iter() .map(|(cid, acc)| { let name = cid .and_then(|id| ledger.get_class(id)) .map(|c| c.name.clone()); (name, acc) }) // Drop rows with no P&L movement. .filter(|(_, acc)| !acc.revenue.is_zero() || !acc.expenses.is_zero()) .collect(); rows.sort_by(|a, b| match (&a.0, &b.0) { (Some(x), Some(y)) => x.cmp(y), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => std::cmp::Ordering::Equal, }); let classes = rows .into_iter() .map(|(class, acc)| ClassPnlOut { class, revenue: money(acc.revenue), expenses: money(acc.expenses), net_income: money(acc.revenue - acc.expenses), }) .collect(); Ok(Json(ProfitAndLossByClassOut { start: start.to_string(), end: end.to_string(), classes, })) } // ------------------------------------------------------------------ // CPA handoff package // ------------------------------------------------------------------ #[tool( name = "books.export_cpa_package", description = "Build the CPA handoff package as a zip (base64): the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), and trial balance, each as PDF and XLSX; plus the general ledger (by account) and general journal (entry by entry) as XLSX. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", annotations(read_only_hint = true) )] async fn export_cpa_package( &self, params: Parameters, ) -> Result, String> { use ledger_import::{XlsxReport, report, report_to_xlsx, statement_pdf}; let p = params.0; let as_of = match p.as_of { Some(s) => parse_date(&s)?, None => today(), }; let store = self.lock()?; let meta = self.report_meta(&store); let ledger = store.ledger(); // Fiscal year to date, per wanderbooks.toml's fiscal_year_start. let (fy_start, _) = self.company.fiscal_year_containing(as_of); let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string()); let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string()); let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?; let members = self.report_members(); // The financial statements, each as PDF and XLSX. let mut statements: Vec<(&str, ledger_import::Statement)> = vec![ ( "profit-and-loss", report::profit_and_loss(&pnl_statement( ledger, Some(&fy_start), Some(&as_of_s), None, )?), ), ( "balance-sheet", report::balance_sheet( &bs_statement(ledger, Some(&as_of_s))?, self.year_earnings(ledger, as_of)?, &members, ), ), ( "cash-flow", report::cash_flow( &cash_flow_statement( ledger, Some(&fy_start), Some(&as_of_s), &p.cash_accounts, &p.financing_accounts, )?, &members, ), ), ]; if !self.company.members.is_empty() { let me = ledger_core::members_equity(ledger, fy, as_of, &self.member_capitals(ledger)?) .map_err(|e| e.to_string())?; statements.push(("members-equity", report::members_equity(&me))); } statements.push(( "trial-balance", report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of), )); let mut files: Vec<(String, Vec)> = Vec::new(); for (name, st) in &statements { files.push((format!("{name}.pdf"), pdf(st)?)); files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?)); } // The books behind them: by account, and entry by entry. let gl = ledger_core::general_ledger(ledger, fy, as_of).map_err(|e| e.to_string())?; files.push(( "general-ledger.xlsx".into(), xlsx(XlsxReport::GeneralLedger(&gl))?, )); files.push(( "general-journal.xlsx".into(), xlsx(XlsxReport::GeneralJournal(ledger, fy, as_of))?, )); drop(store); // Zip it. let mut zip_buf = Vec::new(); { let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf)); let opts = zip::write::SimpleFileOptions::default(); for (name, bytes) in &files { zip.start_file(name.as_str(), opts) .map_err(|e| e.to_string())?; use std::io::Write; zip.write_all(bytes).map_err(|e| e.to_string())?; } zip.finish().map_err(|e| e.to_string())?; } Ok(Json(ExportCpaPackageOut { file_name: format!("cpa-package-{as_of}.zip"), content_base64: base64::Engine::encode( &base64::engine::general_purpose::STANDARD, &zip_buf, ), })) } } // --------------------------------------------------------------------------- // 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, as_of: Option<&str>) -> Result { let store = self.lock()?; let as_of = match as_of { Some(s) => parse_date(s)?, None => today(), }; let tb = reports::trial_balance_as_of(store.ledger(), as_of); if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ "as_of": as_of.to_string(), "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_general_ledger(&self, format: &str, p: &ExportParams) -> Result { let store = self.lock()?; let ledger = store.ledger(); let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; let gl = ledger_core::general_ledger(ledger, start, end).map_err(|e| e.to_string())?; if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ "start": gl.start.to_string(), "end": gl.end.to_string(), "accounts": gl.accounts.iter().map(|a| serde_json::json!({ "code": a.code, "name": a.name, "beginning_balance": money(a.beginning_balance), "postings": a.postings.iter().map(|p| serde_json::json!({ "date": p.date.to_string(), "entry_id": p.entry_id, "description": p.description, "split": p.split, "debit": money(p.debit), "credit": money(p.credit), "balance": money(p.balance), })).collect::>(), "total_debits": money(a.total_debits), "total_credits": money(a.total_credits), "ending_balance": money(a.ending_balance), })).collect::>(), })) .map_err(|e| e.to_string()); } let mut csv = String::from("account,date,entry,description,split,debit,credit,balance\n"); for a in &gl.accounts { let account = csv_escape(&format!("{} {}", a.code, a.name)); csv.push_str(&format!( "{account},,,Beginning balance,,,,{}\n", money(a.beginning_balance) )); for p in &a.postings { csv.push_str(&format!( "{account},{},{},{},{},{},{},{}\n", p.date, p.entry_id, csv_escape(&p.description), csv_escape(&p.split), money(p.debit), money(p.credit), money(p.balance) )); } csv.push_str(&format!( "{account},,,Total,,{},{},{}\n", money(a.total_debits), money(a.total_credits), money(a.ending_balance) )); } Ok(csv) } fn export_members_equity(&self, format: &str, p: &ExportParams) -> Result { let store = self.lock()?; let ledger = store.ledger(); let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?; let me = ledger_core::members_equity(ledger, start, end, &self.member_capitals(ledger)?) .map_err(|e| e.to_string())?; let col = |c: &ledger_core::MemberEquityColumn| { serde_json::json!({ "name": c.name, "beginning": money(c.beginning), "contributions": money(c.contributions), "net_income": money(c.net_income), "distributions": money(c.distributions), "other": money(c.other), "ending": money(c.ending), }) }; if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ "start": me.start.to_string(), "end": me.end.to_string(), "members": me.members.iter().map(col).collect::>(), "total": col(&me.total), })) .map_err(|e| e.to_string()); } let mut csv = String::from("member,beginning,contributions,net_income,distributions,other,ending\n"); for c in me.members.iter().chain(std::iter::once(&me.total)) { csv.push_str(&format!( "{},{},{},{},{},{},{}\n", csv_escape(&c.name), money(c.beginning), money(c.contributions), money(c.net_income), money(c.distributions), money(c.other), money(c.ending) )); } Ok(csv) } fn export_pnl( &self, format: &str, start: Option<&str>, end: Option<&str>, ) -> Result { let store = self.lock()?; let pl = pnl_statement(store.ledger(), start, end, None)?; if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ "start": pl.start.to_string(), "end": pl.end.to_string(), "revenue_accounts": lines_out(&pl.revenue), "expense_accounts": lines_out(&pl.expenses), "revenue": money(pl.total_revenue), "expenses": money(pl.total_expenses), "net_income": money(pl.net_income()), })) .map_err(|e| e.to_string()); } let mut csv = String::from("section,code,name,amount\n"); for (section, lines) in [("revenue", &pl.revenue), ("expenses", &pl.expenses)] { for l in lines { csv.push_str(&format!( "{section},{},{},{}\n", csv_escape(&l.code), csv_escape(&l.name), money(l.amount) )); } } csv.push_str(&format!( "total,,revenue,{}\ntotal,,expenses,{}\ntotal,,net_income,{}\n", money(pl.total_revenue), money(pl.total_expenses), money(pl.net_income()) )); Ok(csv) } fn export_balance_sheet(&self, format: &str, as_of: Option<&str>) -> Result { let store = self.lock()?; let bs = bs_statement(store.ledger(), as_of)?; if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ "as_of": bs.as_of.to_string(), "asset_accounts": lines_out(&bs.assets), "liability_accounts": lines_out(&bs.liabilities), "equity_accounts": lines_out(&bs.equity), "assets": money(bs.total_assets), "liabilities": money(bs.total_liabilities), "equity": money(bs.total_equity), "current_earnings": money(bs.current_earnings), "balanced": bs.balanced(), })) .map_err(|e| e.to_string()); } let mut csv = String::from("section,code,name,amount\n"); for (section, lines) in [ ("assets", &bs.assets), ("liabilities", &bs.liabilities), ("equity", &bs.equity), ] { for l in lines { csv.push_str(&format!( "{section},{},{},{}\n", csv_escape(&l.code), csv_escape(&l.name), money(l.amount) )); } } csv.push_str(&format!( "equity,,current_earnings,{}\ntotal,,assets,{}\ntotal,,liabilities,{}\ntotal,,equity,{}\n", money(bs.current_earnings), money(bs.total_assets), money(bs.total_liabilities), money(bs.total_equity) )); Ok(csv) } fn export_cash_flow(&self, format: &str, p: &ExportParams) -> Result { let store = self.lock()?; let ledger = store.ledger(); let mut cash_accounts = Vec::new(); for code in &p.cash_accounts { let id = ledger .chart() .find_by_code(code) .map(|a| a.id) .ok_or_else(|| format!("unknown cash account code: {code}"))?; cash_accounts.push(id); } if cash_accounts.is_empty() { return Err("cash_accounts is required for cash_flow".to_string()); } 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 => today(), }; let stmt = ledger_core::cash_flow( ledger, &ledger_core::CashFlowParams { start, end, cash_accounts, financing_accounts: vec![], }, ) .map_err(|e| e.to_string())?; if format == "json" { return serde_json::to_string_pretty(&serde_json::json!({ "start": stmt.start.to_string(), "end": stmt.end.to_string(), "net_income": money(stmt.net_income), "depreciation": money(stmt.depreciation), "working_capital": stmt.working_capital.iter().map(|l| serde_json::json!({ "label": l.label, "amount": money(l.amount), })).collect::>(), "cash_from_operations": money(stmt.cash_from_operations), "investing": stmt.investing.iter().map(|l| serde_json::json!({ "label": l.label, "amount": money(l.amount), })).collect::>(), "cash_from_investing": money(stmt.cash_from_investing), "financing": stmt.financing.iter().map(|l| serde_json::json!({ "label": l.label, "amount": money(l.amount), })).collect::>(), "cash_from_financing": money(stmt.cash_from_financing), "net_change_in_cash": money(stmt.net_change_in_cash), "cash_beginning": money(stmt.cash_beginning), "cash_ending": money(stmt.cash_ending), })) .map_err(|e| e.to_string()); } let mut csv = String::from("section,label,amount\n"); csv.push_str(&format!( "operating,Net income,{}\n", money(stmt.net_income) )); csv.push_str(&format!( "operating,Depreciation,{}\n", money(stmt.depreciation) )); for l in &stmt.working_capital { csv.push_str(&format!( "operating,{},{}\n", csv_escape(&l.label), money(l.amount) )); } csv.push_str(&format!( "operating,Cash from operations,{}\n", money(stmt.cash_from_operations) )); for l in &stmt.investing { csv.push_str(&format!( "investing,{},{}\n", csv_escape(&l.label), money(l.amount) )); } csv.push_str(&format!( "investing,Cash from investing,{}\n", money(stmt.cash_from_investing) )); for l in &stmt.financing { csv.push_str(&format!( "financing,{},{}\n", csv_escape(&l.label), money(l.amount) )); } csv.push_str(&format!( "financing,Cash from financing,{}\n", money(stmt.cash_from_financing) )); csv.push_str(&format!( "reconciliation,Net change in cash,{}\n", money(stmt.net_change_in_cash) )); Ok(csv) } } fn csv_escape(s: &str) -> String { if s.contains([',', '"', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } } // --------------------------------------------------------------------------- // ServerHandler // --------------------------------------------------------------------------- impl BooksServer { /// Render a resource URI to its JSON text. Used by `read_resource`. async fn resource_text(&self, uri: &str) -> Result { // Split off the query string for URIs like books://digest?since=... let (path, query) = match uri.split_once('?') { Some((p, q)) => (p, q), None => (uri, ""), }; match path { "books://chart" => { let Json(out) = self.chart().await?; serde_json::to_string_pretty(&out).map_err(|e| e.to_string()) } "books://reports/trial-balance" => { let Json(out) = self .trial_balance(Parameters(TrialBalanceParams::default())) .await?; serde_json::to_string_pretty(&out).map_err(|e| e.to_string()) } "books://reports/balance-sheet" => { let Json(out) = self .balance_sheet(Parameters(BalanceSheetParams { as_of: None })) .await?; serde_json::to_string_pretty(&out).map_err(|e| e.to_string()) } "books://reports/profit-and-loss" => { let Json(out) = self .profit_and_loss(Parameters(ProfitAndLossParams { start: None, end: None, class: None, })) .await?; serde_json::to_string_pretty(&out).map_err(|e| e.to_string()) } "books://digest" => { let since = query .split('&') .find_map(|kv| kv.strip_prefix("since=")) .unwrap_or("") .to_string(); let Json(out) = self.digest(Parameters(DigestParams { since })).await?; serde_json::to_string_pretty(&out).map_err(|e| e.to_string()) } _ if path.starts_with("books://receipts/") => { let id: u64 = path .strip_prefix("books://receipts/") .unwrap_or("") .parse() .map_err(|_| format!("bad receipt resource URI: {uri}"))?; let Json(out) = self .get_receipt(Parameters(GetReceiptParams { attachment_id: id })) .await?; serde_json::to_string_pretty(&out).map_err(|e| e.to_string()) } _ => Err(format!("unknown resource: {uri}")), } } } #[tool_handler] impl ServerHandler for BooksServer { fn get_info(&self) -> ServerConfig { ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_resources() .build(), ) .with_instructions(INSTRUCTIONS) .with_server_info(Implementation::new( "wanderbooks", env!("CARGO_PKG_VERSION"), )) } async fn list_resources( &self, _request: Option, _context: RequestContext, ) -> Result { let mk = |uri: &str, name: &str, title: &str, description: &str| { let mut r = Resource::new(uri, name); r.title = Some(title.to_string()); r.description = Some(description.to_string()); r.mime_type = Some("application/json".to_string()); r }; let resources = vec![ mk( "books://chart", "chart", "Chart of accounts", "All accounts with codes, types, and hierarchy.", ), mk( "books://reports/trial-balance", "trial-balance", "Trial balance", "Current trial balance, debits equal credits.", ), mk( "books://reports/balance-sheet", "balance-sheet", "Balance sheet", "Assets, liabilities, equity.", ), mk( "books://reports/profit-and-loss", "profit-and-loss", "Profit and loss", "Revenue, expenses, net income.", ), mk( "books://digest", "digest", "Activity digest", "What changed in the books. Optional ?since=YYYY-MM-DD query.", ), ]; Ok(ListResourcesResult::with_all_items(resources)) } async fn read_resource( &self, request: ReadResourceRequestParams, _context: RequestContext, ) -> Result { let uri = request.uri.clone(); let text = self.resource_text(&uri).await.map_err(|e| { rmcp::ErrorData::resource_not_found(uri.clone(), Some(serde_json::json!({"reason": e}))) })?; Ok(ReadResourceResponse::Complete(ReadResourceResult::new( vec![ResourceContents::TextResourceContents { uri, mime_type: Some("application/json".to_string()), text, meta: None, }], ))) } } #[cfg(test)] mod tests { use super::money; use rust_decimal::Decimal; use std::str::FromStr; #[test] fn money_always_has_cents_and_never_rounds() { let m = |s: &str| money(Decimal::from_str(s).unwrap()); assert_eq!(money(Decimal::ZERO), "0.00"); assert_eq!(m("-0"), "0.00"); assert_eq!(m("1000"), "1000.00"); assert_eq!(m("5.8"), "5.80"); assert_eq!(m("-207.68"), "-207.68"); assert_eq!(m("12.3456"), "12.3456"); } }