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) Waiting to run
check / check (push) Waiting to run
- 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
+3041
-129
No files matched your search
Generated
+2
@@ -3597,6 +3597,8 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
|||||||
name = "ledger-cli"
|
name = "ledger-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ledger-config",
|
||||||
|
"ledger-core",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml 1.1.8+spec-1.1.0",
|
"toml 1.1.8+spec-1.1.0",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ name = "wanderbooks"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
ledger-config = { path = "../ledger-config" }
|
||||||
|
ledger-core = { path = "../ledger-core" }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "1"
|
toml = "1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -143,7 +143,12 @@ anything. Keep it conversational; ask a few things at a time.
|
|||||||
tax form, close target) in `wanderbooks.toml`.
|
tax form, close target) in `wanderbooks.toml`.
|
||||||
3. Fill in `wanderbooks.toml` `[company]`: name, entity type, tax
|
3. Fill in `wanderbooks.toml` `[company]`: name, entity type, tax
|
||||||
classification, formation state and date, EIN, address. Don't store
|
classification, formation state and date, EIN, address. Don't store
|
||||||
SSNs or full account numbers anywhere in this directory.
|
SSNs or full account numbers anywhere in this directory. The server
|
||||||
|
reloads the file on every change: check `books.settings` after each
|
||||||
|
edit (no `reload_error`, no `problems`).
|
||||||
|
If the imported chart numbers receivables, payables, revenue, payroll,
|
||||||
|
or retained earnings differently from the defaults (`books.roles`),
|
||||||
|
assign those roles to its accounts with `books.assign_role`.
|
||||||
4. Record opening balances as one balanced opening entry (or the owners'
|
4. Record opening balances as one balanced opening entry (or the owners'
|
||||||
first contributions). Verify the trial balance before moving on.
|
first contributions). Verify the trial balance before moving on.
|
||||||
5. File the formation documents they have (articles, EIN letter,
|
5. File the formation documents they have (articles, EIN letter,
|
||||||
@@ -231,16 +236,86 @@ const CLAUDE_MD: &str = "@AGENTS.md\n";
|
|||||||
|
|
||||||
const WANDERBOOKS_TOML: &str = r#"# wanderbooks books dir config. [company] is filled in during onboarding —
|
const WANDERBOOKS_TOML: &str = r#"# wanderbooks books dir config. [company] is filled in during onboarding —
|
||||||
# the agent asks, the human answers, the agent writes it here.
|
# the agent asks, the human answers, the agent writes it here.
|
||||||
|
#
|
||||||
|
# Every setting is optional. A commented-out line is an example to fill in
|
||||||
|
# (uncomment it); a live line is the default. The MCP server and the
|
||||||
|
# viewer reload this file when it changes: no restart needed.
|
||||||
|
|
||||||
[company]
|
[company]
|
||||||
name = ""
|
# Legal name, for report headers.
|
||||||
|
# name = "Acme Widgets LLC"
|
||||||
|
# Shown in the profile. The journal's currency is fixed when the books are
|
||||||
|
# created (books-data/config.json base_currency, default USD).
|
||||||
base_currency = "USD"
|
base_currency = "USD"
|
||||||
|
# "MM-01": a fiscal year starts on the first of a month and ends the day
|
||||||
|
# before the next start (it ends on the last day of a month: IRS Pub. 538).
|
||||||
fiscal_year_start = "01-01"
|
fiscal_year_start = "01-01"
|
||||||
|
# The return the business files: "schedule_c", "1065", "1120s", or "1120".
|
||||||
|
# tax_form = "1065"
|
||||||
|
# Or the federal classification, and the return follows from it:
|
||||||
|
# "partnership", "S corporation", "C corporation", or "disregarded entity"
|
||||||
|
# (Treas. Reg. §301.7701-3). tax_form wins when both are set.
|
||||||
|
# tax_classification = "partnership"
|
||||||
|
# Identity facts the CPA needs, each from a primary document (EIN letter,
|
||||||
|
# certificate of organization). Any simple key = value line here shows in
|
||||||
|
# the viewer's profile, labelled from its key.
|
||||||
|
# entity_type = "LLC, multi-member, member-managed"
|
||||||
|
# formation_state = "UT"
|
||||||
|
# formed = "2026-01-15"
|
||||||
|
# ein = "00-0000000"
|
||||||
|
# address = "123 Main St, Salt Lake City, UT 84101"
|
||||||
|
# Never store owners' SSNs (or any TIN) in this directory: its git history
|
||||||
|
# keeps everything. The CPA collects them through their own channel.
|
||||||
|
|
||||||
|
# Members of a multi-member LLC, one block each: shares come from the
|
||||||
|
# operating agreement (IRC §704(a)), "50%" or "0.5", summing to 100%. Each
|
||||||
|
# names a capital account in the chart, and optionally a distributions
|
||||||
|
# account that a close zeroes into capital.
|
||||||
|
# [[company.members]]
|
||||||
|
# name = "Alex Rivera"
|
||||||
|
# share = "50%"
|
||||||
|
# capital_account = "3000"
|
||||||
|
# distribution_account = "3100"
|
||||||
|
#
|
||||||
|
# [[company.members]]
|
||||||
|
# name = "Sam Chen"
|
||||||
|
# share = "50%"
|
||||||
|
# capital_account = "3001"
|
||||||
|
# distribution_account = "3101"
|
||||||
|
|
||||||
[books]
|
[books]
|
||||||
|
# The books' data, relative to this file. The tools take it from --data-dir
|
||||||
|
# (the justfile, .mcp.json), not from here: move them together.
|
||||||
data_dir = "books-data"
|
data_dir = "books-data"
|
||||||
|
# "accrual" or "cash", for report footers. Use the same method from year
|
||||||
|
# to year (IRS Pub. 538).
|
||||||
accounting_basis = "accrual"
|
accounting_basis = "accrual"
|
||||||
|
# An entry whose debits are strictly over this needs a receipt; "0.00"
|
||||||
|
# means every entry (IRS Pub. 583: keep the documents that support your
|
||||||
|
# books). A receipt_policy in books-data/config.json overrides it.
|
||||||
receipt_threshold = "25.00"
|
receipt_threshold = "25.00"
|
||||||
|
# Account codes that hold cash (bank, savings, cash on hand), for the cash
|
||||||
|
# flow statement and dashboards. Unset: cash accounts are found by name.
|
||||||
|
# cash_accounts = ["1000", "1010"]
|
||||||
|
# Liabilities the cash flow statement treats as financing: loans, and what
|
||||||
|
# owners advanced by paying company expenses (ASC 230-10-45-14(b)).
|
||||||
|
# financing_accounts = ["2100"]
|
||||||
|
# Assets it treats as investing: loans the company makes, investments it
|
||||||
|
# buys (ASC 230-10-20). Registered fixed assets need not be listed.
|
||||||
|
# investing_accounts = ["1400"]
|
||||||
|
# Single owner only (with [[company.members]] a close goes to each
|
||||||
|
# member's capital): the equity account a period close puts net income in.
|
||||||
|
# Unset: Retained Earnings (3999).
|
||||||
|
# close_into = "3000"
|
||||||
|
# Equity accounts a close zeroes into close_into (or Retained Earnings):
|
||||||
|
# draws, distributions, dividends.
|
||||||
|
# close_sweep = ["3100"]
|
||||||
|
|
||||||
|
# Which accounts invoices, bills, payroll, and closes post to (receivables,
|
||||||
|
# payables, revenue, payroll, retained earnings) is not set here: those
|
||||||
|
# roles are part of the books, journaled with books.assign_role and listed
|
||||||
|
# by books.roles. Until assigned, each uses its default code (1200, 2000,
|
||||||
|
# 4000, 6999, 3999, 6100, 6110, 2110-2150).
|
||||||
|
|
||||||
[viewer]
|
[viewer]
|
||||||
# When a session starts and the viewer isn't open (`just gui-status`), the
|
# When a session starts and the viewer isn't open (`just gui-status`), the
|
||||||
|
|||||||
@@ -230,3 +230,165 @@ fn codex_gets_its_own_named_server() {
|
|||||||
assert!(just.contains("codex mcp add \"wanderbooks-acme-books\""));
|
assert!(just.contains("codex mcp add \"wanderbooks-acme-books\""));
|
||||||
assert!(out.contains("just codex"), "{out}");
|
assert!(out.contains("just codex"), "{out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `wanderbooks.toml` a fresh `new` writes.
|
||||||
|
fn scaffolded_toml(tmp: &Path) -> (PathBuf, String) {
|
||||||
|
let books = tmp.join("b");
|
||||||
|
assert!(new(tmp, &books, &[]).0);
|
||||||
|
let path = books.join("wanderbooks.toml");
|
||||||
|
let text = fs::read_to_string(&path).unwrap();
|
||||||
|
(path, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The example a commented line holds, if it is one: `# key = value`, or
|
||||||
|
/// `# [section]` / `# [[section]]`. Prose comments are not examples.
|
||||||
|
fn example(line: &str) -> Option<&str> {
|
||||||
|
let rest = line.strip_prefix("# ")?;
|
||||||
|
let is_key = |k: &str| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
|
||||||
|
let header = rest
|
||||||
|
.trim_start_matches('[')
|
||||||
|
.trim_end_matches(']')
|
||||||
|
.split('.')
|
||||||
|
.all(is_key);
|
||||||
|
let assignment = rest
|
||||||
|
.split_once(" = ")
|
||||||
|
.is_some_and(|(k, v)| is_key(k) && !v.trim().is_empty());
|
||||||
|
((rest.starts_with('[') && rest.ends_with(']') && header) || assignment).then_some(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The template with the examples `pick` chooses uncommented.
|
||||||
|
fn uncomment(text: &str, pick: impl Fn(&str) -> bool) -> String {
|
||||||
|
text.lines()
|
||||||
|
.map(|l| match example(l) {
|
||||||
|
Some(ex) if pick(ex) => ex,
|
||||||
|
_ => l,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_scaffolded_toml_loads_with_the_defaults() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (path, text) = scaffolded_toml(tmp.path());
|
||||||
|
let s = ledger_config::load(&path).expect("scaffolded wanderbooks.toml loads");
|
||||||
|
let c = &s.company;
|
||||||
|
assert_eq!(c.name, "Unnamed company", "name is left for onboarding");
|
||||||
|
assert_eq!(c.fiscal_year_start, (1, 1));
|
||||||
|
assert_eq!(c.basis, "Accrual");
|
||||||
|
assert!(c.members.is_empty());
|
||||||
|
assert_eq!(c.tax_form, None);
|
||||||
|
assert_eq!(c.close_into, None);
|
||||||
|
assert!(c.close_sweep.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
c.profile,
|
||||||
|
[
|
||||||
|
("base_currency".to_string(), "USD".to_string()),
|
||||||
|
("fiscal_year_start".to_string(), "01-01".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
|
||||||
|
assert!(s.cash_accounts.is_empty());
|
||||||
|
assert!(s.financing_accounts.is_empty());
|
||||||
|
assert!(s.investing_accounts.is_empty());
|
||||||
|
let toml: toml::Value = toml::from_str(&text).unwrap();
|
||||||
|
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
|
||||||
|
// The justfile's session-start reads this line with sed, from column 0.
|
||||||
|
assert!(
|
||||||
|
text.lines().any(|l| l == "open_at_start = \"ask\""),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("SSN"), "the template warns off storing SSNs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_example_in_the_toml_is_valid() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (_, text) = scaffolded_toml(tmp.path());
|
||||||
|
let examples = text.lines().filter_map(example).count();
|
||||||
|
assert!(examples >= 20, "only {examples} examples found");
|
||||||
|
// close_into is for a single owner and refused with members, so it
|
||||||
|
// gets a pass of its own; together the two passes cover every example.
|
||||||
|
let single_owner = |ex: &str| ex.starts_with("close_into ");
|
||||||
|
let all = ledger_config::parse(&uncomment(&text, |ex| !single_owner(ex)))
|
||||||
|
.unwrap_or_else(|e| panic!("examples (without close_into) parse: {e}"));
|
||||||
|
let c = &all.company;
|
||||||
|
assert_eq!(c.name, "Acme Widgets LLC");
|
||||||
|
assert_eq!(c.tax_form, Some(ledger_core::TaxForm::Form1065));
|
||||||
|
assert_eq!(c.members.len(), 2);
|
||||||
|
assert_eq!(c.members[1].distribution_account.as_deref(), Some("3101"));
|
||||||
|
assert_eq!(c.close_sweep, ["3100"]);
|
||||||
|
assert!(c.profile.iter().any(|(k, _)| k == "ein"));
|
||||||
|
assert_eq!(all.cash_accounts, ["1000", "1010"]);
|
||||||
|
assert_eq!(all.financing_accounts, ["2100"]);
|
||||||
|
assert_eq!(all.investing_accounts, ["1400"]);
|
||||||
|
let single = ledger_config::parse(&uncomment(&text, single_owner))
|
||||||
|
.unwrap_or_else(|e| panic!("close_into example parses: {e}"));
|
||||||
|
assert_eq!(single.company.close_into.as_deref(), Some("3000"));
|
||||||
|
assert!(
|
||||||
|
ledger_config::parse(&uncomment(&text, |_| true)).is_err(),
|
||||||
|
"close_into with members is refused, as the template says"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The serde field names of `struct name` in ledger-config's source.
|
||||||
|
fn fields(src: &str, name: &str) -> Vec<String> {
|
||||||
|
let start = src
|
||||||
|
.find(&format!("struct {name} {{"))
|
||||||
|
.unwrap_or_else(|| panic!("ledger-config has struct {name}"));
|
||||||
|
let body = &src[start..];
|
||||||
|
let body = &body[..body.find("\n}").expect("struct ends")];
|
||||||
|
let found: Vec<String> = body
|
||||||
|
.lines()
|
||||||
|
.skip(1)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.starts_with("//") && !l.starts_with("#["))
|
||||||
|
.filter_map(|l| l.split_once(':'))
|
||||||
|
.map(|(k, _)| k.trim_start_matches("pub ").to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(!found.is_empty(), "struct {name} has fields");
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_toml_shows_every_setting_the_config_reads() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (_, text) = scaffolded_toml(tmp.path());
|
||||||
|
// (section, key) for every assignment, examples included.
|
||||||
|
let mut section = String::new();
|
||||||
|
let mut present = Vec::new();
|
||||||
|
for line in uncomment(&text, |_| true).lines() {
|
||||||
|
if line.starts_with('[') {
|
||||||
|
section = line.trim_matches(['[', ']']).to_string();
|
||||||
|
} else if let Some((k, _)) = line.split_once(" = ") {
|
||||||
|
present.push((section.clone(), k.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let has = |s: &str, k: &str| present.iter().any(|(ps, pk)| ps == s && pk == k);
|
||||||
|
// Read from the config crate's serde structs, so a new setting there
|
||||||
|
// fails here until the template shows it.
|
||||||
|
let src = include_str!("../../ledger-config/src/lib.rs");
|
||||||
|
assert_eq!(fields(src, "BooksToml"), ["company", "books"]);
|
||||||
|
for (strukt, sect) in [
|
||||||
|
("CompanyToml", "company"),
|
||||||
|
("MemberToml", "company.members"),
|
||||||
|
("BooksSection", "books"),
|
||||||
|
] {
|
||||||
|
for key in fields(src, strukt) {
|
||||||
|
if strukt == "CompanyToml" && key == "members" {
|
||||||
|
assert!(text.contains("# [[company.members]]"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(has(sect, &key), "[{sect}] {key} is in the template");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Read elsewhere: the justfile's session-start (main.rs), and the two
|
||||||
|
// the scaffold has always written live.
|
||||||
|
for (sect, key) in [
|
||||||
|
("viewer", "open_at_start"),
|
||||||
|
("company", "base_currency"),
|
||||||
|
("books", "data_dir"),
|
||||||
|
] {
|
||||||
|
assert!(has(sect, key), "[{sect}] {key} is in the template");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,8 +97,8 @@ pub enum LedgerError {
|
|||||||
/// decides with the human how to handle it.
|
/// decides with the human how to handle it.
|
||||||
#[error(
|
#[error(
|
||||||
"account {code} \"{name}\" is {}, but {role} needs account {code} to be a postable {expected:?} account; \
|
"account {code} \"{name}\" is {}, but {role} needs account {code} to be a postable {expected:?} account; \
|
||||||
wanderbooks won't post {role} there. Decide with the books' owner how to handle it \
|
wanderbooks won't post {role} there. Decide with the books' owner how to handle it: \
|
||||||
(for instance, renumber that account so {code} is free, if they agree)",
|
assign {role} to an account of theirs, or renumber that account so {code} is free",
|
||||||
if *placeholder { format!("a placeholder {found:?} account") } else { format!("a {found:?} account") }
|
if *placeholder { format!("a placeholder {found:?} account") } else { format!("a {found:?} account") }
|
||||||
)]
|
)]
|
||||||
ControlAccountMismatch {
|
ControlAccountMismatch {
|
||||||
@@ -116,6 +116,44 @@ pub enum LedgerError {
|
|||||||
placeholder: bool,
|
placeholder: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// No control role has that name (see `ledger_core::control_accounts`).
|
||||||
|
#[error(
|
||||||
|
"no role {0:?}; the roles are receivables, revenue, payables, general_expense, \
|
||||||
|
retained_earnings, salary_expense, payroll_tax_expense, federal_withholding, \
|
||||||
|
state_withholding, social_security_payable, medicare_payable, other_deductions_payable"
|
||||||
|
)]
|
||||||
|
UnknownRole(String),
|
||||||
|
|
||||||
|
/// One account per role: an account already filling a role can't take
|
||||||
|
/// a second (its balance would mix what the two control).
|
||||||
|
#[error("account {code} \"{name}\" already holds {role}; give each role its own account")]
|
||||||
|
RoleTaken {
|
||||||
|
/// The account's code.
|
||||||
|
code: String,
|
||||||
|
/// The account's name.
|
||||||
|
name: String,
|
||||||
|
/// The role it fills, e.g. "accounts receivable".
|
||||||
|
role: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A balance sheet role (receivables, payables, withholdings, retained
|
||||||
|
/// earnings) can't move while its account holds a balance: the control
|
||||||
|
/// account must keep agreeing with what it controls.
|
||||||
|
#[error(
|
||||||
|
"{role} is in account {code} \"{name}\", which holds {balance}; move that balance to the \
|
||||||
|
new account with a journal entry first, then assign the role"
|
||||||
|
)]
|
||||||
|
RoleHoldsBalance {
|
||||||
|
/// The role, e.g. "accounts receivable".
|
||||||
|
role: String,
|
||||||
|
/// The code of the account filling it now.
|
||||||
|
code: String,
|
||||||
|
/// That account's name.
|
||||||
|
name: String,
|
||||||
|
/// Its balance.
|
||||||
|
balance: rust_decimal::Decimal,
|
||||||
|
},
|
||||||
|
|
||||||
/// A rule set by law (a dollar threshold, a rate) isn't known to
|
/// A rule set by law (a dollar threshold, a rate) isn't known to
|
||||||
/// wanderbooks for the year asked; the engine never guesses (see
|
/// wanderbooks for the year asked; the engine never guesses (see
|
||||||
/// [`crate::rules`]).
|
/// [`crate::rules`]).
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ use std::collections::HashMap;
|
|||||||
/// (`LedgerError::ControlAccountMismatch`).
|
/// (`LedgerError::ControlAccountMismatch`).
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct ControlAccount {
|
pub struct ControlAccount {
|
||||||
/// The code the engine looks for.
|
/// The role's stable name, for assigning it an account
|
||||||
|
/// ([`Ledger::assign_role`]): `receivables`, `payables`, ...
|
||||||
|
pub key: &'static str,
|
||||||
|
/// The code the engine looks for when the role has no account assigned.
|
||||||
pub code: &'static str,
|
pub code: &'static str,
|
||||||
/// The name it gives the account when it creates it.
|
/// The name it gives the account when it creates it.
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
@@ -41,12 +44,14 @@ pub struct ControlAccount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fn control(
|
const fn control(
|
||||||
|
key: &'static str,
|
||||||
code: &'static str,
|
code: &'static str,
|
||||||
name: &'static str,
|
name: &'static str,
|
||||||
role: &'static str,
|
role: &'static str,
|
||||||
account_type: AccountType,
|
account_type: AccountType,
|
||||||
) -> ControlAccount {
|
) -> ControlAccount {
|
||||||
ControlAccount {
|
ControlAccount {
|
||||||
|
key,
|
||||||
code,
|
code,
|
||||||
name,
|
name,
|
||||||
role,
|
role,
|
||||||
@@ -56,16 +61,23 @@ const fn control(
|
|||||||
|
|
||||||
/// A control account; see [`ControlAccount`].
|
/// A control account; see [`ControlAccount`].
|
||||||
pub const RECEIVABLES: ControlAccount = control(
|
pub const RECEIVABLES: ControlAccount = control(
|
||||||
|
"receivables",
|
||||||
"1200",
|
"1200",
|
||||||
"Accounts Receivable",
|
"Accounts Receivable",
|
||||||
"accounts receivable",
|
"accounts receivable",
|
||||||
AccountType::Asset,
|
AccountType::Asset,
|
||||||
);
|
);
|
||||||
/// A control account; see [`ControlAccount`].
|
/// A control account; see [`ControlAccount`].
|
||||||
pub const REVENUE: ControlAccount =
|
pub const REVENUE: ControlAccount = control(
|
||||||
control("4000", "Revenue", "invoice revenue", AccountType::Revenue);
|
"revenue",
|
||||||
|
"4000",
|
||||||
|
"Revenue",
|
||||||
|
"invoice revenue",
|
||||||
|
AccountType::Revenue,
|
||||||
|
);
|
||||||
/// A control account; see [`ControlAccount`].
|
/// A control account; see [`ControlAccount`].
|
||||||
pub const PAYABLES: ControlAccount = control(
|
pub const PAYABLES: ControlAccount = control(
|
||||||
|
"payables",
|
||||||
"2000",
|
"2000",
|
||||||
"Accounts Payable",
|
"Accounts Payable",
|
||||||
"accounts payable",
|
"accounts payable",
|
||||||
@@ -73,6 +85,7 @@ pub const PAYABLES: ControlAccount = control(
|
|||||||
);
|
);
|
||||||
/// A control account; see [`ControlAccount`].
|
/// A control account; see [`ControlAccount`].
|
||||||
pub const GENERAL_EXPENSES: ControlAccount = control(
|
pub const GENERAL_EXPENSES: ControlAccount = control(
|
||||||
|
"general_expense",
|
||||||
"6999",
|
"6999",
|
||||||
"General Expenses",
|
"General Expenses",
|
||||||
"bill expense",
|
"bill expense",
|
||||||
@@ -80,6 +93,7 @@ pub const GENERAL_EXPENSES: ControlAccount = control(
|
|||||||
);
|
);
|
||||||
/// A control account; see [`ControlAccount`].
|
/// A control account; see [`ControlAccount`].
|
||||||
pub const RETAINED_EARNINGS: ControlAccount = control(
|
pub const RETAINED_EARNINGS: ControlAccount = control(
|
||||||
|
"retained_earnings",
|
||||||
periods::RETAINED_EARNINGS_CODE,
|
periods::RETAINED_EARNINGS_CODE,
|
||||||
"Retained Earnings",
|
"Retained Earnings",
|
||||||
"retained earnings",
|
"retained earnings",
|
||||||
@@ -98,45 +112,57 @@ pub fn control_accounts() -> impl Iterator<Item = &'static ControlAccount> {
|
|||||||
.chain(PAYROLL.iter())
|
.chain(PAYROLL.iter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The control role named `key` (see [`ControlAccount::key`]).
|
||||||
|
pub fn control_account(key: &str) -> Option<&'static ControlAccount> {
|
||||||
|
control_accounts().find(|c| c.key == key)
|
||||||
|
}
|
||||||
|
|
||||||
/// The payroll accounts, in [`PayrollAccounts`] order.
|
/// The payroll accounts, in [`PayrollAccounts`] order.
|
||||||
pub const PAYROLL: [ControlAccount; 7] = [
|
pub const PAYROLL: [ControlAccount; 7] = [
|
||||||
control(
|
control(
|
||||||
|
"salary_expense",
|
||||||
"6100",
|
"6100",
|
||||||
"Salary & Wage Expense",
|
"Salary & Wage Expense",
|
||||||
"salary expense",
|
"salary expense",
|
||||||
AccountType::Expense,
|
AccountType::Expense,
|
||||||
),
|
),
|
||||||
control(
|
control(
|
||||||
|
"payroll_tax_expense",
|
||||||
"6110",
|
"6110",
|
||||||
"Payroll Tax Expense",
|
"Payroll Tax Expense",
|
||||||
"payroll tax expense",
|
"payroll tax expense",
|
||||||
AccountType::Expense,
|
AccountType::Expense,
|
||||||
),
|
),
|
||||||
control(
|
control(
|
||||||
|
"federal_withholding",
|
||||||
"2110",
|
"2110",
|
||||||
"Federal Withholding Payable",
|
"Federal Withholding Payable",
|
||||||
"federal withholding payable",
|
"federal withholding payable",
|
||||||
AccountType::Liability,
|
AccountType::Liability,
|
||||||
),
|
),
|
||||||
control(
|
control(
|
||||||
|
"state_withholding",
|
||||||
"2120",
|
"2120",
|
||||||
"State Withholding Payable",
|
"State Withholding Payable",
|
||||||
"state withholding payable",
|
"state withholding payable",
|
||||||
AccountType::Liability,
|
AccountType::Liability,
|
||||||
),
|
),
|
||||||
control(
|
control(
|
||||||
|
"social_security_payable",
|
||||||
"2130",
|
"2130",
|
||||||
"SS Tax Payable",
|
"SS Tax Payable",
|
||||||
"social security tax payable",
|
"social security tax payable",
|
||||||
AccountType::Liability,
|
AccountType::Liability,
|
||||||
),
|
),
|
||||||
control(
|
control(
|
||||||
|
"medicare_payable",
|
||||||
"2140",
|
"2140",
|
||||||
"Medicare Tax Payable",
|
"Medicare Tax Payable",
|
||||||
"Medicare tax payable",
|
"Medicare tax payable",
|
||||||
AccountType::Liability,
|
AccountType::Liability,
|
||||||
),
|
),
|
||||||
control(
|
control(
|
||||||
|
"other_deductions_payable",
|
||||||
"2150",
|
"2150",
|
||||||
"Other Deductions Payable",
|
"Other Deductions Payable",
|
||||||
"other deductions payable",
|
"other deductions payable",
|
||||||
@@ -229,6 +255,9 @@ pub struct Ledger {
|
|||||||
invoice_payments: HashMap<InvoiceId, Vec<(EntryId, Decimal)>>,
|
invoice_payments: HashMap<InvoiceId, Vec<(EntryId, Decimal)>>,
|
||||||
/// Each bill's payments (entry, amount), for aging as of a date.
|
/// Each bill's payments (entry, amount), for aging as of a date.
|
||||||
bill_payments: HashMap<BillId, Vec<(EntryId, Decimal)>>,
|
bill_payments: HashMap<BillId, Vec<(EntryId, Decimal)>>,
|
||||||
|
/// Control roles assigned an account ([`Ledger::assign_role`]), by
|
||||||
|
/// [`ControlAccount::key`]. A role not here uses its code.
|
||||||
|
roles: std::collections::BTreeMap<&'static str, AccountId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ledger {
|
impl Ledger {
|
||||||
@@ -266,6 +295,7 @@ impl Ledger {
|
|||||||
entry_vendors: HashMap::new(),
|
entry_vendors: HashMap::new(),
|
||||||
invoice_payments: HashMap::new(),
|
invoice_payments: HashMap::new(),
|
||||||
bill_payments: HashMap::new(),
|
bill_payments: HashMap::new(),
|
||||||
|
roles: std::collections::BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,7 +805,8 @@ impl Ledger {
|
|||||||
if self.chart.find_by_code(code).is_some() {
|
if self.chart.find_by_code(code).is_some() {
|
||||||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
||||||
}
|
}
|
||||||
if let Some(c) = control_accounts().find(|c| c.code == code)
|
if let Some(c) =
|
||||||
|
control_accounts().find(|c| c.code == code && !self.roles.contains_key(c.key))
|
||||||
&& (a.account_type != c.account_type || a.placeholder)
|
&& (a.account_type != c.account_type || a.placeholder)
|
||||||
{
|
{
|
||||||
return Err(LedgerError::ControlAccountMismatch {
|
return Err(LedgerError::ControlAccountMismatch {
|
||||||
@@ -790,6 +821,124 @@ impl Ledger {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The account that fills a control role now: the one assigned to it,
|
||||||
|
/// else the account at the role's code (`None` when there is none yet;
|
||||||
|
/// the first command that needs it creates it).
|
||||||
|
pub fn role_account(&self, c: &ControlAccount) -> Option<AccountId> {
|
||||||
|
match self.roles.get(c.key) {
|
||||||
|
Some(&id) => Some(id),
|
||||||
|
None => self.chart.find_by_code(c.code).map(|a| a.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account assigned to a control role, if any (`None`: the role
|
||||||
|
/// uses its code).
|
||||||
|
pub fn role_assignment(&self, c: &ControlAccount) -> Option<AccountId> {
|
||||||
|
self.roles.get(c.key).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign a control role an account of the books' own numbering, or
|
||||||
|
/// (`None`) return it to its code. Invoices, bills, payroll, and closes
|
||||||
|
/// then post the role there.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: let a chart keep its own numbering: the engine's roles go to
|
||||||
|
/// the accounts the human picks, not only to fixed codes.
|
||||||
|
/// SOURCE: docs/spec/rules.md B-4 (the elements), B-23 (control roles);
|
||||||
|
/// DECISION (Stephen, 2026-10-15): roles are journaled, not
|
||||||
|
/// configured, so replay never depends on a file.
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. After an assignment, every command that posts the role posts it
|
||||||
|
/// to that account; after `None`, to the account at the role's
|
||||||
|
/// code (created when first needed, as before).
|
||||||
|
/// 2. No account fills two roles.
|
||||||
|
/// 3. A role's account is always postable and of the role's type.
|
||||||
|
/// 4. Posted entries never change: an assignment only steers what
|
||||||
|
/// later commands post.
|
||||||
|
/// 5. A refusal MUST leave the books unchanged.
|
||||||
|
/// SCENARIOS:
|
||||||
|
/// 1. IF the role is unknown, or the account is unknown: SHOULD refuse.
|
||||||
|
/// 2. IF the account (or, for `None`, the account at the role's
|
||||||
|
/// code) is of another type or a placeholder: SHOULD refuse with
|
||||||
|
/// ControlAccountMismatch.
|
||||||
|
/// 3. IF that account already fills another role: SHOULD refuse
|
||||||
|
/// with RoleTaken.
|
||||||
|
/// 4. IF the role is a balance sheet one (an asset, liability, or
|
||||||
|
/// equity role) and the account filling it now holds a balance,
|
||||||
|
/// and the role would move: SHOULD refuse with RoleHoldsBalance
|
||||||
|
/// BECAUSE the control account must keep agreeing with what it
|
||||||
|
/// controls (open invoices, bills, taxes withheld); move the
|
||||||
|
/// balance with an entry first. Revenue and expense roles move
|
||||||
|
/// freely.
|
||||||
|
/// 5. IF the role already resolves to that account: SHOULD accept
|
||||||
|
/// (the assignment is recorded).
|
||||||
|
/// ```
|
||||||
|
pub fn assign_role(
|
||||||
|
&mut self,
|
||||||
|
key: &str,
|
||||||
|
account: Option<AccountId>,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
let c = self.check_assign_role(key, account)?;
|
||||||
|
match account {
|
||||||
|
Some(id) => self.roles.insert(c.key, id),
|
||||||
|
None => self.roles.remove(c.key),
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validation behind [`Self::assign_role`], for stores that journal
|
||||||
|
/// before mutating. Returns the role.
|
||||||
|
pub fn check_assign_role(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
account: Option<AccountId>,
|
||||||
|
) -> Result<&'static ControlAccount, LedgerError> {
|
||||||
|
let c = control_account(key).ok_or_else(|| LedgerError::UnknownRole(key.to_string()))?;
|
||||||
|
let target = match account {
|
||||||
|
Some(id) => Some(self.chart.get(id).ok_or(LedgerError::UnknownAccount(id))?),
|
||||||
|
None => self.chart.find_by_code(c.code),
|
||||||
|
};
|
||||||
|
if let Some(a) = target {
|
||||||
|
if a.account_type != c.account_type || a.placeholder {
|
||||||
|
return Err(LedgerError::ControlAccountMismatch {
|
||||||
|
code: a.code.clone(),
|
||||||
|
name: a.name.clone(),
|
||||||
|
role: c.role.to_string(),
|
||||||
|
expected: c.account_type,
|
||||||
|
found: a.account_type,
|
||||||
|
placeholder: a.placeholder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(other) =
|
||||||
|
control_accounts().find(|o| o.key != c.key && self.role_account(o) == Some(a.id))
|
||||||
|
{
|
||||||
|
return Err(LedgerError::RoleTaken {
|
||||||
|
code: a.code.clone(),
|
||||||
|
name: a.name.clone(),
|
||||||
|
role: other.role.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let now = self.role_account(c);
|
||||||
|
let balance_sheet = !matches!(c.account_type, AccountType::Revenue | AccountType::Expense);
|
||||||
|
if let Some(current) = now
|
||||||
|
&& balance_sheet
|
||||||
|
&& now != target.map(|a| a.id)
|
||||||
|
{
|
||||||
|
let balance = self.account_balance(current).unwrap_or(Decimal::ZERO);
|
||||||
|
if !balance.is_zero() {
|
||||||
|
let a = self.chart.get(current).expect("a role's account exists");
|
||||||
|
return Err(LedgerError::RoleHoldsBalance {
|
||||||
|
role: c.role.to_string(),
|
||||||
|
code: a.code.clone(),
|
||||||
|
name: a.name.clone(),
|
||||||
|
balance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
|
||||||
/// Set (or clear, with `None`) an account's tax category, and optionally
|
/// Set (or clear, with `None`) an account's tax category, and optionally
|
||||||
/// override the category's deductible percent (0 to 100). The category
|
/// override the category's deductible percent (0 to 100). The category
|
||||||
/// must fit the account's type (see [`crate::tax::check_category`]).
|
/// must fit the account's type (see [`crate::tax::check_category`]).
|
||||||
@@ -1676,6 +1825,9 @@ impl Ledger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn retained_earnings_account(&mut self) -> Result<AccountId, LedgerError> {
|
fn retained_earnings_account(&mut self) -> Result<AccountId, LedgerError> {
|
||||||
|
if let Some(&id) = self.roles.get(RETAINED_EARNINGS.key) {
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
Ok(
|
Ok(
|
||||||
match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
|
match self.chart.find_by_code(periods::RETAINED_EARNINGS_CODE) {
|
||||||
Some(a) => a.id,
|
Some(a) => a.id,
|
||||||
@@ -1970,6 +2122,11 @@ impl Ledger {
|
|||||||
/// ```
|
/// ```
|
||||||
fn check_controls(&self, controls: &[ControlAccount]) -> Result<(), LedgerError> {
|
fn check_controls(&self, controls: &[ControlAccount]) -> Result<(), LedgerError> {
|
||||||
for c in controls {
|
for c in controls {
|
||||||
|
// An assigned account was checked when it was assigned, and
|
||||||
|
// an account's type never changes.
|
||||||
|
if self.roles.contains_key(c.key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(a) = self.chart.find_by_code(c.code)
|
if let Some(a) = self.chart.find_by_code(c.code)
|
||||||
&& (a.account_type != c.account_type || a.placeholder)
|
&& (a.account_type != c.account_type || a.placeholder)
|
||||||
{
|
{
|
||||||
@@ -1990,7 +2147,10 @@ impl Ledger {
|
|||||||
/// run [`Self::check_controls`] first, so an existing account here is
|
/// run [`Self::check_controls`] first, so an existing account here is
|
||||||
/// the right type.
|
/// the right type.
|
||||||
fn ensure_control(&mut self, c: &ControlAccount) -> AccountId {
|
fn ensure_control(&mut self, c: &ControlAccount) -> AccountId {
|
||||||
self.ensure_account(c.code, c.name, c.account_type)
|
match self.roles.get(c.key) {
|
||||||
|
Some(&id) => id,
|
||||||
|
None => self.ensure_account(c.code, c.name, c.account_type),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure an account exists by code, creating it with the given type
|
/// Ensure an account exists by code, creating it with the given type
|
||||||
@@ -2808,14 +2968,15 @@ impl Ledger {
|
|||||||
/// means owed.
|
/// means owed.
|
||||||
pub fn payroll_tax_payables(&self) -> Vec<(AccountId, Decimal)> {
|
pub fn payroll_tax_payables(&self) -> Vec<(AccountId, Decimal)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for code in ["2110", "2120", "2130", "2140", "2150"] {
|
for c in &PAYROLL[2..] {
|
||||||
let Some(account) = self.chart.find_by_code(code) else {
|
let Some(account) = self.role_account(c) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// The code resolved, so the balance lookup cannot fail.
|
// The role resolved to an account, so the balance lookup
|
||||||
let balance = self.account_balance(account.id).unwrap_or(Decimal::ZERO);
|
// cannot fail.
|
||||||
|
let balance = self.account_balance(account).unwrap_or(Decimal::ZERO);
|
||||||
if !balance.is_zero() {
|
if !balance.is_zero() {
|
||||||
out.push((account.id, balance));
|
out.push((account, balance));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_repor
|
|||||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||||
pub use ledger::{
|
pub use ledger::{
|
||||||
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
|
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
|
||||||
REVENUE, ReconciliationReport, UnclearedLine,
|
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
|
||||||
};
|
};
|
||||||
pub use money::{Currency, Money};
|
pub use money::{Currency, Money};
|
||||||
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -132,6 +132,28 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
|||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.map(Route::Account),
|
.map(Route::Account),
|
||||||
),
|
),
|
||||||
|
"role.assigned" => {
|
||||||
|
let role = s(p, "role");
|
||||||
|
let control = ledger_core::control_account(role);
|
||||||
|
let purpose = control.map_or(role, |c| c.role);
|
||||||
|
let mut chars = purpose.chars();
|
||||||
|
let purpose: String = chars
|
||||||
|
.next()
|
||||||
|
.map(|f| f.to_uppercase().chain(chars).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let account = p.get("account_id").and_then(|v| v.as_u64());
|
||||||
|
ev(
|
||||||
|
icon::TAG,
|
||||||
|
match account {
|
||||||
|
Some(id) => format!("{purpose} now posts to {}", b.account_label(id)),
|
||||||
|
None => format!(
|
||||||
|
"{purpose} back to its default code {}",
|
||||||
|
control.map_or("", |c| c.code)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
account.map(Route::Account),
|
||||||
|
)
|
||||||
|
}
|
||||||
"account.tax_set" => ev(
|
"account.tax_set" => ev(
|
||||||
icon::TAG,
|
icon::TAG,
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
//! and optionally `config.json`. If no store exists, one is created with the
|
//! and optionally `config.json`. If no store exists, one is created with the
|
||||||
//! configured base currency. Company settings (name, fiscal year start,
|
//! configured base currency. Company settings (name, fiscal year start,
|
||||||
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
//! 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 policy;
|
||||||
mod server;
|
mod server;
|
||||||
|
mod settings;
|
||||||
mod types;
|
mod types;
|
||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
@@ -94,22 +95,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
};
|
};
|
||||||
// Logs go to stderr: stdout is the MCP channel.
|
// Logs go to stderr: stdout is the MCP channel.
|
||||||
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
||||||
// An explicit --books-toml must exist; the default is optional.
|
// An explicit --books-toml must exist; the default (beside the data
|
||||||
let settings = match books_toml {
|
// dir) is optional. Either is reloaded whenever it changes.
|
||||||
Some(path) => company::load(&path)?,
|
let source = settings::Source {
|
||||||
None => match data_dir.parent().map(|p| p.join("wanderbooks.toml")) {
|
required: books_toml.is_some(),
|
||||||
Some(path) if path.exists() => company::load(&path)?,
|
books_toml: books_toml.unwrap_or_else(|| {
|
||||||
_ => company::BooksSettings::default(),
|
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 live = settings::Live::watching(source)?;
|
||||||
let policy = company::effective_receipt_policy(&config_path, &settings)?;
|
eprintln!("ledger-mcp: company {:?}", live.current().company.name);
|
||||||
let roles = server::CashRoles {
|
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
|
||||||
cash: settings.cash_accounts,
|
|
||||||
financing: settings.financing_accounts,
|
|
||||||
investing: settings.investing_accounts,
|
|
||||||
};
|
|
||||||
let company = settings.company;
|
|
||||||
|
|
||||||
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||||
open_waiting_for_lock(&data_dir)?
|
open_waiting_for_lock(&data_dir)?
|
||||||
@@ -134,14 +134,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.build()?;
|
.build()?;
|
||||||
if let Some(addr) = http_addr {
|
if let Some(addr) = http_addr {
|
||||||
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
eprintln!("ledger-mcp: store verified, serving over stdio");
|
||||||
let server = BooksServer::new(store, policy)
|
let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live);
|
||||||
.with_company(company)
|
|
||||||
.with_cash_roles(roles);
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
|
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
|
||||||
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
|
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(
|
async fn serve_http(
|
||||||
addr: String,
|
addr: String,
|
||||||
store: Store,
|
store: Store,
|
||||||
policy: ReceiptPolicy,
|
live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
|
||||||
company: company::Company,
|
|
||||||
roles: server::CashRoles,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use rmcp::transport::streamable_http_server::{
|
use rmcp::transport::streamable_http_server::{
|
||||||
StreamableHttpService, session::local::LocalSessionManager,
|
StreamableHttpService, session::local::LocalSessionManager,
|
||||||
@@ -168,9 +164,8 @@ async fn serve_http(
|
|||||||
let store = Arc::new(std::sync::Mutex::new(store));
|
let store = Arc::new(std::sync::Mutex::new(store));
|
||||||
let service = StreamableHttpService::new(
|
let service = StreamableHttpService::new(
|
||||||
move || {
|
move || {
|
||||||
let server = server::BooksServer::new_shared(store.clone(), policy.clone())
|
let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
|
||||||
.with_company(company.clone())
|
.with_live_settings(live.clone());
|
||||||
.with_cash_roles(roles.clone());
|
|
||||||
Ok(server)
|
Ok(server)
|
||||||
},
|
},
|
||||||
Arc::new(LocalSessionManager::default()),
|
Arc::new(LocalSessionManager::default()),
|
||||||
|
|||||||
+283
-78
@@ -31,8 +31,10 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::policy::ReceiptPolicy;
|
use crate::policy::ReceiptPolicy;
|
||||||
|
use crate::settings::{Live, Settings};
|
||||||
use crate::types::*;
|
use crate::types::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
#[cfg(test)]
|
||||||
use ledger_config::Company;
|
use ledger_config::Company;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
||||||
@@ -52,7 +54,7 @@ use rust_decimal::Decimal;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// The agent operating contract, delivered via initialize.
|
/// 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.
|
/// Max receipt/document upload: 25 MB.
|
||||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||||
@@ -63,40 +65,35 @@ const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct BooksServer {
|
pub struct BooksServer {
|
||||||
store: Arc<Mutex<Store>>,
|
store: Arc<Mutex<Store>>,
|
||||||
policy: ReceiptPolicy,
|
settings: Arc<Mutex<Live>>,
|
||||||
company: Company,
|
|
||||||
roles: CashRoles,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[books]` account roles for the cash flow statement, used when a call
|
#[cfg(test)]
|
||||||
/// doesn't pass its own: cash, financing, and investing account codes.
|
pub use crate::settings::CashRoles;
|
||||||
#[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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BooksServer {
|
impl BooksServer {
|
||||||
/// The wanderbooks.toml settings that name account `code`.
|
/// The wanderbooks.toml settings that name account `code`.
|
||||||
fn config_mentions(&self, code: &str) -> Vec<String> {
|
fn config_mentions(&self, code: &str) -> Vec<String> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (key, codes) in [
|
for (key, codes) in [
|
||||||
("[books] cash_accounts", &self.roles.cash),
|
("[books] cash_accounts", &self.settings().roles.cash),
|
||||||
("[books] financing_accounts", &self.roles.financing),
|
(
|
||||||
("[books] investing_accounts", &self.roles.investing),
|
"[books] financing_accounts",
|
||||||
|
&self.settings().roles.financing,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"[books] investing_accounts",
|
||||||
|
&self.settings().roles.investing,
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
if codes.iter().any(|c| c == code) {
|
if codes.iter().any(|c| c == code) {
|
||||||
out.push(key.to_string());
|
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());
|
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 {
|
if m.capital_account == code {
|
||||||
out.push(format!("[[company.members]] {} capital_account", m.name));
|
out.push(format!("[[company.members]] {} capital_account", m.name));
|
||||||
}
|
}
|
||||||
@@ -112,12 +109,7 @@ impl BooksServer {
|
|||||||
|
|
||||||
/// Build the server around an open store.
|
/// Build the server around an open store.
|
||||||
pub fn new(store: Store, policy: ReceiptPolicy) -> Self {
|
pub fn new(store: Store, policy: ReceiptPolicy) -> Self {
|
||||||
Self {
|
Self::new_shared(Arc::new(Mutex::new(store)), policy)
|
||||||
store: Arc::new(Mutex::new(store)),
|
|
||||||
policy,
|
|
||||||
company: Company::default(),
|
|
||||||
roles: CashRoles::default(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the server around a shared store (for HTTP: each connection
|
/// 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 {
|
pub fn new_shared(store: Arc<Mutex<Store>>, policy: ReceiptPolicy) -> Self {
|
||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
|
settings: Arc::new(Mutex::new(Live::fixed(Settings {
|
||||||
policy,
|
policy,
|
||||||
company: Company::default(),
|
..Settings::default()
|
||||||
roles: CashRoles::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).
|
/// Set the company (name for report headers, fiscal year).
|
||||||
pub fn with_company(mut self, company: Company) -> Self {
|
#[cfg(test)]
|
||||||
self.company = company;
|
pub fn with_company(self, company: Company) -> Self {
|
||||||
|
self.live().edit(|s| s.company = company);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the `[books]` cash flow account roles.
|
/// Set the `[books]` cash flow account roles.
|
||||||
pub fn with_cash_roles(mut self, roles: CashRoles) -> Self {
|
#[cfg(test)]
|
||||||
self.roles = roles;
|
pub fn with_cash_roles(self, roles: CashRoles) -> Self {
|
||||||
|
self.live().edit(|s| s.roles = roles);
|
||||||
self
|
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 cash flow statement from account codes, each list defaulting to
|
||||||
/// the `[books]` roles when the call passes none.
|
/// the `[books]` roles when the call passes none.
|
||||||
fn cash_flow_for(
|
fn cash_flow_for(
|
||||||
@@ -165,17 +179,17 @@ impl BooksServer {
|
|||||||
ledger,
|
ledger,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
&or(cash, &self.roles.cash),
|
&or(cash, &self.settings().roles.cash),
|
||||||
&or(financing, &self.roles.financing),
|
&or(financing, &self.settings().roles.financing),
|
||||||
&or(investing, &self.roles.investing),
|
&or(investing, &self.settings().roles.investing),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Header and provenance for a rendered report.
|
/// Header and provenance for a rendered report.
|
||||||
fn report_meta(&self, store: &Store) -> ReportMeta {
|
fn report_meta(&self, store: &Store) -> ReportMeta {
|
||||||
ReportMeta {
|
ReportMeta {
|
||||||
company: self.company.name.clone(),
|
company: self.settings().company.name.clone(),
|
||||||
basis: self.company.basis.clone(),
|
basis: self.settings().company.basis.clone(),
|
||||||
prepared_on: today(),
|
prepared_on: today(),
|
||||||
journal_seq: store.journal_seq(),
|
journal_seq: store.journal_seq(),
|
||||||
}
|
}
|
||||||
@@ -183,7 +197,8 @@ impl BooksServer {
|
|||||||
|
|
||||||
/// Members as reports name them (empty unless a multi-member LLC).
|
/// Members as reports name them (empty unless a multi-member LLC).
|
||||||
fn report_members(&self) -> Vec<ledger_import::Member> {
|
fn report_members(&self) -> Vec<ledger_import::Member> {
|
||||||
self.company
|
self.settings()
|
||||||
|
.company
|
||||||
.members
|
.members
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| ledger_import::Member {
|
.map(|m| ledger_import::Member {
|
||||||
@@ -197,13 +212,14 @@ impl BooksServer {
|
|||||||
/// Members with their capital accounts resolved in the chart, for the
|
/// Members with their capital accounts resolved in the chart, for the
|
||||||
/// statement of members' equity.
|
/// statement of members' equity.
|
||||||
fn member_capitals(&self, ledger: &Ledger) -> Result<Vec<ledger_core::MemberCapital>, String> {
|
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(
|
return Err(
|
||||||
"no members configured: add [[company.members]] (name, share, capital_account) to wanderbooks.toml"
|
"no members configured: add [[company.members]] (name, share, capital_account) to wanderbooks.toml"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.company
|
self.settings()
|
||||||
|
.company
|
||||||
.members
|
.members
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
@@ -250,7 +266,7 @@ impl BooksServer {
|
|||||||
.map(|a| a.id)
|
.map(|a| a.id)
|
||||||
.ok_or_else(|| format!("wanderbooks.toml names unknown account {c}"))
|
.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(
|
return Ok(ledger_core::CloseTo::Members(
|
||||||
self.member_capitals(ledger)?
|
self.member_capitals(ledger)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -263,12 +279,13 @@ impl BooksServer {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let sweep = self
|
let sweep = self
|
||||||
|
.settings()
|
||||||
.company
|
.company
|
||||||
.close_sweep
|
.close_sweep
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| code(c))
|
.map(|c| code(c))
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.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) => {
|
None | Some(ledger_core::RETAINED_EARNINGS_CODE) => {
|
||||||
ledger_core::CloseTo::RetainedEarnings { sweep }
|
ledger_core::CloseTo::RetainedEarnings { sweep }
|
||||||
}
|
}
|
||||||
@@ -288,7 +305,7 @@ impl BooksServer {
|
|||||||
ledger: &Ledger,
|
ledger: &Ledger,
|
||||||
as_of: NaiveDate,
|
as_of: NaiveDate,
|
||||||
) -> Result<ledger_import::YearEarnings, String> {
|
) -> 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)
|
let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.net_income();
|
.net_income();
|
||||||
@@ -339,8 +356,16 @@ impl BooksServer {
|
|||||||
if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() {
|
if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let split_group = self.policy.split_group(store.ledger(), entry_id, entry);
|
let split_group = self
|
||||||
if !self.policy.receipt_required(store.ledger(), entry) && split_group.is_empty() {
|
.settings()
|
||||||
|
.policy
|
||||||
|
.split_group(store.ledger(), entry_id, entry);
|
||||||
|
if !self
|
||||||
|
.settings()
|
||||||
|
.policy
|
||||||
|
.receipt_required(store.ledger(), entry)
|
||||||
|
&& split_group.is_empty()
|
||||||
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let total = ReceiptPolicy::entry_debits(entry);
|
let total = ReceiptPolicy::entry_debits(entry);
|
||||||
@@ -349,12 +374,12 @@ impl BooksServer {
|
|||||||
message: format!(
|
message: format!(
|
||||||
"entry {entry_id} moves {} in debits ({}); unlinking its last receipt would leave it without one.",
|
"entry {entry_id} moves {} in debits ({}); unlinking its last receipt would leave it without one.",
|
||||||
money(total),
|
money(total),
|
||||||
self.policy.describe()
|
self.settings().policy.describe()
|
||||||
),
|
),
|
||||||
entry_id: Some(entry_id),
|
entry_id: Some(entry_id),
|
||||||
asset_id: None,
|
asset_id: None,
|
||||||
debits: total.to_string(),
|
debits: total.to_string(),
|
||||||
policy: self.policy.describe(),
|
policy: self.settings().policy.describe(),
|
||||||
remedy_tool: "books.attach_receipt".to_string(),
|
remedy_tool: "books.attach_receipt".to_string(),
|
||||||
remedy_hint: format!(
|
remedy_hint: format!(
|
||||||
"attach the replacement receipt to entry {entry_id} with books.attach_receipt first, then retry books.unlink_receipt"
|
"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".
|
/// Parse a calendar-month period "YYYY-MM".
|
||||||
fn parse_period_mcp(period: &str) -> Result<(i32, u32), String> {
|
fn parse_period_mcp(period: &str) -> Result<(i32, u32), String> {
|
||||||
let err = || format!("bad period {period:?}: expected YYYY-MM");
|
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)?;
|
let receipts = preview_receipts(&store, p.entry_id)?;
|
||||||
// Anti-splitting: if this entry looks like part of a split group,
|
// Anti-splitting: if this entry looks like part of a split group,
|
||||||
// the receipt policy applies to the group total.
|
// 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() {
|
if !split_group.is_empty() {
|
||||||
receipt_required = true;
|
receipt_required = true;
|
||||||
}
|
}
|
||||||
@@ -1105,13 +1208,13 @@ impl BooksServer {
|
|||||||
"entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}",
|
"entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}",
|
||||||
p.entry_id,
|
p.entry_id,
|
||||||
money(total),
|
money(total),
|
||||||
self.policy.describe(),
|
self.settings().policy.describe(),
|
||||||
split_note
|
split_note
|
||||||
),
|
),
|
||||||
entry_id: Some(p.entry_id),
|
entry_id: Some(p.entry_id),
|
||||||
asset_id: None,
|
asset_id: None,
|
||||||
debits: total.to_string(),
|
debits: total.to_string(),
|
||||||
policy: self.policy.describe(),
|
policy: self.settings().policy.describe(),
|
||||||
remedy_tool: "books.attach_receipt".to_string(),
|
remedy_tool: "books.attach_receipt".to_string(),
|
||||||
remedy_hint: format!(
|
remedy_hint: format!(
|
||||||
"attach a receipt to entry {} with books.attach_receipt, then retry books.post_entry with confirmed=true",
|
"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
|
let receipts = store
|
||||||
.list_attachments(Some(e.id()))
|
.list_attachments(Some(e.id()))
|
||||||
.map_err(|e| e.to_string())?;
|
.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 {
|
entries.push(DigestEntry {
|
||||||
entry_id: e.id(),
|
entry_id: e.id(),
|
||||||
date: e.date().to_string(),
|
date: e.date().to_string(),
|
||||||
@@ -1761,6 +1864,16 @@ impl BooksServer {
|
|||||||
totals_by_account: totals,
|
totals_by_account: totals,
|
||||||
stale_rules,
|
stale_rules,
|
||||||
journal_repairs,
|
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)
|
annotations(read_only_hint = true)
|
||||||
)]
|
)]
|
||||||
async fn tax_categories(&self) -> Result<Json<TaxCategoriesOut>, String> {
|
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
|
let categories = ledger_core::tax::CATEGORIES
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|c| form.is_none_or(|f| c.forms.contains(&f)))
|
.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(
|
#[tool(
|
||||||
name = "books.set_tax_category",
|
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."
|
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)
|
.get(id)
|
||||||
.expect("resolved")
|
.expect("resolved")
|
||||||
.account_type;
|
.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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
store
|
store
|
||||||
@@ -2470,7 +2657,11 @@ impl BooksServer {
|
|||||||
.add_account(&row.code, &row.name, account_type, parent, false)
|
.add_account(&row.code, &row.name, account_type, parent, false)
|
||||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||||
if let Some(cat) = &row.tax_category {
|
if let Some(cat) = &row.tax_category {
|
||||||
ledger_core::tax::check_category(cat, account_type, self.company.tax_form)
|
ledger_core::tax::check_category(
|
||||||
|
cat,
|
||||||
|
account_type,
|
||||||
|
self.settings().company.tax_form,
|
||||||
|
)
|
||||||
.map_err(|e| format!("{}: {e}", row.code))?;
|
.map_err(|e| format!("{}: {e}", row.code))?;
|
||||||
scratch
|
scratch
|
||||||
.set_account_tax(id, Some(cat), None)
|
.set_account_tax(id, Some(cat), None)
|
||||||
@@ -2677,7 +2868,7 @@ impl BooksServer {
|
|||||||
"tax_summary" => {
|
"tax_summary" => {
|
||||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
||||||
report::tax_summary(
|
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())?,
|
.map_err(|e| e.to_string())?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2745,12 +2936,14 @@ impl BooksServer {
|
|||||||
.registered_assets()
|
.registered_assets()
|
||||||
.flat_map(|a| [a.cost_account, a.accum_depr_account])
|
.flat_map(|a| [a.cost_account, a.accum_depr_account])
|
||||||
.collect();
|
.collect();
|
||||||
|
let receivables = ledger.role_account(&ledger_core::RECEIVABLES);
|
||||||
|
let payables = ledger.role_account(&ledger_core::PAYABLES);
|
||||||
let accounts = iif_accounts(ledger, |a| {
|
let accounts = iif_accounts(ledger, |a| {
|
||||||
if self.roles.cash.contains(&a.code) {
|
if self.settings().roles.cash.contains(&a.code) {
|
||||||
Qb::Bank
|
Qb::Bank
|
||||||
} else if a.code == ledger_core::RECEIVABLES.code {
|
} else if Some(a.id) == receivables {
|
||||||
Qb::AccountsReceivable
|
Qb::AccountsReceivable
|
||||||
} else if a.code == ledger_core::PAYABLES.code {
|
} else if Some(a.id) == payables {
|
||||||
Qb::AccountsPayable
|
Qb::AccountsPayable
|
||||||
} else if fixed.contains(&a.id) {
|
} else if fixed.contains(&a.id) {
|
||||||
Qb::FixedAsset
|
Qb::FixedAsset
|
||||||
@@ -3003,7 +3196,10 @@ impl BooksServer {
|
|||||||
} else {
|
} else {
|
||||||
Decimal::ZERO
|
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 {
|
let out = DisposeAssetOut {
|
||||||
status: "preview".to_string(),
|
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",
|
"disposing {} moves {} in debits ({}); file the bill of sale with books.file_document, then retry with receipt_attachment_ids",
|
||||||
asset.description,
|
asset.description,
|
||||||
money(debits),
|
money(debits),
|
||||||
self.policy.describe()
|
self.settings().policy.describe()
|
||||||
),
|
),
|
||||||
entry_id: None,
|
entry_id: None,
|
||||||
asset_id: Some(p.asset_id),
|
asset_id: Some(p.asset_id),
|
||||||
debits: debits.to_string(),
|
debits: debits.to_string(),
|
||||||
policy: self.policy.describe(),
|
policy: self.settings().policy.describe(),
|
||||||
remedy_tool: "books.file_document".to_string(),
|
remedy_tool: "books.file_document".to_string(),
|
||||||
remedy_hint:
|
remedy_hint:
|
||||||
"file the bill of sale with books.file_document, then retry books.dispose_asset with confirmed=true and receipt_attachment_ids=[<id>]"
|
"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 mut store = self.lock()?;
|
||||||
let cash = resolve_account(&store, &p.cash_account)?;
|
let cash = resolve_account(&store, &p.cash_account)?;
|
||||||
// Total before remitting, for the response.
|
// Total before remitting, for the response.
|
||||||
let mut total = Decimal::ZERO;
|
let total: Decimal = store
|
||||||
for code in ["2110", "2120", "2130", "2140", "2150"] {
|
|
||||||
if let Some(a) = store.ledger().chart().find_by_code(code) {
|
|
||||||
total += store
|
|
||||||
.ledger()
|
.ledger()
|
||||||
.account_balance(a.id)
|
.payroll_tax_payables()
|
||||||
.map_err(|e| e.to_string())?;
|
.iter()
|
||||||
}
|
.map(|(_, b)| *b)
|
||||||
}
|
.sum();
|
||||||
let entry_id = store
|
let entry_id = store
|
||||||
.remit_payroll_taxes(parse_date(&p.date)?, cash)
|
.remit_payroll_taxes(parse_date(&p.date)?, cash)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -3755,7 +3948,7 @@ impl BooksServer {
|
|||||||
let ledger = store.ledger();
|
let ledger = store.ledger();
|
||||||
|
|
||||||
// Fiscal year to date, per wanderbooks.toml's fiscal_year_start.
|
// 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 (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 xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string());
|
||||||
let pdf =
|
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)?)
|
let me = ledger_core::members_equity(ledger, fy, as_of, &self.member_capitals(ledger)?)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
statements.push(("members-equity", report::members_equity(&me)));
|
statements.push(("members-equity", report::members_equity(&me)));
|
||||||
@@ -3805,7 +3998,7 @@ impl BooksServer {
|
|||||||
statements.push((
|
statements.push((
|
||||||
"tax-summary",
|
"tax-summary",
|
||||||
report::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())?,
|
.map_err(|e| e.to_string())?,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -3922,9 +4115,9 @@ impl BooksServer {
|
|||||||
));
|
));
|
||||||
let readme = format!(
|
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} — 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,
|
name = self.settings().company.name,
|
||||||
basis = self.company.basis,
|
basis = self.settings().company.basis,
|
||||||
members = if self.company.members.is_empty() {
|
members = if self.settings().company.members.is_empty() {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
"members' equity, "
|
"members' equity, "
|
||||||
@@ -4116,7 +4309,7 @@ impl BooksServer {
|
|||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let ledger = store.ledger();
|
||||||
let (start, end) = statement_range(ledger, p.start.as_deref(), p.end.as_deref())?;
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let line = |l: &ledger_core::TaxSummaryLine| {
|
let line = |l: &ledger_core::TaxSummaryLine| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -4443,6 +4636,17 @@ impl BooksServer {
|
|||||||
|
|
||||||
#[tool_handler]
|
#[tool_handler]
|
||||||
impl ServerHandler for BooksServer {
|
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 {
|
fn get_info(&self) -> ServerConfig {
|
||||||
ServerConfig::new(
|
ServerConfig::new(
|
||||||
ServerCapabilities::builder()
|
ServerCapabilities::builder()
|
||||||
@@ -4453,7 +4657,7 @@ impl ServerHandler for BooksServer {
|
|||||||
// Tell the agent the policy these books actually use.
|
// Tell the agent the policy these books actually use.
|
||||||
.with_instructions(INSTRUCTIONS.replace(
|
.with_instructions(INSTRUCTIONS.replace(
|
||||||
"any entry moving more than $25.00 in debits needs at least one receipt",
|
"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(
|
.with_server_info(Implementation::new(
|
||||||
"wanderbooks",
|
"wanderbooks",
|
||||||
@@ -4513,6 +4717,7 @@ impl ServerHandler for BooksServer {
|
|||||||
request: ReadResourceRequestParams,
|
request: ReadResourceRequestParams,
|
||||||
_context: RequestContext<RoleServer>,
|
_context: RequestContext<RoleServer>,
|
||||||
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
|
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
|
||||||
|
self.live().refresh();
|
||||||
let uri = request.uri.clone();
|
let uri = request.uri.clone();
|
||||||
let text = self.resource_text(&uri).await.map_err(|e| {
|
let text = self.resource_text(&uri).await.map_err(|e| {
|
||||||
rmcp::ErrorData::resource_not_found(uri.clone(), Some(serde_json::json!({"reason": 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.
|
/// The code it has now.
|
||||||
pub code: String,
|
pub code: String,
|
||||||
/// Settings in wanderbooks.toml that still name the old code: change
|
/// 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>,
|
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`.
|
/// Parameters for `books.set_tax_category`.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct SetTaxCategoryParams {
|
pub struct SetTaxCategoryParams {
|
||||||
@@ -1307,6 +1403,10 @@ pub struct DigestOut {
|
|||||||
/// lost; tell the human, and if they had just asked for a change,
|
/// lost; tell the human, and if they had just asked for a change,
|
||||||
/// check it is there and redo it if not.
|
/// check it is there and redo it if not.
|
||||||
pub journal_repairs: Vec<JournalRepair>,
|
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.
|
/// A torn journal tail set aside on open.
|
||||||
|
|||||||
@@ -144,10 +144,10 @@ fn mcp_end_to_end_over_stdio() {
|
|||||||
assert!(instructions.contains("decimal strings"));
|
assert!(instructions.contains("decimal strings"));
|
||||||
mcp.notify("notifications/initialized");
|
mcp.notify("notifications/initialized");
|
||||||
|
|
||||||
// Tool listing: 23 narrow tools, chart is read-only.
|
// Tool listing: the narrow tools; chart is read-only.
|
||||||
let list = mcp.request("tools/list", json!({}));
|
let list = mcp.request("tools/list", json!({}));
|
||||||
let tools = list["result"]["tools"].as_array().unwrap();
|
let tools = list["result"]["tools"].as_array().unwrap();
|
||||||
assert_eq!(tools.len(), 67, "expected 67 tools, got {}", tools.len());
|
assert_eq!(tools.len(), 70, "expected 70 tools, got {}", tools.len());
|
||||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -609,6 +609,16 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
"books.renumber_account",
|
"books.renumber_account",
|
||||||
json!({"account": "7000", "code": "7100"}),
|
json!({"account": "7000", "code": "7100"}),
|
||||||
);
|
);
|
||||||
|
m.ok("books.settings", json!({}));
|
||||||
|
m.ok("books.roles", json!({}));
|
||||||
|
m.ok(
|
||||||
|
"books.assign_role",
|
||||||
|
json!({"role": "general_expense", "account": "7100"}),
|
||||||
|
);
|
||||||
|
m.ok(
|
||||||
|
"books.assign_role",
|
||||||
|
json!({"role": "general_expense", "account": null}),
|
||||||
|
);
|
||||||
let deduction = cats["categories"]
|
let deduction = cats["categories"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1982,3 +1992,202 @@ fn helpers_self_check() {
|
|||||||
);
|
);
|
||||||
assert_eq!(bad.len(), 2, "{bad:?}");
|
assert_eq!(bad.len(), 2, "{bad:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------- settings reload (settings.rs spec)
|
||||||
|
|
||||||
|
/// INVARIANT 1: a call after an edit sees it, with no restart, and two
|
||||||
|
/// same-size edits in a row are both seen (compared by content).
|
||||||
|
#[test]
|
||||||
|
fn settings_reload_on_the_next_call() {
|
||||||
|
let dir = test_dir("reload");
|
||||||
|
let toml = books_toml(&dir, "25.00", "");
|
||||||
|
let mut m = Mcp::start(&dir, &toml);
|
||||||
|
let s = m.ok("books.settings", json!({}));
|
||||||
|
assert_eq!(s["company"], "Spec Co");
|
||||||
|
assert_eq!(s["loads"], 1);
|
||||||
|
for name in ["Aaaa Co", "Bbbb Co"] {
|
||||||
|
let text = std::fs::read_to_string(&toml)
|
||||||
|
.unwrap()
|
||||||
|
.replace("Spec Co", name)
|
||||||
|
.replace("Aaaa Co", name);
|
||||||
|
std::fs::write(&toml, text).unwrap();
|
||||||
|
let s = m.ok("books.settings", json!({}));
|
||||||
|
assert_eq!(s["company"], name);
|
||||||
|
assert_eq!(s["reload_error"], Value::Null);
|
||||||
|
}
|
||||||
|
assert_eq!(m.ok("books.settings", json!({}))["loads"], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// INVARIANT 1, enforced: a lowered receipt threshold refuses the next
|
||||||
|
/// post without a receipt.
|
||||||
|
#[test]
|
||||||
|
fn a_reloaded_threshold_is_enforced() {
|
||||||
|
let dir = test_dir("reload-threshold");
|
||||||
|
let toml = books_toml(&dir, "1000.00", "");
|
||||||
|
let mut m = Mcp::start(&dir, &toml);
|
||||||
|
m.accounts(BASE_CHART);
|
||||||
|
let first = m.draft(
|
||||||
|
"2026-03-01",
|
||||||
|
"small",
|
||||||
|
&[("6000", "debit", "1.00"), ("1000", "credit", "1.00")],
|
||||||
|
);
|
||||||
|
m.post(first).ok("under the threshold");
|
||||||
|
let text = std::fs::read_to_string(&toml)
|
||||||
|
.unwrap()
|
||||||
|
.replace("\"1000.00\"", "\"0.00\"");
|
||||||
|
std::fs::write(&toml, text).unwrap();
|
||||||
|
let second = m.draft(
|
||||||
|
"2026-03-02",
|
||||||
|
"small",
|
||||||
|
&[("6000", "debit", "1.00"), ("1000", "credit", "1.00")],
|
||||||
|
);
|
||||||
|
let out = m.post(second);
|
||||||
|
assert!(out.err_text().contains("receipt_required"), "{out:?}");
|
||||||
|
assert_eq!(m.entry_status(second), "draft");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// INVARIANTS 2 and 3: a broken edit, and a named file gone missing, keep
|
||||||
|
/// the last good settings and say why; fixing it loads again.
|
||||||
|
#[test]
|
||||||
|
fn a_broken_edit_keeps_the_last_good_settings() {
|
||||||
|
let dir = test_dir("reload-broken");
|
||||||
|
let toml = books_toml(&dir, "25.00", "");
|
||||||
|
let good = std::fs::read_to_string(&toml).unwrap();
|
||||||
|
let mut m = Mcp::start(&dir, &toml);
|
||||||
|
std::fs::write(
|
||||||
|
&toml,
|
||||||
|
good.replace("Spec Co", "Next Co")
|
||||||
|
.replace("[books]", "[books"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let s = m.ok("books.settings", json!({}));
|
||||||
|
assert_eq!(s["company"], "Spec Co");
|
||||||
|
assert!(s["reload_error"].is_string(), "{s}");
|
||||||
|
let digest = m.ok("books.digest", json!({"since": "2026-01-01"}));
|
||||||
|
assert!(
|
||||||
|
digest["settings_problems"][0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("didn't load"),
|
||||||
|
"{digest}"
|
||||||
|
);
|
||||||
|
std::fs::remove_file(&toml).unwrap();
|
||||||
|
let s = m.ok("books.settings", json!({}));
|
||||||
|
assert!(
|
||||||
|
s["reload_error"].is_string(),
|
||||||
|
"a named file gone missing: {s}"
|
||||||
|
);
|
||||||
|
assert_eq!(s["books_toml_exists"], false);
|
||||||
|
assert_eq!(s["company"], "Spec Co");
|
||||||
|
std::fs::write(&toml, good.replace("Spec Co", "Fixed Co")).unwrap();
|
||||||
|
let s = m.ok("books.settings", json!({}));
|
||||||
|
assert_eq!(s["company"], "Fixed Co");
|
||||||
|
assert_eq!(s["reload_error"], Value::Null);
|
||||||
|
assert_eq!(s["loads"], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// books.settings and the digest list settings that disagree with the
|
||||||
|
/// chart, each naming the setting.
|
||||||
|
#[test]
|
||||||
|
fn settings_that_disagree_with_the_chart_are_problems() {
|
||||||
|
let dir = test_dir("problems");
|
||||||
|
let toml = books_toml(
|
||||||
|
&dir,
|
||||||
|
"25.00",
|
||||||
|
"financing_accounts = [\"6000\"]\nclose_into = \"3000\"\n",
|
||||||
|
);
|
||||||
|
let mut m = Mcp::start(&dir, &toml);
|
||||||
|
m.accounts(BASE_CHART);
|
||||||
|
let s = m.ok("books.settings", json!({}));
|
||||||
|
let problems: Vec<String> = s["problems"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||||
|
assert!(problems[0].contains("[books] financing_accounts names 6000"));
|
||||||
|
let text = std::fs::read_to_string(&toml)
|
||||||
|
.unwrap()
|
||||||
|
.replace("cash_accounts = [\"1000\"]", "cash_accounts = [\"1010\"]")
|
||||||
|
.replace("close_into = \"3000\"", "close_into = \"4000\"");
|
||||||
|
std::fs::write(&toml, text).unwrap();
|
||||||
|
let digest = m.ok("books.digest", json!({"since": "2026-01-01"}));
|
||||||
|
let problems = digest["settings_problems"].to_string();
|
||||||
|
assert!(
|
||||||
|
problems.contains("[books] cash_accounts names 1010, which isn't in the chart"),
|
||||||
|
"{problems}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
problems.contains("[books] close_into names 4000"),
|
||||||
|
"{problems}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
problems.contains("[books] financing_accounts names 6000"),
|
||||||
|
"{problems}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// books.assign_role steers what an invoice posts, and books.roles shows
|
||||||
|
/// it; a refusal names the reason.
|
||||||
|
#[test]
|
||||||
|
fn assigned_roles_steer_invoices() {
|
||||||
|
let dir = test_dir("roles");
|
||||||
|
let toml = books_toml(&dir, "1000000.00", "");
|
||||||
|
let mut m = Mcp::start(&dir, &toml);
|
||||||
|
m.accounts(&[
|
||||||
|
("1000", "Bank", "asset"),
|
||||||
|
("1110", "Trade Receivables", "asset"),
|
||||||
|
("4100", "Consulting Income", "revenue"),
|
||||||
|
("6000", "Rent", "expense"),
|
||||||
|
]);
|
||||||
|
let roles = m.ok("books.roles", json!({}));
|
||||||
|
let receivables = roles["roles"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|r| r["role"] == "receivables")
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
assert_eq!(receivables["default_code"], "1200");
|
||||||
|
assert_eq!(receivables["assigned"], false);
|
||||||
|
assert_eq!(receivables["account_code"], Value::Null);
|
||||||
|
let err = m.refuse(
|
||||||
|
"books.assign_role",
|
||||||
|
json!({"role": "receivables", "account": "6000"}),
|
||||||
|
);
|
||||||
|
assert!(err.contains("accounts receivable"), "{err}");
|
||||||
|
let err = m.refuse(
|
||||||
|
"books.assign_role",
|
||||||
|
json!({"role": "receivable", "account": "1110"}),
|
||||||
|
);
|
||||||
|
assert!(err.contains("no role"), "{err}");
|
||||||
|
m.ok(
|
||||||
|
"books.assign_role",
|
||||||
|
json!({"role": "receivables", "account": "1110"}),
|
||||||
|
);
|
||||||
|
let roles = m.ok(
|
||||||
|
"books.assign_role",
|
||||||
|
json!({"role": "revenue", "account": "4100"}),
|
||||||
|
);
|
||||||
|
let revenue = roles["roles"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|r| r["role"] == "revenue")
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
assert_eq!(revenue["assigned"], true);
|
||||||
|
assert_eq!(revenue["account_code"], "4100");
|
||||||
|
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap();
|
||||||
|
m.ok(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||||||
|
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
|
||||||
|
);
|
||||||
|
let tb = m.ok("books.trial_balance", json!({})).to_string();
|
||||||
|
assert!(tb.contains("1110") && tb.contains("4100"), "{tb}");
|
||||||
|
assert!(!tb.contains("\"1200\"") && !tb.contains("\"4000\""), "{tb}");
|
||||||
|
}
|
||||||
@@ -1235,7 +1235,8 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a control account by code, creating it through the journaled
|
/// Fetch a control role's account (the one assigned to it, else by
|
||||||
|
/// code), creating it at its code through the journaled
|
||||||
/// account path when missing. The ledger's internal ensure_account
|
/// account path when missing. The ledger's internal ensure_account
|
||||||
/// then no-ops, which keeps replay deterministic: every account
|
/// then no-ops, which keeps replay deterministic: every account
|
||||||
/// creation is an `account.added` event.
|
/// creation is an `account.added` event.
|
||||||
@@ -1243,8 +1244,8 @@ impl Store {
|
|||||||
&mut self,
|
&mut self,
|
||||||
c: &ledger_core::ControlAccount,
|
c: &ledger_core::ControlAccount,
|
||||||
) -> Result<AccountId, StoreError> {
|
) -> Result<AccountId, StoreError> {
|
||||||
if let Some(a) = self.ledger.chart().find_by_code(c.code) {
|
if let Some(id) = self.ledger.role_account(c) {
|
||||||
return Ok(a.id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
self.add_account(c.code, c.name, c.account_type, None, false)
|
self.add_account(c.code, c.name, c.account_type, None, false)
|
||||||
}
|
}
|
||||||
@@ -2093,6 +2094,25 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Assign a control role an account, or (`None`) return it to its code
|
||||||
|
/// (see `Ledger::assign_role`). Journaled as `role.assigned`, with the
|
||||||
|
/// account that filled the role before.
|
||||||
|
pub fn assign_role(
|
||||||
|
&mut self,
|
||||||
|
role: &str,
|
||||||
|
account: Option<AccountId>,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
let c = self.ledger.check_assign_role(role, account)?;
|
||||||
|
let previous = self.ledger.role_account(c);
|
||||||
|
let line = self.append_journal(
|
||||||
|
"role.assigned",
|
||||||
|
json!({"role": c.key, "account_id": account, "previous_account_id": previous}),
|
||||||
|
)?;
|
||||||
|
self.ledger.assign_role(role, account)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Set (or clear) an account's tax category and deductible percent.
|
/// Set (or clear) an account's tax category and deductible percent.
|
||||||
/// Journaled as `account.tax_set`.
|
/// Journaled as `account.tax_set`.
|
||||||
pub fn set_account_tax(
|
pub fn set_account_tax(
|
||||||
@@ -3285,6 +3305,15 @@ pub(crate) fn replay_line_into_ledger(
|
|||||||
"account.renumbered" => {
|
"account.renumbered" => {
|
||||||
ledger.renumber_account(get_u64(p, "account_id")?, get_str(p, "code")?)?;
|
ledger.renumber_account(get_u64(p, "account_id")?, get_str(p, "code")?)?;
|
||||||
}
|
}
|
||||||
|
"role.assigned" => {
|
||||||
|
let account = match p.get("account_id") {
|
||||||
|
None | Some(Value::Null) => None,
|
||||||
|
Some(v) => Some(v.as_u64().ok_or_else(|| {
|
||||||
|
StoreError::JournalCorrupt("role.assigned account_id".into())
|
||||||
|
})?),
|
||||||
|
};
|
||||||
|
ledger.assign_role(get_str(p, "role")?, account)?;
|
||||||
|
}
|
||||||
"account.tax_set" => {
|
"account.tax_set" => {
|
||||||
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
|
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
|
||||||
Some(s) => Some(s.parse().map_err(|_| {
|
Some(s) => Some(s.parse().map_err(|_| {
|
||||||
@@ -3776,6 +3805,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
|||||||
"payroll.taxes_remitted" => {
|
"payroll.taxes_remitted" => {
|
||||||
// The entry was projected by its own events; this only names it.
|
// The entry was projected by its own events; this only names it.
|
||||||
}
|
}
|
||||||
|
"role.assigned" => {
|
||||||
|
// Roles live in the ledger (rebuilt by replay); the events
|
||||||
|
// mirror below keeps the history.
|
||||||
|
}
|
||||||
"receipt.attached" | "document.filed" => {
|
"receipt.attached" | "document.filed" => {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO blobs(sha256, size_bytes, mime_type, original_name, created_at) VALUES (?,?,?,?,?)",
|
"INSERT OR IGNORE INTO blobs(sha256, size_bytes, mime_type, original_name, created_at) VALUES (?,?,?,?,?)",
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
//! Spec-derived tests for control-role assignment in the store.
|
||||||
|
//!
|
||||||
|
//! Oracles: `docs/spec/rules.md` B-23 ("the assignment is journaled, so
|
||||||
|
//! replay never depends on a setting outside the journal"), the doc comment
|
||||||
|
//! on `Store::assign_role` (journaled as `role.assigned`, with the account
|
||||||
|
//! that filled the role before), and the `Ledger::assign_role` spec (a
|
||||||
|
//! refusal leaves the books unchanged). Expected values are worked by hand.
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::{
|
||||||
|
AccountId, AccountType, CloseTo, Currency, DocumentLine, EntryId, Ledger, LedgerError, Money,
|
||||||
|
RECEIVABLES, Side, control_accounts,
|
||||||
|
};
|
||||||
|
use ledger_store::journal::read_and_verify_journal;
|
||||||
|
use ledger_store::{JOURNAL_FILE, JournalWriter, Store, StoreError};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn test_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("wb-roles-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dec(s: &str) -> Decimal {
|
||||||
|
Decimal::from_str(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line(desc: &str, amount: &str) -> DocumentLine {
|
||||||
|
DocumentLine {
|
||||||
|
description: desc.to_string(),
|
||||||
|
amount: dec(amount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The journal's (type, payload) pairs, verified.
|
||||||
|
fn journal(dir: &Path) -> Vec<(String, Value)> {
|
||||||
|
read_and_verify_journal(&dir.join(JOURNAL_FILE))
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| (l.line_type, l.payload))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roles(l: &Ledger) -> Vec<(&'static str, Option<AccountId>, Option<AccountId>)> {
|
||||||
|
control_accounts()
|
||||||
|
.map(|c| (c.key, l.role_assignment(c), l.role_account(c)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_of(l: &Ledger, id: AccountId) -> String {
|
||||||
|
l.chart().get(id).unwrap().code.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_codes(l: &Ledger, e: EntryId) -> Vec<(String, Side, Decimal)> {
|
||||||
|
let mut v: Vec<_> = l
|
||||||
|
.get_entry(e)
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.map(|x| (code_of(l, x.account), x.side, x.amount.amount))
|
||||||
|
.collect();
|
||||||
|
v.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}")));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every entry and every balance, for "replays identically".
|
||||||
|
fn state(l: &Ledger) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for id in 0..l.next_entry_id() + 2 {
|
||||||
|
if let Some(e) = l.get_entry(id) {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"E{} {} {:?} {:?} {:?}\n",
|
||||||
|
e.id(),
|
||||||
|
e.date(),
|
||||||
|
e.description(),
|
||||||
|
e.status(),
|
||||||
|
e.lines()
|
||||||
|
.iter()
|
||||||
|
.map(|x| (x.account, x.side, x.amount.amount))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut accts: Vec<_> = l
|
||||||
|
.chart()
|
||||||
|
.all()
|
||||||
|
.map(|a| {
|
||||||
|
format!(
|
||||||
|
"{} {} {:?} {}",
|
||||||
|
a.id,
|
||||||
|
a.code,
|
||||||
|
a.account_type,
|
||||||
|
l.account_balance(a.id).unwrap()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
accts.sort();
|
||||||
|
out.push_str(&accts.join("\n"));
|
||||||
|
for p in l.list_periods() {
|
||||||
|
out.push_str(&format!("\n{p:?}"));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("\n{:?}", roles(l)));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: Store::assign_role doc: journaled as `role.assigned` with role,
|
||||||
|
/// account_id, and the account that filled the role before.
|
||||||
|
#[test]
|
||||||
|
fn assign_role_is_journaled_as_role_assigned() {
|
||||||
|
let dir = test_dir("journaled");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let ar = s
|
||||||
|
.add_account("1100", "Trade A/R", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let old = s
|
||||||
|
.add_account(
|
||||||
|
"1200",
|
||||||
|
"Accounts Receivable",
|
||||||
|
AccountType::Asset,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let seq = s.journal_seq();
|
||||||
|
s.assign_role("receivables", Some(ar)).unwrap();
|
||||||
|
assert_eq!(s.journal_seq(), seq + 1);
|
||||||
|
let j = journal(&dir);
|
||||||
|
let (t, p) = j.last().unwrap();
|
||||||
|
assert_eq!(t, "role.assigned");
|
||||||
|
assert_eq!(p["role"], json!("receivables"));
|
||||||
|
assert_eq!(p["account_id"], json!(ar));
|
||||||
|
// Before: the account at 1200 filled it by code.
|
||||||
|
assert_eq!(p["previous_account_id"], json!(old));
|
||||||
|
|
||||||
|
// Back to the code: account_id is null, the previous is 1100.
|
||||||
|
s.assign_role("receivables", None).unwrap();
|
||||||
|
let j = journal(&dir);
|
||||||
|
let (t, p) = j.last().unwrap();
|
||||||
|
assert_eq!(t, "role.assigned");
|
||||||
|
assert_eq!(p["role"], json!("receivables"));
|
||||||
|
assert!(p["account_id"].is_null());
|
||||||
|
assert_eq!(p["previous_account_id"], json!(ar));
|
||||||
|
assert_eq!(s.ledger().role_account(&RECEIVABLES), Some(old));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: a role with no account at its code yet had no previous account.
|
||||||
|
#[test]
|
||||||
|
fn a_first_assignment_with_nothing_at_the_code_has_no_previous() {
|
||||||
|
let dir = test_dir("no-previous");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let sales = s
|
||||||
|
.add_account("4100", "Sales", AccountType::Revenue, None, false)
|
||||||
|
.unwrap();
|
||||||
|
s.assign_role("revenue", Some(sales)).unwrap();
|
||||||
|
let j = journal(&dir);
|
||||||
|
let (t, p) = j.last().unwrap();
|
||||||
|
assert_eq!(t, "role.assigned");
|
||||||
|
assert_eq!(p["account_id"], json!(sales));
|
||||||
|
assert!(p["previous_account_id"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: Ledger::assign_role INVARIANT 5 through the store: a refusal
|
||||||
|
/// journals nothing and changes nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_refused_assignment_journals_nothing() {
|
||||||
|
let dir = test_dir("refused");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let exp = s
|
||||||
|
.add_account("6500", "Misc", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let ph = s
|
||||||
|
.add_account("1150", "A/R group", AccountType::Asset, None, true)
|
||||||
|
.unwrap();
|
||||||
|
let bytes = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
|
||||||
|
let seq = s.journal_seq();
|
||||||
|
let before = state(s.ledger());
|
||||||
|
|
||||||
|
let e = s.assign_role("nope", Some(exp)).unwrap_err();
|
||||||
|
assert!(matches!(e, StoreError::Ledger(LedgerError::UnknownRole(ref k)) if k == "nope"));
|
||||||
|
let e = s.assign_role("receivables", Some(exp)).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
e,
|
||||||
|
StoreError::Ledger(LedgerError::ControlAccountMismatch { .. })
|
||||||
|
));
|
||||||
|
let e = s.assign_role("receivables", Some(ph)).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
e,
|
||||||
|
StoreError::Ledger(LedgerError::ControlAccountMismatch { .. })
|
||||||
|
));
|
||||||
|
let e = s.assign_role("receivables", Some(9999)).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
e,
|
||||||
|
StoreError::Ledger(LedgerError::UnknownAccount(9999))
|
||||||
|
));
|
||||||
|
s.assign_role("general_expense", Some(exp)).unwrap();
|
||||||
|
let bytes_after_ok = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
|
||||||
|
assert!(bytes_after_ok.len() > bytes.len());
|
||||||
|
let seq_ok = s.journal_seq();
|
||||||
|
assert_eq!(seq_ok, seq + 1);
|
||||||
|
let mid = state(s.ledger());
|
||||||
|
assert_ne!(mid, before);
|
||||||
|
let e = s.assign_role("salary_expense", Some(exp)).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
e,
|
||||||
|
StoreError::Ledger(LedgerError::RoleTaken { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(s.journal_seq(), seq_ok);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(dir.join(JOURNAL_FILE)).unwrap(),
|
||||||
|
bytes_after_ok
|
||||||
|
);
|
||||||
|
assert_eq!(state(s.ledger()), mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: B-23 (replay never depends on a setting outside the journal):
|
||||||
|
/// reopening gives the same assignments, and later commands still post to
|
||||||
|
/// the assigned accounts.
|
||||||
|
#[test]
|
||||||
|
fn assignments_replay_on_reopen_and_steer_later_invoices() {
|
||||||
|
let dir = test_dir("replay");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let cash = s
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let ar = s
|
||||||
|
.add_account("1100", "Trade A/R", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let sales = s
|
||||||
|
.add_account("4100", "Sales", AccountType::Revenue, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let misc = s
|
||||||
|
.add_account("6500", "Misc", AccountType::Expense, None, false)
|
||||||
|
.unwrap();
|
||||||
|
s.assign_role("receivables", Some(ar)).unwrap();
|
||||||
|
s.assign_role("revenue", Some(sales)).unwrap();
|
||||||
|
s.assign_role("general_expense", Some(misc)).unwrap();
|
||||||
|
// Assigned then returned: replay must end with it unassigned.
|
||||||
|
s.assign_role("general_expense", None).unwrap();
|
||||||
|
let before = roles(s.ledger());
|
||||||
|
drop(s);
|
||||||
|
|
||||||
|
let mut s = Store::open(&dir).unwrap();
|
||||||
|
assert_eq!(roles(s.ledger()), before);
|
||||||
|
assert_eq!(s.ledger().role_assignment(&RECEIVABLES), Some(ar));
|
||||||
|
|
||||||
|
let cust = s.add_customer("Acme").unwrap();
|
||||||
|
let inv = s
|
||||||
|
.create_invoice(
|
||||||
|
cust,
|
||||||
|
date(2026, 3, 1),
|
||||||
|
date(2026, 3, 31),
|
||||||
|
vec![line("w", "42.00")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let e = s.ledger().get_invoice(inv).unwrap().entry_id;
|
||||||
|
let mut want = vec![
|
||||||
|
("1100".to_string(), Side::Debit, dec("42.00")),
|
||||||
|
("4100".to_string(), Side::Credit, dec("42.00")),
|
||||||
|
];
|
||||||
|
want.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}")));
|
||||||
|
assert_eq!(entry_codes(s.ledger(), e), want);
|
||||||
|
s.receive_payment(inv, date(2026, 3, 9), dec("42.00"), cash)
|
||||||
|
.unwrap();
|
||||||
|
assert!(s.ledger().chart().find_by_code("1200").is_none());
|
||||||
|
assert!(s.ledger().chart().find_by_code("4000").is_none());
|
||||||
|
let after = state(s.ledger());
|
||||||
|
drop(s);
|
||||||
|
|
||||||
|
let s = Store::open(&dir).unwrap();
|
||||||
|
assert_eq!(state(s.ledger()), after);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: B-23 + close_period: a close to retained earnings after the
|
||||||
|
/// role is assigned goes to the assigned account, and replays identically.
|
||||||
|
#[test]
|
||||||
|
fn a_close_to_assigned_retained_earnings_replays_identically() {
|
||||||
|
let dir = test_dir("close");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let cash = s
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let rev = s
|
||||||
|
.add_account("4500", "Fees", AccountType::Revenue, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let re = s
|
||||||
|
.add_account(
|
||||||
|
"3100",
|
||||||
|
"Accumulated Earnings",
|
||||||
|
AccountType::Equity,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.assign_role("retained_earnings", Some(re)).unwrap();
|
||||||
|
let e = s.create_entry(date(2026, 1, 5), "fees").unwrap();
|
||||||
|
s.add_line(
|
||||||
|
e,
|
||||||
|
cash,
|
||||||
|
Side::Debit,
|
||||||
|
Money::new(dec("310.00"), Currency::USD),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.add_line(
|
||||||
|
e,
|
||||||
|
rev,
|
||||||
|
Side::Credit,
|
||||||
|
Money::new(dec("310.00"), Currency::USD),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.post_entry(e).unwrap();
|
||||||
|
let p = s
|
||||||
|
.create_period("2026-01", date(2026, 1, 1), date(2026, 1, 31))
|
||||||
|
.unwrap();
|
||||||
|
let closing = s
|
||||||
|
.close_period_to(p, &CloseTo::RetainedEarnings { sweep: vec![] })
|
||||||
|
.unwrap()
|
||||||
|
.expect("closing entry");
|
||||||
|
let mut want = vec![
|
||||||
|
("4500".to_string(), Side::Debit, dec("310.00")),
|
||||||
|
("3100".to_string(), Side::Credit, dec("310.00")),
|
||||||
|
];
|
||||||
|
want.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}")));
|
||||||
|
assert_eq!(entry_codes(s.ledger(), closing), want);
|
||||||
|
assert!(s.ledger().chart().find_by_code("3999").is_none());
|
||||||
|
let before = state(s.ledger());
|
||||||
|
drop(s);
|
||||||
|
|
||||||
|
let s = Store::open(&dir).unwrap();
|
||||||
|
assert_eq!(state(s.ledger()), before);
|
||||||
|
assert_eq!(s.ledger().account_balance(re).unwrap(), dec("310.00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: a journal is the system of record; a `role.assigned` line whose
|
||||||
|
/// account_id isn't an account id (an integer) is corruption, refused on
|
||||||
|
/// open. The line is appended with `JournalWriter`, so the hash chain is
|
||||||
|
/// valid and only the payload is wrong.
|
||||||
|
#[test]
|
||||||
|
fn a_role_assigned_line_with_a_non_integer_account_is_corrupt() {
|
||||||
|
for bad in [json!("1100"), json!(1.5), json!(-3), json!({"id": 1})] {
|
||||||
|
let dir = test_dir("corrupt");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
s.add_account("1100", "Trade A/R", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
drop(s);
|
||||||
|
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||||
|
w.append(
|
||||||
|
"role.assigned",
|
||||||
|
json!({"role": "receivables", "account_id": bad, "previous_account_id": null}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(w);
|
||||||
|
match Store::open(&dir) {
|
||||||
|
Err(StoreError::JournalCorrupt(_)) => {}
|
||||||
|
Err(other) => panic!("account_id {bad}: expected JournalCorrupt, got {other:?}"),
|
||||||
|
Ok(_) => panic!("account_id {bad}: a corrupt role.assigned line opened"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-3
@@ -43,6 +43,35 @@ advanced, such as a "Due to owners" liability: ASC 230-10-45-14(b)), and
|
|||||||
230-10-20); the tools and the viewer both read them, and a call may pass
|
230-10-20); the tools and the viewer both read them, and a call may pass
|
||||||
its own (docs/spec/rules.md B-14).
|
its own (docs/spec/rules.md B-14).
|
||||||
|
|
||||||
|
The server reloads `wanderbooks.toml` (and `config.json`'s receipt policy)
|
||||||
|
whenever either changes, compared by content at the start of each request,
|
||||||
|
so an edit takes effect on the next call with no restart. A broken edit
|
||||||
|
keeps the last good settings in force and is reported until fixed; the
|
||||||
|
data dir and base currency are fixed at startup (`ledger-mcp/src/settings.rs`).
|
||||||
|
|
||||||
|
- `books.settings {}` → `{ books_toml, books_toml_exists, config_json,
|
||||||
|
reload_error, loads, company, fiscal_year_start, tax_form, basis,
|
||||||
|
receipt_rule, members, cash_accounts, financing_accounts,
|
||||||
|
investing_accounts, close_into, close_sweep, problems }`: the settings in
|
||||||
|
force and where they come from. `problems` lists settings that disagree
|
||||||
|
with the chart (a code not in it, or of the wrong kind), each saying what
|
||||||
|
to change; `books.digest` repeats them, with a failed reload, as
|
||||||
|
`settings_problems`. The agent edits the file when the human gives a
|
||||||
|
company fact, a refusal names a setting, or a problem is listed, then
|
||||||
|
checks `books.settings`.
|
||||||
|
|
||||||
|
The accounts invoices, bills, payroll, and closes post to are not settings:
|
||||||
|
they are control roles in the journal (rules.md B-23).
|
||||||
|
|
||||||
|
- `books.roles {}` → `{ roles: [{ role, purpose, account_type,
|
||||||
|
default_code, assigned, account_code?, account_name? }] }`: every control
|
||||||
|
role and the account filling it.
|
||||||
|
- `books.assign_role { role, account? }` → `{ roles }`: assign a role one
|
||||||
|
of the chart's accounts (`account` null: back to the default code).
|
||||||
|
Refuses the wrong type, a placeholder, an account already filling
|
||||||
|
another role, and moving a balance sheet role while its account holds a
|
||||||
|
balance. Journaled as `role.assigned`.
|
||||||
|
|
||||||
## Transports
|
## Transports
|
||||||
|
|
||||||
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
|
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
|
||||||
@@ -344,9 +373,10 @@ posting.
|
|||||||
- `books.renumber_account { account, code }` → `{ id, previous_code,
|
- `books.renumber_account { account, code }` → `{ id, previous_code,
|
||||||
code, config_mentions }`: give an account a new code. Only the code
|
code, config_mentions }`: give an account a new code. Only the code
|
||||||
changes (entries name accounts by id), so balances and reports don't
|
changes (entries name accounts by id), so balances and reports don't
|
||||||
move. Refuses a code in use, or a control account's code for an account
|
move. Refuses a code in use, or an unassigned control role's default
|
||||||
of the wrong type. `config_mentions` lists the wanderbooks.toml settings
|
code for an account of the wrong type. `config_mentions` lists the
|
||||||
still naming the old code. Journaled as `account.renumbered`.
|
wanderbooks.toml settings still naming the old code (edit them; the
|
||||||
|
server reloads the file). Journaled as `account.renumbered`.
|
||||||
- `books.set_tax_category { account, category?, deductible_percent? }`:
|
- `books.set_tax_category { account, category?, deductible_percent? }`:
|
||||||
map a revenue or expense account to a category (null clears),
|
map a revenue or expense account to a category (null clears),
|
||||||
optionally overriding its deductible percent. Journaled as
|
optionally overriding its deductible percent. Journaled as
|
||||||
|
|||||||
@@ -420,3 +420,29 @@ taxable years beginning on or after January 1, 2016. The 12-month rule:
|
|||||||
§1.263(a)-4(f)(1). The election is made yearly on the return.
|
§1.263(a)-4(f)(1). The election is made yearly on the return.
|
||||||
|
|
||||||
**Enforced:** bookkeeper policy (books' AGENTS.md), not the engine.
|
**Enforced:** bookkeeper policy (books' AGENTS.md), not the engine.
|
||||||
|
|
||||||
|
## B-23 Control roles: one account each, of the right kind, moved only when empty
|
||||||
|
|
||||||
|
**Status:** CONVENTION (a control account agrees with the records it
|
||||||
|
controls); DECISIONS (Stephen, 2026-10-13 and 2026-10-15)
|
||||||
|
|
||||||
|
Invoices, bills, payroll, and closes post to accounts the engine picks by
|
||||||
|
role: receivables, revenue, payables, general expense, retained earnings,
|
||||||
|
salary and payroll tax expense, and five payroll withholdings. A role uses
|
||||||
|
its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110–2150)
|
||||||
|
until the human assigns it one of the chart's own accounts; the assignment
|
||||||
|
is journaled, so replay never depends on a setting outside the journal
|
||||||
|
(DECISION, 2026-10-15). A role's account is postable and of the role's
|
||||||
|
type (B-4), and the engine refuses one that isn't rather than renumber
|
||||||
|
the chart (DECISION, 2026-10-13). No account fills two roles. A
|
||||||
|
receivables, payables, withholding, or retained earnings account must keep
|
||||||
|
agreeing with what it controls (open invoices, open bills, taxes withheld,
|
||||||
|
closed earnings): such a role moves only when its account holds no
|
||||||
|
balance, so the bookkeeper first moves the balance with an entry, which
|
||||||
|
the journal shows. Revenue and expense roles move at any time; earlier
|
||||||
|
postings stay where they are.
|
||||||
|
|
||||||
|
**Enforced:** `Ledger::check_controls`, `Ledger::assign_role` /
|
||||||
|
`check_assign_role`, `Ledger::check_renumber_account` (an assigned role
|
||||||
|
frees its code). **Checked:** `ledger-core` `tests/spec_roles.rs`;
|
||||||
|
`ledger-store` replay of `role.assigned`.
|
||||||
Reference in new issue
Block a user