Files
wanderbooks/crates/ledger-mcp/src/server.rs
T

2529 lines
96 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, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
depreciation_schedule, reports,
};
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- 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<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,
}
}
/// Build the server around a shared store (for HTTP: each connection
/// gets a handle to the same store).
pub fn new_shared(store: Arc<Mutex<Store>>, policy: ReceiptPolicy) -> Self {
Self { 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\"")),
}
}
/// 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<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.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<CashFlowParams>,
) -> Result<Json<CashFlowOut>, 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(),
};
// 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<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. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate."
)]
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 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())?;
}
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 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<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.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<ImportBankCsvParams>,
) -> Result<Json<ImportBankCsvOut>, 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<ImportChartCsvParams>,
) -> Result<Json<ImportChartCsvOut>, 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<ImportLocalFileParams>,
) -> Result<Json<ImportLocalFileOut>, 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. Formats: csv, json, xlsx (base64), iif (journal only, for QuickBooks Desktop). Reports: journal, trial_balance, profit_and_loss, balance_sheet, cash_flow.",
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 !["csv", "json", "xlsx", "iif"].contains(&format.as_str()) {
return Err(format!(
"bad format {:?}: expected \"csv\", \"json\", \"xlsx\", 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" {
let bytes = self.export_xlsx(&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" => 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())?,
"cash_flow" => self.export_cash_flow(&format, &p)?,
_ => {
return Err(format!(
"bad report {:?}: expected journal, trial_balance, profit_and_loss, balance_sheet, or cash_flow",
p.report
));
}
};
Ok(Json(ExportOut {
format,
report,
content,
content_encoding: None,
}))
}
/// Render a report to XLSX bytes via ledger-import.
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
use ledger_import::{XlsxReport, report_to_xlsx};
let store = self.lock()?;
let ledger = store.ledger();
// Build owned report data first (all report structs are owned).
enum Owned {
Tb(ledger_core::reports::TrialBalance),
Pnl(ledger_core::reports::ProfitAndLoss, String, String),
Bs(ledger_core::reports::BalanceSheet, String),
Cf(ledger_core::reports::CashFlowStatement),
}
let owned = match report {
"trial_balance" => Owned::Tb(reports::trial_balance(ledger)),
"profit_and_loss" => {
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(),
};
Owned::Pnl(
reports::profit_and_loss(ledger).map_err(|e| e.to_string())?,
start.to_string(),
end.to_string(),
)
}
"balance_sheet" => {
let as_of = match &p.as_of {
Some(s) => parse_date(s)?,
None => chrono::Utc::now().date_naive(),
};
// Engine balance_sheet is as-of now; xlsx uses the same.
// (The books.balance_sheet tool does as-of via movement_through.)
Owned::Bs(
reports::balance_sheet(ledger).map_err(|e| e.to_string())?,
as_of.to_string(),
)
}
"cash_flow" => {
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 => chrono::Utc::now().date_naive(),
};
Owned::Cf(
ledger_core::cash_flow(
ledger,
&ledger_core::CashFlowParams {
start,
end,
cash_accounts,
financing_accounts: vec![],
},
)
.map_err(|e| e.to_string())?,
)
}
_ => {
return Err(format!(
"bad report {report:?} for xlsx: expected trial_balance, profit_and_loss, balance_sheet, or cash_flow"
));
}
};
let xlsx_report = match &owned {
Owned::Tb(tb) => XlsxReport::TrialBalance(tb),
Owned::Pnl(pl, s, e) => XlsxReport::ProfitAndLoss(pl, s.clone(), e.clone()),
Owned::Bs(bs, a) => XlsxReport::BalanceSheet(bs, a.clone()),
Owned::Cf(cf) => XlsxReport::CashFlow(cf),
};
report_to_xlsx(&xlsx_report).map_err(|e| e.to_string())
}
/// Render the journal as IIF text via ledger-import.
fn export_iif(&self) -> Result<String, String> {
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<IifEntry<'_>> = 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<RegisterAssetParams>,
) -> Result<Json<RegisterAssetOut>, 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<AssetScheduleParams>,
) -> Result<Json<AssetScheduleOut>, 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<Json<ListAssetsOut>, String> {
let store = self.lock()?;
let today = chrono::Utc::now().date_naive();
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<RunDepreciationParams>,
) -> Result<Json<RunDepreciationOut>, 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<DisposeAssetParams>,
) -> Result<Json<DisposeAssetOut>, 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<String> = [
asset.accum_depr_account,
proceeds_account,
asset.cost_account,
gain_loss_account,
]
.iter()
.map(|id| code_of(*id))
.collect::<Result<_, _>>()?;
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=[<id>]"
.to_string(),
split_group: Vec::new(),
}
.to_tool_error());
}
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<CreatePeriodParams>,
) -> Result<Json<CreatePeriodOut>, 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. Without confirmed=true, returns a preview of the closing entry (revenue/expense zeroed to Retained Earnings) and closes nothing. With confirmed=true, posts the closing entry and marks the period closed. Closed periods reject new entries."
)]
async fn close_period(
&self,
params: Parameters<ClosePeriodParams>,
) -> Result<Json<ClosePeriodOut>, 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));
}
if !p.confirmed {
// Preview: compute what the closing entry would look like
// without mutating. We do this by inspecting revenue/expense
// balances directly.
let mut lines = Vec::new();
let mut accounts: Vec<_> = store.ledger().chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
match account.account_type {
AccountType::Revenue | AccountType::Expense => {
let movement = store.ledger().account_movement(account.id);
if movement.is_zero() {
continue;
}
let (side, amount) = if movement > Decimal::ZERO {
("credit", movement)
} else {
("debit", -movement)
};
lines.push(format!("{} {} {} to zero", side, account.code, amount));
}
_ => {}
}
}
return Ok(Json(ClosePeriodOut {
status: "preview".to_string(),
period_id: p.period_id,
closing_entry: None,
lines,
}));
}
let closing = store.close_period(p.period_id).map_err(|e| e.to_string())?;
// Build line descriptions from the posted closing entry.
let mut lines = Vec::new();
if let Some(eid) = closing {
if let Some(entry) = store.ledger().get_entry(eid) {
for line in entry.lines() {
let code = store
.ledger()
.chart()
.get(line.account)
.map(|a| a.code.clone())
.unwrap_or_default();
lines.push(format!("{:?} {} {}", line.side, code, line.amount.amount));
}
}
}
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<Json<ListPeriodsOut>, 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<MarkClearedParams>,
) -> Result<Json<MarkClearedOut>, 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<ReconcileParams>,
) -> Result<Json<ReconcileOut>, 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<AddPartyParams>,
) -> Result<Json<AddPartyOut>, 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<AddPartyParams>,
) -> Result<Json<AddPartyOut>, 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<CreateDocumentParams>,
) -> Result<Json<CreateDocumentOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let lines: Vec<DocumentLine> = p
.lines
.into_iter()
.map(|l| {
Ok(DocumentLine {
description: l.description,
amount: parse_amount(&l.amount)?,
})
})
.collect::<Result<_, String>>()?;
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<CreateDocumentParams>,
) -> Result<Json<CreateDocumentOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let lines: Vec<DocumentLine> = p
.lines
.into_iter()
.map(|l| {
Ok(DocumentLine {
description: l.description,
amount: parse_amount(&l.amount)?,
})
})
.collect::<Result<_, String>>()?;
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<PaymentParams>,
) -> Result<Json<PaymentOut>, 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<PaymentParams>,
) -> Result<Json<PaymentOut>, 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<AgingParams>) -> Result<Json<AgingOut>, 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<AgingParams>) -> Result<Json<AgingOut>, 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<SetFxRateParams>,
) -> Result<Json<SetFxRateOut>, 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 }))
}
}
// ---------------------------------------------------------------------------
// 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 export_cash_flow(&self, format: &str, p: &ExportParams) -> Result<String, String> {
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 => chrono::Utc::now().date_naive(),
};
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::<Vec<_>>(),
"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::<Vec<_>>(),
"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::<Vec<_>>(),
"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<String, String> {
// 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().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,
}))
.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<rmcp::model::PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, rmcp::ErrorData> {
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<RoleServer>,
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
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,
}],
)))
}
}