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