Control roles in the journal (books.assign_role, books.roles); the MCP reloads wanderbooks.toml on change and reports settings problems (books.settings, digest); new writes a full commented wanderbooks.toml
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
- core: ControlAccount keys, Ledger::assign_role/check_assign_role/role_account (rules.md B-23): one account per role, of the role's type, balance sheet roles move only when empty; invoices, bills, payroll, remittance, and closes post to the assigned account; an assigned role frees its default code for renumbering - store: role.assigned journaled and replayed (strict account_id) - mcp: settings.rs reloads by content at the start of each request, keeps the last good settings on a broken edit; books.settings with chart problems; digest settings_problems; instructions say when to edit the toml - viewer: journal text for role.assigned - cli: full example wanderbooks.toml, tested to load with every example uncommented - tests: spec_roles.rs (25, spec-blind), store roles.rs (6), MCP reload/problems/roles, settings unit tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
efa370dfa3
commit
1771d87e0e
19 files changed
+3044
-132
No files matched your search
@@ -7,11 +7,12 @@
|
||||
//! and optionally `config.json`. If no store exists, one is created with the
|
||||
//! configured base currency. Company settings (name, fiscal year start,
|
||||
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
||||
//! default the one next to the data dir.
|
||||
//! default the one next to the data dir, and reload whenever it changes
|
||||
//! (settings.rs).
|
||||
|
||||
use ledger_config as company;
|
||||
mod policy;
|
||||
mod server;
|
||||
mod settings;
|
||||
mod types;
|
||||
mod wire;
|
||||
|
||||
@@ -94,22 +95,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
};
|
||||
// Logs go to stderr: stdout is the MCP channel.
|
||||
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
||||
// An explicit --books-toml must exist; the default is optional.
|
||||
let settings = match books_toml {
|
||||
Some(path) => company::load(&path)?,
|
||||
None => match data_dir.parent().map(|p| p.join("wanderbooks.toml")) {
|
||||
Some(path) if path.exists() => company::load(&path)?,
|
||||
_ => company::BooksSettings::default(),
|
||||
},
|
||||
// An explicit --books-toml must exist; the default (beside the data
|
||||
// dir) is optional. Either is reloaded whenever it changes.
|
||||
let source = settings::Source {
|
||||
required: books_toml.is_some(),
|
||||
books_toml: books_toml.unwrap_or_else(|| {
|
||||
data_dir
|
||||
.parent()
|
||||
.map(|p| p.join("wanderbooks.toml"))
|
||||
.unwrap_or_else(|| PathBuf::from("wanderbooks.toml"))
|
||||
}),
|
||||
config_json: config_path.clone(),
|
||||
};
|
||||
eprintln!("ledger-mcp: company {:?}", settings.company.name);
|
||||
let policy = company::effective_receipt_policy(&config_path, &settings)?;
|
||||
let roles = server::CashRoles {
|
||||
cash: settings.cash_accounts,
|
||||
financing: settings.financing_accounts,
|
||||
investing: settings.investing_accounts,
|
||||
};
|
||||
let company = settings.company;
|
||||
let live = settings::Live::watching(source)?;
|
||||
eprintln!("ledger-mcp: company {:?}", live.current().company.name);
|
||||
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
|
||||
|
||||
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||
open_waiting_for_lock(&data_dir)?
|
||||
@@ -134,14 +134,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.build()?;
|
||||
if let Some(addr) = http_addr {
|
||||
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
|
||||
rt.block_on(serve_http(addr, store, policy, company, roles))?;
|
||||
rt.block_on(serve_http(addr, store, live))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
||||
let server = BooksServer::new(store, policy)
|
||||
.with_company(company)
|
||||
.with_cash_roles(roles);
|
||||
let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live);
|
||||
rt.block_on(async {
|
||||
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
|
||||
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
|
||||
@@ -156,9 +154,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
async fn serve_http(
|
||||
addr: String,
|
||||
store: Store,
|
||||
policy: ReceiptPolicy,
|
||||
company: company::Company,
|
||||
roles: server::CashRoles,
|
||||
live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpService, session::local::LocalSessionManager,
|
||||
@@ -168,9 +164,8 @@ async fn serve_http(
|
||||
let store = Arc::new(std::sync::Mutex::new(store));
|
||||
let service = StreamableHttpService::new(
|
||||
move || {
|
||||
let server = server::BooksServer::new_shared(store.clone(), policy.clone())
|
||||
.with_company(company.clone())
|
||||
.with_cash_roles(roles.clone());
|
||||
let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
|
||||
.with_live_settings(live.clone());
|
||||
Ok(server)
|
||||
},
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
|
||||
+286
-81
@@ -31,8 +31,10 @@
|
||||
//! ```
|
||||
|
||||
use crate::policy::ReceiptPolicy;
|
||||
use crate::settings::{Live, Settings};
|
||||
use crate::types::*;
|
||||
use chrono::NaiveDate;
|
||||
#[cfg(test)]
|
||||
use ledger_config::Company;
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
||||
@@ -52,7 +54,7 @@ use rust_decimal::Decimal;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The agent operating contract, delivered via initialize.
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control accounts: invoices, bills, payroll, and closes post to accounts found by code (1200, 2000, 4000, 6999, 3999, 6100-2150). If the chart already uses one of those codes for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check the names before running payroll, bills, or invoices. Never renumber the human's accounts on your own: explain the conflict, and when they decide, books.renumber_account moves the account (its history stays).";
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when bo
Line truncated
|
||||
|
||||
/// Max receipt/document upload: 25 MB.
|
||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
@@ -63,40 +65,35 @@ const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
#[derive(Clone)]
|
||||
pub struct BooksServer {
|
||||
store: Arc<Mutex<Store>>,
|
||||
policy: ReceiptPolicy,
|
||||
company: Company,
|
||||
roles: CashRoles,
|
||||
settings: Arc<Mutex<Live>>,
|
||||
}
|
||||
|
||||
/// `[books]` account roles for the cash flow statement, used when a call
|
||||
/// doesn't pass its own: cash, financing, and investing account codes.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CashRoles {
|
||||
/// `[books] cash_accounts`.
|
||||
pub cash: Vec<String>,
|
||||
/// `[books] financing_accounts`.
|
||||
pub financing: Vec<String>,
|
||||
/// `[books] investing_accounts`.
|
||||
pub investing: Vec<String>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub use crate::settings::CashRoles;
|
||||
|
||||
impl BooksServer {
|
||||
/// The wanderbooks.toml settings that name account `code`.
|
||||
fn config_mentions(&self, code: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (key, codes) in [
|
||||
("[books] cash_accounts", &self.roles.cash),
|
||||
("[books] financing_accounts", &self.roles.financing),
|
||||
("[books] investing_accounts", &self.roles.investing),
|
||||
("[books] cash_accounts", &self.settings().roles.cash),
|
||||
(
|
||||
"[books] financing_accounts",
|
||||
&self.settings().roles.financing,
|
||||
),
|
||||
(
|
||||
"[books] investing_accounts",
|
||||
&self.settings().roles.investing,
|
||||
),
|
||||
] {
|
||||
if codes.iter().any(|c| c == code) {
|
||||
out.push(key.to_string());
|
||||
}
|
||||
}
|
||||
if self.company.close_into.as_deref() == Some(code) {
|
||||
if self.settings().company.close_into.as_deref() == Some(code) {
|
||||
out.push("[company] close_into".to_string());
|
||||
}
|
||||
for m in &self.company.members {
|
||||
for m in &self.settings().company.members {
|
||||
if m.capital_account == code {
|
||||
out.push(format!("[[company.members]] {} capital_account", m.name));
|
||||
}
|
||||
@@ -112,12 +109,7 @@ impl BooksServer {
|
||||
|
||||
/// Build the server around an open store.
|
||||
pub fn new(store: Store, policy: ReceiptPolicy) -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(store)),
|
||||
policy,
|
||||
company: Company::default(),
|
||||
roles: CashRoles::default(),
|
||||
}
|
||||
Self::new_shared(Arc::new(Mutex::new(store)), policy)
|
||||
}
|
||||
|
||||
/// Build the server around a shared store (for HTTP: each connection
|
||||
@@ -125,24 +117,46 @@ impl BooksServer {
|
||||
pub fn new_shared(store: Arc<Mutex<Store>>, policy: ReceiptPolicy) -> Self {
|
||||
Self {
|
||||
store,
|
||||
policy,
|
||||
company: Company::default(),
|
||||
roles: CashRoles::default(),
|
||||
settings: Arc::new(Mutex::new(Live::fixed(Settings {
|
||||
policy,
|
||||
..Settings::default()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the settings from files, reloading them whenever they change
|
||||
/// (crate::settings), shared with the other sessions holding `live`.
|
||||
pub fn with_live_settings(mut self, live: Arc<Mutex<Live>>) -> Self {
|
||||
self.settings = live;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the company (name for report headers, fiscal year).
|
||||
pub fn with_company(mut self, company: Company) -> Self {
|
||||
self.company = company;
|
||||
#[cfg(test)]
|
||||
pub fn with_company(self, company: Company) -> Self {
|
||||
self.live().edit(|s| s.company = company);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the `[books]` cash flow account roles.
|
||||
pub fn with_cash_roles(mut self, roles: CashRoles) -> Self {
|
||||
self.roles = roles;
|
||||
#[cfg(test)]
|
||||
pub fn with_cash_roles(self, roles: CashRoles) -> Self {
|
||||
self.live().edit(|s| s.roles = roles);
|
||||
self
|
||||
}
|
||||
|
||||
fn live(&self) -> std::sync::MutexGuard<'_, Live> {
|
||||
// A panic while holding it can't leave the settings half-written
|
||||
// (they're swapped whole), so a poisoned lock is still good.
|
||||
self.settings.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The settings in force for this request (reloaded at its start, in
|
||||
/// `call_tool` and `read_resource`, if their files changed).
|
||||
fn settings(&self) -> Arc<Settings> {
|
||||
self.live().current()
|
||||
}
|
||||
|
||||
/// The cash flow statement from account codes, each list defaulting to
|
||||
/// the `[books]` roles when the call passes none.
|
||||
fn cash_flow_for(
|
||||
@@ -165,17 +179,17 @@ impl BooksServer {
|
||||
ledger,
|
||||
start,
|
||||
end,
|
||||
&or(cash, &self.roles.cash),
|
||||
&or(financing, &self.roles.financing),
|
||||
&or(investing, &self.roles.investing),
|
||||
&or(cash, &self.settings().roles.cash),
|
||||
&or(financing, &self.settings().roles.financing),
|
||||
&or(investing, &self.settings().roles.investing),
|
||||
)
|
||||
}
|
||||
|
||||
/// Header and provenance for a rendered report.
|
||||
fn report_meta(&self, store: &Store) -> ReportMeta {
|
||||
ReportMeta {
|
||||
company: self.company.name.clone(),
|
||||
basis: self.company.basis.clone(),
|
||||
company: self.settings().company.name.clone(),
|
||||
basis: self.settings().company.basis.clone(),
|
||||
prepared_on: today(),
|
||||
journal_seq: store.journal_seq(),
|
||||
}
|
||||
@@ -183,7 +197,8 @@ impl BooksServer {
|
||||
|
||||
/// Members as reports name them (empty unless a multi-member LLC).
|
||||
fn report_members(&self) -> Vec<ledger_import::Member> {
|
||||
self.company
|
||||
self.settings()
|
||||
.company
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| ledger_import::Member {
|
||||
@@ -197,13 +212,14 @@ impl BooksServer {
|
||||
/// Members with their capital accounts resolved in the chart, for the
|
||||
/// statement of members' equity.
|
||||
fn member_capitals(&self, ledger: &Ledger) -> Result<Vec<ledger_core::MemberCapital>, String> {
|
||||
if self.company.members.is_empty() {
|
||||
if self.settings().company.members.is_empty() {
|
||||
return Err(
|
||||
"no members configured: add [[company.members]] (name, share, capital_account) to wanderbooks.toml"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
self.company
|
||||
self.settings()
|
||||
.company
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
@@ -250,7 +266,7 @@ impl BooksServer {
|
||||
.map(|a| a.id)
|
||||
.ok_or_else(|| format!("wanderbooks.toml names unknown account {c}"))
|
||||
};
|
||||
if !self.company.members.is_empty() {
|
||||
if !self.settings().company.members.is_empty() {
|
||||
return Ok(ledger_core::CloseTo::Members(
|
||||
self.member_capitals(ledger)?
|
||||
.into_iter()
|
||||
@@ -263,12 +279,13 @@ impl BooksServer {
|
||||
));
|
||||
}
|
||||
let sweep = self
|
||||
.settings()
|
||||
.company
|
||||
.close_sweep
|
||||
.iter()
|
||||
.map(|c| code(c))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(match self.company.close_into.as_deref() {
|
||||
Ok(match self.settings().company.close_into.as_deref() {
|
||||
None | Some(ledger_core::RETAINED_EARNINGS_CODE) => {
|
||||
ledger_core::CloseTo::RetainedEarnings { sweep }
|
||||
}
|
||||
@@ -288,7 +305,7 @@ impl BooksServer {
|
||||
ledger: &Ledger,
|
||||
as_of: NaiveDate,
|
||||
) -> Result<ledger_import::YearEarnings, String> {
|
||||
let (fy_start, _) = self.company.fiscal_year_containing(as_of);
|
||||
let (fy_start, _) = self.settings().company.fiscal_year_containing(as_of);
|
||||
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.net_income();
|
||||
@@ -339,8 +356,16 @@ impl BooksServer {
|
||||
if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let split_group = self.policy.split_group(store.ledger(), entry_id, entry);
|
||||
if !self.policy.receipt_required(store.ledger(), entry) && split_group.is_empty() {
|
||||
let split_group = self
|
||||
.settings()
|
||||
.policy
|
||||
.split_group(store.ledger(), entry_id, entry);
|
||||
if !self
|
||||
.settings()
|
||||
.policy
|
||||
.receipt_required(store.ledger(), entry)
|
||||
&& split_group.is_empty()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let total = ReceiptPolicy::entry_debits(entry);
|
||||
@@ -349,12 +374,12 @@ impl BooksServer {
|
||||
message: format!(
|
||||
"entry {entry_id} moves {} in debits ({}); unlinking its last receipt would leave it without one.",
|
||||
money(total),
|
||||
self.policy.describe()
|
||||
self.settings().policy.describe()
|
||||
),
|
||||
entry_id: Some(entry_id),
|
||||
asset_id: None,
|
||||
debits: total.to_string(),
|
||||
policy: self.policy.describe(),
|
||||
policy: self.settings().policy.describe(),
|
||||
remedy_tool: "books.attach_receipt".to_string(),
|
||||
remedy_hint: format!(
|
||||
"attach the replacement receipt to entry {entry_id} with books.attach_receipt first, then retry books.unlink_receipt"
|
||||
@@ -388,6 +413,78 @@ fn parse_side(s: &str) -> Result<Side, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings that disagree with the books: each names the setting and
|
||||
/// what to change. A code must name an account in the chart, of the kind
|
||||
/// the setting needs (cash and investing: assets; financing: liabilities;
|
||||
/// capital, distributions, close_into, close_sweep: equity).
|
||||
fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut check = |setting: &str, code: &str, want: &[AccountType]| {
|
||||
match ledger.chart().find_by_code(code) {
|
||||
None => out.push(format!(
|
||||
"{setting} names {code}, which isn't in the chart: add that account, or change {setting} in wanderbooks.toml"
|
||||
)),
|
||||
Some(a) if !want.contains(&a.account_type) => out.push(format!(
|
||||
"{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting} in wanderbooks.toml",
|
||||
a.name,
|
||||
a.account_type,
|
||||
want.iter().map(|t| format!("{t:?}")).collect::<Vec<_>>().join(" or "),
|
||||
)),
|
||||
Some(_) => {}
|
||||
}
|
||||
};
|
||||
use AccountType::*;
|
||||
for code in &cfg.roles.cash {
|
||||
check("[books] cash_accounts", code, &[Asset]);
|
||||
}
|
||||
for code in &cfg.roles.financing {
|
||||
check("[books] financing_accounts", code, &[Liability, Equity]);
|
||||
}
|
||||
for code in &cfg.roles.investing {
|
||||
check("[books] investing_accounts", code, &[Asset]);
|
||||
}
|
||||
let c = &cfg.company;
|
||||
for m in &c.members {
|
||||
check(
|
||||
&format!("[[company.members]] {} capital_account", m.name),
|
||||
&m.capital_account,
|
||||
&[Equity],
|
||||
);
|
||||
if let Some(d) = &m.distribution_account {
|
||||
check(
|
||||
&format!("[[company.members]] {} distribution_account", m.name),
|
||||
d,
|
||||
&[Equity],
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(code) = &c.close_into {
|
||||
check("[books] close_into", code, &[Equity]);
|
||||
}
|
||||
for code in &c.close_sweep {
|
||||
check("[books] close_sweep", code, &[Equity]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every control role and the account filling it, for books.roles.
|
||||
fn roles_out(ledger: &Ledger) -> Vec<RoleOut> {
|
||||
ledger_core::control_accounts()
|
||||
.map(|c| {
|
||||
let account = ledger.role_account(c).and_then(|id| ledger.chart().get(id));
|
||||
RoleOut {
|
||||
role: c.key.to_string(),
|
||||
purpose: c.role.to_string(),
|
||||
account_type: format!("{:?}", c.account_type),
|
||||
default_code: c.code.to_string(),
|
||||
assigned: ledger.role_assignment(c).is_some(),
|
||||
account_code: account.map(|a| a.code.clone()),
|
||||
account_name: account.map(|a| a.name.clone()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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");
|
||||
@@ -1066,11 +1163,17 @@ impl BooksServer {
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut receipt_required = self.policy.receipt_required(store.ledger(), &entry);
|
||||
let mut receipt_required = self
|
||||
.settings()
|
||||
.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);
|
||||
let split_group = self
|
||||
.settings()
|
||||
.policy
|
||||
.split_group(store.ledger(), p.entry_id, &entry);
|
||||
if !split_group.is_empty() {
|
||||
receipt_required = true;
|
||||
}
|
||||
@@ -1105,13 +1208,13 @@ impl BooksServer {
|
||||
"entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}",
|
||||
p.entry_id,
|
||||
money(total),
|
||||
self.policy.describe(),
|
||||
self.settings().policy.describe(),
|
||||
split_note
|
||||
),
|
||||
entry_id: Some(p.entry_id),
|
||||
asset_id: None,
|
||||
debits: total.to_string(),
|
||||
policy: self.policy.describe(),
|
||||
policy: self.settings().policy.describe(),
|
||||
remedy_tool: "books.attach_receipt".to_string(),
|
||||
remedy_hint: format!(
|
||||
"attach a receipt to entry {} with books.attach_receipt, then retry books.post_entry with confirmed=true",
|
||||
@@ -1706,7 +1809,7 @@ impl BooksServer {
|
||||
let receipts = store
|
||||
.list_attachments(Some(e.id()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let required = self.policy.receipt_required(ledger, e);
|
||||
let required = self.settings().policy.receipt_required(ledger, e);
|
||||
entries.push(DigestEntry {
|
||||
entry_id: e.id(),
|
||||
date: e.date().to_string(),
|
||||
@@ -1761,6 +1864,16 @@ impl BooksServer {
|
||||
totals_by_account: totals,
|
||||
stale_rules,
|
||||
journal_repairs,
|
||||
settings_problems: {
|
||||
let mut problems = settings_problems(store.ledger(), &self.settings());
|
||||
if let Some(e) = self.live().status().error {
|
||||
problems.insert(
|
||||
0,
|
||||
format!("wanderbooks.toml didn't load (the last good settings are in force): {e}"),
|
||||
);
|
||||
}
|
||||
problems
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2346,7 +2459,7 @@ impl BooksServer {
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn tax_categories(&self) -> Result<Json<TaxCategoriesOut>, String> {
|
||||
let form = self.company.tax_form;
|
||||
let form = self.settings().company.tax_form;
|
||||
let categories = ledger_core::tax::CATEGORIES
|
||||
.iter()
|
||||
.filter(|c| form.is_none_or(|f| c.forms.contains(&f)))
|
||||
@@ -2398,6 +2511,80 @@ impl BooksServer {
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.settings",
|
||||
description = "The settings in force from wanderbooks.toml (company, fiscal year, tax form, members, basis, receipt rule, cash flow roles, close target), the file they come from, and problems: settings that disagree with the chart, each saying what to change. The server reloads the file whenever it changes, with no restart; after editing it, call this to confirm the edit loaded (reload_error says why one didn't, and the last good settings stay in force).",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn settings_tool(&self) -> Result<Json<SettingsOut>, String> {
|
||||
let status = self.live().status();
|
||||
let cfg = self.settings();
|
||||
let store = self.lock()?;
|
||||
let c = &cfg.company;
|
||||
Ok(Json(SettingsOut {
|
||||
books_toml: status.books_toml.map(|p| p.display().to_string()),
|
||||
books_toml_exists: status.books_toml_exists,
|
||||
config_json: status.config_json.map(|p| p.display().to_string()),
|
||||
reload_error: status.error,
|
||||
loads: status.loads,
|
||||
company: c.name.clone(),
|
||||
fiscal_year_start: format!("{:02}-{:02}", c.fiscal_year_start.0, c.fiscal_year_start.1),
|
||||
tax_form: c.tax_form.map(|f| f.label().to_string()),
|
||||
basis: c.basis.clone(),
|
||||
receipt_rule: cfg.policy.rule(),
|
||||
members: c
|
||||
.members
|
||||
.iter()
|
||||
.map(|m| MemberSettingOut {
|
||||
name: m.name.clone(),
|
||||
share: m.share.normalize().to_string(),
|
||||
capital_account: m.capital_account.clone(),
|
||||
distribution_account: m.distribution_account.clone(),
|
||||
})
|
||||
.collect(),
|
||||
cash_accounts: cfg.roles.cash.clone(),
|
||||
financing_accounts: cfg.roles.financing.clone(),
|
||||
investing_accounts: cfg.roles.investing.clone(),
|
||||
close_into: c.close_into.clone(),
|
||||
close_sweep: c.close_sweep.clone(),
|
||||
problems: settings_problems(store.ledger(), &cfg),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.roles",
|
||||
description = "The control roles invoices, bills, payroll, and closes post to (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings), and the account filling each: the one assigned with books.assign_role, else the account at the role's default code.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn roles(&self) -> Result<Json<RolesOut>, String> {
|
||||
let store = self.lock()?;
|
||||
Ok(Json(RolesOut {
|
||||
roles: roles_out(store.ledger()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.assign_role",
|
||||
description = "Assign a control role (see books.roles) one of the chart's own accounts, so invoices, bills, payroll, or closes post there instead of the default code; account null returns the role to its default code. Journaled. Refuses an account of the wrong type, a placeholder, one already filling another role, and moving a receivables, payables, withholding, or retained earnings role while its account holds a balance (move the balance with an entry first). Posted entries never change. Only when the human agrees."
|
||||
)]
|
||||
async fn assign_role(
|
||||
&self,
|
||||
params: Parameters<AssignRoleParams>,
|
||||
) -> Result<Json<RolesOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let account = match &p.account {
|
||||
Some(a) => Some(resolve_account(&store, a)?),
|
||||
None => None,
|
||||
};
|
||||
store
|
||||
.assign_role(&p.role, account)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(RolesOut {
|
||||
roles: roles_out(store.ledger()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_tax_category",
|
||||
description = "Map a revenue or expense account to a tax category (see books.tax_categories), optionally overriding its deductible percent; category null clears it. Drives the tax_summary report."
|
||||
@@ -2420,7 +2607,7 @@ impl BooksServer {
|
||||
.get(id)
|
||||
.expect("resolved")
|
||||
.account_type;
|
||||
ledger_core::tax::check_category(cat, t, self.company.tax_form)
|
||||
ledger_core::tax::check_category(cat, t, self.settings().company.tax_form)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
store
|
||||
@@ -2470,8 +2657,12 @@ impl BooksServer {
|
||||
.add_account(&row.code, &row.name, account_type, parent, false)
|
||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||
if let Some(cat) = &row.tax_category {
|
||||
ledger_core::tax::check_category(cat, account_type, self.company.tax_form)
|
||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||
ledger_core::tax::check_category(
|
||||
cat,
|
||||
account_type,
|
||||
self.settings().company.tax_form,
|
||||
)
|
||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||
scratch
|
||||
.set_account_tax(id, Some(cat), None)
|
||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||
@@ -2677,7 +2868,7 @@ impl BooksServer {
|
||||
"tax_summary" => {
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
report::tax_summary(
|
||||
&ledger_core::tax_summary(ledger, start, end, self.company.tax_form)
|
||||
&ledger_core::tax_summary(ledger, start, end, self.settings().company.tax_form)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
@@ -2745,12 +2936,14 @@ impl BooksServer {
|
||||
.registered_assets()
|
||||
.flat_map(|a| [a.cost_account, a.accum_depr_account])
|
||||
.collect();
|
||||
let receivables = ledger.role_account(&ledger_core::RECEIVABLES);
|
||||
let payables = ledger.role_account(&ledger_core::PAYABLES);
|
||||
let accounts = iif_accounts(ledger, |a| {
|
||||
if self.roles.cash.contains(&a.code) {
|
||||
if self.settings().roles.cash.contains(&a.code) {
|
||||
Qb::Bank
|
||||
} else if a.code == ledger_core::RECEIVABLES.code {
|
||||
} else if Some(a.id) == receivables {
|
||||
Qb::AccountsReceivable
|
||||
} else if a.code == ledger_core::PAYABLES.code {
|
||||
} else if Some(a.id) == payables {
|
||||
Qb::AccountsPayable
|
||||
} else if fixed.contains(&a.id) {
|
||||
Qb::FixedAsset
|
||||
@@ -3003,7 +3196,10 @@ impl BooksServer {
|
||||
} else {
|
||||
Decimal::ZERO
|
||||
};
|
||||
let receipt_required = self.policy.receipt_required_for(&code_refs, debits);
|
||||
let receipt_required = self
|
||||
.settings()
|
||||
.policy
|
||||
.receipt_required_for(&code_refs, debits);
|
||||
|
||||
let out = DisposeAssetOut {
|
||||
status: "preview".to_string(),
|
||||
@@ -3028,12 +3224,12 @@ impl BooksServer {
|
||||
"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()
|
||||
self.settings().policy.describe()
|
||||
),
|
||||
entry_id: None,
|
||||
asset_id: Some(p.asset_id),
|
||||
debits: debits.to_string(),
|
||||
policy: self.policy.describe(),
|
||||
policy: self.settings().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>]"
|
||||
@@ -3552,15 +3748,12 @@ impl BooksServer {
|
||||
let mut store = self.lock()?;
|
||||
let cash = resolve_account(&store, &p.cash_account)?;
|
||||
// Total before remitting, for the response.
|
||||
let mut total = Decimal::ZERO;
|
||||
for code in ["2110", "2120", "2130", "2140", "2150"] {
|
||||
if let Some(a) = store.ledger().chart().find_by_code(code) {
|
||||
total += store
|
||||
.ledger()
|
||||
.account_balance(a.id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
let total: Decimal = store
|
||||
.ledger()
|
||||
.payroll_tax_payables()
|
||||
.iter()
|
||||
.map(|(_, b)| *b)
|
||||
.sum();
|
||||
let entry_id = store
|
||||
.remit_payroll_taxes(parse_date(&p.date)?, cash)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -3755,7 +3948,7 @@ impl BooksServer {
|
||||
let ledger = store.ledger();
|
||||
|
||||
// Fiscal year to date, per wanderbooks.toml's fiscal_year_start.
|
||||
let (fy_start, _) = self.company.fiscal_year_containing(as_of);
|
||||
let (fy_start, _) = self.settings().company.fiscal_year_containing(as_of);
|
||||
let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string());
|
||||
let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
|
||||
let pdf =
|
||||
@@ -3797,7 +3990,7 @@ impl BooksServer {
|
||||
),
|
||||
),
|
||||
];
|
||||
if !self.company.members.is_empty() {
|
||||
if !self.settings().company.members.is_empty() {
|
||||
let me = ledger_core::members_equity(ledger, fy, as_of, &self.member_capitals(ledger)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
statements.push(("members-equity", report::members_equity(&me)));
|
||||
@@ -3805,7 +3998,7 @@ impl BooksServer {
|
||||
statements.push((
|
||||
"tax-summary",
|
||||
report::tax_summary(
|
||||
&ledger_core::tax_summary(ledger, fy, as_of, self.company.tax_form)
|
||||
&ledger_core::tax_summary(ledger, fy, as_of, self.settings().company.tax_form)
|
||||
.map_err(|e| e.to_string())?,
|
||||
),
|
||||
));
|
||||
@@ -3922,9 +4115,9 @@ impl BooksServer {
|
||||
));
|
||||
let readme = format!(
|
||||
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\ngeneral-journal.iif: every posted entry, for import into QuickBooks Desktop.\n1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold.\n{receipts}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
|
||||
name = self.company.name,
|
||||
basis = self.company.basis,
|
||||
members = if self.company.members.is_empty() {
|
||||
name = self.settings().company.name,
|
||||
basis = self.settings().company.basis,
|
||||
members = if self.settings().company.members.is_empty() {
|
||||
""
|
||||
} else {
|
||||
"members' equity, "
|
||||
@@ -4116,7 +4309,7 @@ impl BooksServer {
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||
let ts = ledger_core::tax_summary(ledger, start, end, self.company.tax_form)
|
||||
let ts = ledger_core::tax_summary(ledger, start, end, self.settings().company.tax_form)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let line = |l: &ledger_core::TaxSummaryLine| {
|
||||
serde_json::json!({
|
||||
@@ -4443,6 +4636,17 @@ impl BooksServer {
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for BooksServer {
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: rmcp::model::CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
|
||||
// Pick up an edited wanderbooks.toml before the tool runs.
|
||||
self.live().refresh();
|
||||
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
|
||||
Self::tool_router().call(tcc).await
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerConfig {
|
||||
ServerConfig::new(
|
||||
ServerCapabilities::builder()
|
||||
@@ -4453,7 +4657,7 @@ impl ServerHandler for BooksServer {
|
||||
// Tell the agent the policy these books actually use.
|
||||
.with_instructions(INSTRUCTIONS.replace(
|
||||
"any entry moving more than $25.00 in debits needs at least one receipt",
|
||||
&self.policy.rule(),
|
||||
&self.settings().policy.rule(),
|
||||
))
|
||||
.with_server_info(Implementation::new(
|
||||
"wanderbooks",
|
||||
@@ -4513,6 +4717,7 @@ impl ServerHandler for BooksServer {
|
||||
request: ReadResourceRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
|
||||
self.live().refresh();
|
||||
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})))
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! The server's settings (`wanderbooks.toml`, and the receipt policy in
|
||||
//! `books-data/config.json`), reloaded whenever their files change.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an edit to wanderbooks.toml takes effect on the next tool call,
|
||||
//! with no restart, and a broken edit never takes the server down.
|
||||
//! SOURCE: DECISION (Stephen, 2026-10-15): the configuration is always
|
||||
//! auto-reloaded.
|
||||
//! INVARIANTS:
|
||||
//! 1. Every request (tool call, resource read) sees the files' contents
|
||||
//! as of its start, one version for the whole request: a request after
|
||||
//! an edit sees the edit (compared by content, not by timestamp, so two
|
||||
//! edits in one second are both seen).
|
||||
//! 2. A file that fails to load leaves the last good settings in force,
|
||||
//! and the failure is reported (books.settings, stderr) until a later
|
||||
//! edit loads.
|
||||
//! 3. A settings file that doesn't exist and wasn't asked for (the
|
||||
//! default wanderbooks.toml beside the books) means the defaults; one
|
||||
//! named with --books-toml that goes missing is a failure (2).
|
||||
//! 4. Only the settings reload: the data dir and the books' base currency
|
||||
//! are fixed when the server starts.
|
||||
//! ```
|
||||
|
||||
use crate::policy::ReceiptPolicy;
|
||||
use ledger_config::Company;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `[books]` account roles for the cash flow statement, used when a call
|
||||
/// doesn't pass its own: cash, financing, and investing account codes.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CashRoles {
|
||||
/// `[books] cash_accounts`.
|
||||
pub cash: Vec<String>,
|
||||
/// `[books] financing_accounts`.
|
||||
pub financing: Vec<String>,
|
||||
/// `[books] investing_accounts`.
|
||||
pub investing: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the settings files say, as last loaded.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Settings {
|
||||
/// Which entries need a receipt.
|
||||
pub policy: ReceiptPolicy,
|
||||
/// `[company]`.
|
||||
pub company: Company,
|
||||
/// `[books]` cash flow roles.
|
||||
pub roles: CashRoles,
|
||||
}
|
||||
|
||||
/// Where the settings come from.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Source {
|
||||
/// The books' wanderbooks.toml.
|
||||
pub books_toml: PathBuf,
|
||||
/// Whether it must exist (named with --books-toml) or may be absent
|
||||
/// (the default beside the books: then the defaults apply).
|
||||
pub required: bool,
|
||||
/// `config.json`, for its receipt policy (optional).
|
||||
pub config_json: PathBuf,
|
||||
}
|
||||
|
||||
/// Both files' bytes (`None`: absent), to tell when either changed.
|
||||
type Seen = (Option<Vec<u8>>, Option<Vec<u8>>);
|
||||
|
||||
impl Source {
|
||||
fn read(&self) -> Seen {
|
||||
(
|
||||
std::fs::read(&self.books_toml).ok(),
|
||||
std::fs::read(&self.config_json).ok(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Load the settings now.
|
||||
pub fn load(&self) -> Result<Settings, String> {
|
||||
let books = if self.required || self.books_toml.exists() {
|
||||
ledger_config::load(&self.books_toml)?
|
||||
} else {
|
||||
ledger_config::BooksSettings::default()
|
||||
};
|
||||
let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)?;
|
||||
Ok(Settings {
|
||||
policy,
|
||||
roles: CashRoles {
|
||||
cash: books.cash_accounts,
|
||||
financing: books.financing_accounts,
|
||||
investing: books.investing_accounts,
|
||||
},
|
||||
company: books.company,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings in force, and how they got there.
|
||||
#[derive(Debug)]
|
||||
pub struct Live {
|
||||
current: Arc<Settings>,
|
||||
source: Option<Source>,
|
||||
seen: Seen,
|
||||
error: Option<String>,
|
||||
loads: u64,
|
||||
}
|
||||
|
||||
/// What books.settings reports about the files.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Status {
|
||||
/// The wanderbooks.toml read, if the settings come from files.
|
||||
pub books_toml: Option<PathBuf>,
|
||||
/// Whether that file exists now.
|
||||
pub books_toml_exists: bool,
|
||||
/// The config.json read.
|
||||
pub config_json: Option<PathBuf>,
|
||||
/// Why the last change failed to load (the last good settings stay in
|
||||
/// force), or `None`.
|
||||
pub error: Option<String>,
|
||||
/// How many times the settings have loaded, the first included.
|
||||
pub loads: u64,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
/// Settings that never reload (tests, and servers built in code).
|
||||
pub fn fixed(settings: Settings) -> Self {
|
||||
Self {
|
||||
current: Arc::new(settings),
|
||||
source: None,
|
||||
seen: (None, None),
|
||||
error: None,
|
||||
loads: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load from `source`, and reload from it whenever it changes. Fails
|
||||
/// only on this first load: a server doesn't start on broken settings.
|
||||
pub fn watching(source: Source) -> Result<Self, String> {
|
||||
let seen = source.read();
|
||||
let settings = source.load()?;
|
||||
Ok(Self {
|
||||
current: Arc::new(settings),
|
||||
source: Some(source),
|
||||
seen,
|
||||
error: None,
|
||||
loads: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// The settings in force.
|
||||
pub fn current(&self) -> Arc<Settings> {
|
||||
self.current.clone()
|
||||
}
|
||||
|
||||
/// Reload if a file changed since the last look: the server calls this
|
||||
/// once at the start of every request, so the whole request sees one
|
||||
/// version.
|
||||
pub fn refresh(&mut self) {
|
||||
if let Some(source) = &self.source {
|
||||
let now = source.read();
|
||||
if now != self.seen {
|
||||
self.seen = now;
|
||||
match source.load() {
|
||||
Ok(s) => {
|
||||
self.current = Arc::new(s);
|
||||
self.error = None;
|
||||
self.loads += 1;
|
||||
eprintln!("ledger-mcp: reloaded {}", source.books_toml.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"ledger-mcp: settings not reloaded (keeping the last good ones): {e}"
|
||||
);
|
||||
self.error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the settings in force (builders; never reloads them away
|
||||
/// unless a watched file changes).
|
||||
#[cfg(test)]
|
||||
pub fn edit(&mut self, f: impl FnOnce(&mut Settings)) {
|
||||
f(Arc::make_mut(&mut self.current));
|
||||
}
|
||||
|
||||
/// Where the settings come from and whether the last change loaded.
|
||||
pub fn status(&mut self) -> Status {
|
||||
self.refresh();
|
||||
Status {
|
||||
books_toml: self.source.as_ref().map(|s| s.books_toml.clone()),
|
||||
books_toml_exists: self.source.as_ref().is_some_and(|s| s.books_toml.exists()),
|
||||
config_json: self.source.as_ref().map(|s| s.config_json.clone()),
|
||||
error: self.error.clone(),
|
||||
loads: self.loads,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(tag: &str) -> PathBuf {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("ledger-mcp-settings-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn source(d: &std::path::Path, required: bool) -> Source {
|
||||
Source {
|
||||
books_toml: d.join("wanderbooks.toml"),
|
||||
required,
|
||||
config_json: d.join("config.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 3: an absent default means the defaults, and the file
|
||||
/// loads once it appears; an unchanged file is not reloaded.
|
||||
#[test]
|
||||
fn an_absent_default_is_the_defaults_until_it_appears() {
|
||||
let d = dir("absent");
|
||||
let mut live = Live::watching(source(&d, false)).unwrap();
|
||||
assert_eq!(live.current().company.name, Company::default().name);
|
||||
live.refresh();
|
||||
assert_eq!(live.status().loads, 1, "nothing changed");
|
||||
std::fs::write(
|
||||
d.join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Late Co\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
live.refresh();
|
||||
assert_eq!(live.current().company.name, "Late Co");
|
||||
assert_eq!(live.status().loads, 2);
|
||||
assert!(live.status().books_toml_exists);
|
||||
}
|
||||
|
||||
/// INVARIANT 3: a named file must exist to start; INVARIANT 2: the
|
||||
/// first load fails on broken settings, a later one keeps the last.
|
||||
#[test]
|
||||
fn a_named_file_must_load_to_start() {
|
||||
let d = dir("named");
|
||||
assert!(Live::watching(source(&d, true)).is_err());
|
||||
std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap();
|
||||
assert!(Live::watching(source(&d, true)).is_err());
|
||||
std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = \"One\"\n").unwrap();
|
||||
let mut live = Live::watching(source(&d, true)).unwrap();
|
||||
std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = 5\n").unwrap();
|
||||
live.refresh();
|
||||
assert_eq!(live.current().company.name, "One");
|
||||
assert!(live.status().error.is_some());
|
||||
}
|
||||
|
||||
/// The receipt policy in config.json is watched too.
|
||||
#[test]
|
||||
fn the_config_json_policy_reloads() {
|
||||
let d = dir("json");
|
||||
std::fs::write(
|
||||
d.join("wanderbooks.toml"),
|
||||
"[books]\nreceipt_threshold = \"25.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut live = Live::watching(source(&d, true)).unwrap();
|
||||
let before = live.current().policy.rule();
|
||||
std::fs::write(
|
||||
d.join("config.json"),
|
||||
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
live.refresh();
|
||||
assert_ne!(live.current().policy.rule(), before);
|
||||
assert!(live.status().error.is_none(), "{:?}", live.status().error);
|
||||
}
|
||||
|
||||
/// Settings built in code never reload.
|
||||
#[test]
|
||||
fn fixed_settings_stay() {
|
||||
let mut live = Live::fixed(Settings::default());
|
||||
live.refresh();
|
||||
let s = live.status();
|
||||
assert_eq!((s.loads, s.books_toml), (1, None));
|
||||
}
|
||||
}
|
||||
@@ -499,10 +499,106 @@ pub struct RenumberAccountOut {
|
||||
/// The code it has now.
|
||||
pub code: String,
|
||||
/// Settings in wanderbooks.toml that still name the old code: change
|
||||
/// each to the new one, then restart the server.
|
||||
/// each to the new one (the server reloads the file).
|
||||
pub config_mentions: Vec<String>,
|
||||
}
|
||||
|
||||
/// One member, as `books.settings` reports it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MemberSettingOut {
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// Share of income, as a decimal string ("0.5").
|
||||
pub share: String,
|
||||
/// Capital account code.
|
||||
pub capital_account: String,
|
||||
/// Distributions account code, if any.
|
||||
pub distribution_account: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.settings`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SettingsOut {
|
||||
/// The wanderbooks.toml the server reads (null: settings built in, not
|
||||
/// from a file).
|
||||
pub books_toml: Option<String>,
|
||||
/// Whether that file exists (absent: the defaults apply).
|
||||
pub books_toml_exists: bool,
|
||||
/// The config.json read for a receipt policy.
|
||||
pub config_json: Option<String>,
|
||||
/// Why the last edit failed to load, or null. While set, the settings
|
||||
/// below are the last ones that loaded: fix the file.
|
||||
pub reload_error: Option<String>,
|
||||
/// How many times the settings have loaded (the first included); it
|
||||
/// goes up each time an edit loads.
|
||||
pub loads: u64,
|
||||
/// `[company] name`.
|
||||
pub company: String,
|
||||
/// `[company] fiscal_year_start`, "MM-DD".
|
||||
pub fiscal_year_start: String,
|
||||
/// The return filed (`tax_form`, or from `tax_classification`), or null.
|
||||
pub tax_form: Option<String>,
|
||||
/// `[books] accounting_basis`.
|
||||
pub basis: String,
|
||||
/// The receipt rule in force.
|
||||
pub receipt_rule: String,
|
||||
/// `[[company.members]]`.
|
||||
pub members: Vec<MemberSettingOut>,
|
||||
/// `[books] cash_accounts`.
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// `[books] financing_accounts`.
|
||||
pub financing_accounts: Vec<String>,
|
||||
/// `[books] investing_accounts`.
|
||||
pub investing_accounts: Vec<String>,
|
||||
/// `[books] close_into`.
|
||||
pub close_into: Option<String>,
|
||||
/// `[books] close_sweep`.
|
||||
pub close_sweep: Vec<String>,
|
||||
/// Settings that disagree with the books, each saying what to change in
|
||||
/// wanderbooks.toml (or the chart). Empty: nothing to fix.
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.assign_role`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct AssignRoleParams {
|
||||
/// The role, from books.roles: receivables, revenue, payables,
|
||||
/// general_expense, retained_earnings, salary_expense,
|
||||
/// payroll_tax_expense, federal_withholding, state_withholding,
|
||||
/// social_security_payable, medicare_payable, other_deductions_payable.
|
||||
pub role: String,
|
||||
/// The account to fill it (code or id); null returns the role to its
|
||||
/// default code.
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
/// One control role and the account filling it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RoleOut {
|
||||
/// The role's key, for books.assign_role.
|
||||
pub role: String,
|
||||
/// What it is for, e.g. "accounts receivable".
|
||||
pub purpose: String,
|
||||
/// The account type it needs.
|
||||
pub account_type: String,
|
||||
/// The code it uses when no account is assigned.
|
||||
pub default_code: String,
|
||||
/// Whether an account is assigned (false: the default code is used).
|
||||
pub assigned: bool,
|
||||
/// The account filling it now: its code, or null when none exists yet
|
||||
/// (the first command that needs it creates it at the default code).
|
||||
pub account_code: Option<String>,
|
||||
/// That account's name.
|
||||
pub account_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.roles`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RolesOut {
|
||||
/// Every control role, in the engine's order.
|
||||
pub roles: Vec<RoleOut>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_tax_category`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SetTaxCategoryParams {
|
||||
@@ -1307,6 +1403,10 @@ pub struct DigestOut {
|
||||
/// lost; tell the human, and if they had just asked for a change,
|
||||
/// check it is there and redo it if not.
|
||||
pub journal_repairs: Vec<JournalRepair>,
|
||||
/// Settings in wanderbooks.toml that need changing: an edit that
|
||||
/// didn't load, or a setting that disagrees with the chart. Fix each
|
||||
/// with the human, then check books.settings.
|
||||
pub settings_problems: Vec<String>,
|
||||
}
|
||||
|
||||
/// A torn journal tail set aside on open.
|
||||
|
||||
Reference in new issue
Block a user