Settings, conventions (posting templates, approval rules, policies, close steps, calendar, mappings, notes), decisions, open items and entry approvals are journal events (journal format 2). Books that keep their settings never read wanderbooks.toml or config.json; the server and the viewer take the vault key from --vault-key-file instead. books.import_settings moves a settings file in once, exactly as the server reads it. Approval rules, when enforced, gate every posting path. Replay refuses what it can't read as newer, never as corrupt. Lean: a record changes no figure. Mutation on the whole diff: every survivor killed by a test but two equivalent (docs/testing.md). The Company page says a late open item is overdue in words, not colour alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
71 files changed
+12249
-461
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_import_csv, fuzz_config_parse]
|
||||
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
|
||||
Generated
+3
@@ -3348,6 +3348,7 @@ dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb",
|
||||
"jiff-tzdb-platform",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -3651,6 +3652,7 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
name = "ledger-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ledger-config",
|
||||
"ledger-core",
|
||||
"serde_json",
|
||||
@@ -3678,6 +3680,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
"jiff",
|
||||
"proptest",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
|
||||
@@ -97,7 +97,7 @@ just # lists the recipes: check, build, prove, demo, ...
|
||||
```
|
||||
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
|
||||
crates/ledger-store/ # journal + SQLite projection + blob store
|
||||
crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy
|
||||
crates/ledger-mcp/ # MCP server: 108 tools, resources, handbook, receipt policy
|
||||
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
|
||||
crates/ledger-config/ # wanderbooks.toml company settings
|
||||
crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent
|
||||
|
||||
@@ -11,6 +11,7 @@ name = "wanderbooks"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = "0.4"
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -262,6 +262,9 @@ fiscal_year_start = "01-01"
|
||||
# "partnership", "S corporation", "C corporation", or "disregarded entity"
|
||||
# (Treas. Reg. §301.7701-3). tax_form wins when both are set.
|
||||
# tax_classification = "partnership"
|
||||
# The company's time zone (an IANA name): what "today" means for the
|
||||
# books. Unset: the computer's.
|
||||
# timezone = "America/Denver"
|
||||
# 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.
|
||||
@@ -304,6 +307,10 @@ accounting_basis = "accrual"
|
||||
# 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"
|
||||
# Accounts whose entries always need a receipt, whatever the amount; and
|
||||
# accounts exempt from the rule (an entry whose every line is in one).
|
||||
# receipt_required_accounts = ["6200"]
|
||||
# receipt_exempt_accounts = ["6999"]
|
||||
# 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"]
|
||||
@@ -332,6 +339,11 @@ receipt_threshold = "25.00"
|
||||
# (a transfer between your own accounts); "off" never mentions it.
|
||||
# parties = "suggest"
|
||||
|
||||
# Approval rules (conventions naming who approves which entries) block a
|
||||
# post until approved only under "enforce"; "off" (the default) records
|
||||
# them and blocks nothing.
|
||||
# approval_rules = "off"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -418,3 +418,41 @@ fn the_toml_shows_every_setting_the_config_reads() {
|
||||
assert!(has(sect, key), "[{sect}] {key} is in the template");
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving the scaffolded settings file, and every example in it, into
|
||||
/// books: each imports, reads as the file does (the import refuses
|
||||
/// otherwise), and exporting it back and importing the export changes
|
||||
/// nothing (plan/served.md P1: toml_round_trip_zero_events).
|
||||
#[test]
|
||||
fn the_toml_and_its_examples_import_and_round_trip() {
|
||||
use ledger_core::settings::{Settings, Source};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, text) = scaffolded_toml(tmp.path());
|
||||
let single_owner = |ex: &str| ex.starts_with("close_into ");
|
||||
let source = Source {
|
||||
decided_by: "onboarding".into(),
|
||||
decided_on: chrono_date(),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
for file in [
|
||||
text.clone(),
|
||||
uncomment(&text, |ex| !single_owner(ex)),
|
||||
uncomment(&text, single_owner),
|
||||
] {
|
||||
let import = ledger_config::codec::import(&file, None, &Settings::default(), "USD")
|
||||
.unwrap_or_else(|e| panic!("imports: {e}\n{file}"));
|
||||
assert!(import.unknown.is_empty(), "{:?}", import.unknown);
|
||||
let mut books = Settings::default();
|
||||
let changes = books.check_changes(&import.changes, &source).unwrap();
|
||||
books.apply(&changes, &source, 1).unwrap();
|
||||
let export = ledger_config::codec::export(&books, "USD");
|
||||
let again = ledger_config::codec::import(&export, None, &books, "USD")
|
||||
.unwrap_or_else(|e| panic!("the export imports: {e}\n{export}"));
|
||||
assert!(again.changes.is_empty(), "{:?}\n{export}", again.changes);
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_date() -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
//! Settings files as text in and out of the books (plan/served.md 5.4).
|
||||
//!
|
||||
//! Books keep their settings in the journal (`ledger_core::settings`).
|
||||
//! A `wanderbooks.toml` is text to import from and export to: the server
|
||||
//! never reads one once the books hold their settings.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: move a company's settings file into its books without changing
|
||||
//! how a single setting reads, and write them back out as a file.
|
||||
//! SOURCE: plan/served.md I-3, 5.4 (toml_round_trip_zero_events); the
|
||||
//! settings file's own rules (crate::parse).
|
||||
//! INVARIANTS:
|
||||
//! 1. An import is exactly the changes that make the books' settings
|
||||
//! what the file (and config.json's receipt policy, if given) says:
|
||||
//! keys the file sets and the books don't, or set differently, are
|
||||
//! set; keys the books set and the file doesn't are cleared. Nothing
|
||||
//! else.
|
||||
//! 2. NEVER an import that reads differently than the file: the
|
||||
//! settings after it give the same company, receipt policy, cash
|
||||
//! flow roles, asset tag prefix, party policy and time zone that
|
||||
//! the file gives the server today (crate::parse,
|
||||
//! crate::effective_receipt_policy_text), or the import is refused.
|
||||
//! 3. Nothing in the file is dropped silently: what isn't a books
|
||||
//! setting is a note (where it goes instead) or unknown (not
|
||||
//! imported; the caller shows it before anything is journaled).
|
||||
//! 4. Round trip: importing an export changes nothing (identity values
|
||||
//! are exported masked, and a masked value imports as "unchanged").
|
||||
//! ```
|
||||
|
||||
use crate::{BooksSettings, ReceiptPolicy};
|
||||
use ledger_core::settings::{self, Change, KEYS, Kind, Sensitivity, Settings, Source};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// What importing a settings file would do.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Import {
|
||||
/// The changes that make the books' settings what the file says
|
||||
/// (INVARIANT 1); empty when they already are.
|
||||
pub changes: Vec<Change>,
|
||||
/// What the file says that isn't a books setting, and where it goes
|
||||
/// instead (INVARIANT 3).
|
||||
pub notes: Vec<String>,
|
||||
/// Entries the books don't know: not imported (INVARIANT 3).
|
||||
pub unknown: Vec<String>,
|
||||
/// `[vault] key_file`, if the file sets one: the server's to use
|
||||
/// (`--vault-key-file`), not a books setting.
|
||||
pub vault_key_file: Option<String>,
|
||||
/// The file's comments, in order: a settings file's notes (where a
|
||||
/// value came from, what it waits on) are not settings, so they are
|
||||
/// listed for the caller to keep elsewhere (a convention, a decision, a
|
||||
/// setting's note) rather than dropped unseen (INVARIANT 3).
|
||||
pub comments: Vec<String>,
|
||||
}
|
||||
|
||||
/// The comments of TOML text: what follows each `#` outside a string, in
|
||||
/// order, trimmed, blank ones left out.
|
||||
pub fn comments(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines() {
|
||||
let mut quote: Option<char> = None;
|
||||
let mut escaped = false;
|
||||
for (i, c) in line.char_indices() {
|
||||
match quote {
|
||||
Some(q) => {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == '\\' && q == '"' {
|
||||
escaped = true;
|
||||
} else if c == q {
|
||||
quote = None;
|
||||
}
|
||||
}
|
||||
None if c == '"' || c == '\'' => quote = Some(c),
|
||||
None if c == '#' => {
|
||||
let rest = line[i + 1..].trim();
|
||||
if !rest.is_empty() {
|
||||
out.push(rest.to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A TOML value as the JSON a setting takes: strings stay strings; a
|
||||
/// number, boolean or date in a text setting reads as its TOML spelling;
|
||||
/// arrays and tables convert element by element.
|
||||
fn json_of(v: &toml::Value) -> Value {
|
||||
match v {
|
||||
toml::Value::String(s) => json!(s),
|
||||
toml::Value::Integer(i) => json!(i.to_string()),
|
||||
toml::Value::Float(f) => json!(f.to_string()),
|
||||
toml::Value::Boolean(b) => json!(b.to_string()),
|
||||
toml::Value::Datetime(d) => json!(d.to_string()),
|
||||
toml::Value::Array(a) => Value::Array(a.iter().map(json_of).collect()),
|
||||
toml::Value::Table(t) => {
|
||||
Value::Object(t.iter().map(|(k, v)| (k.clone(), json_of(v))).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_at(table: &str, name: &str) -> Option<&'static settings::Key> {
|
||||
KEYS.iter().find(|k| k.toml == (table, name))
|
||||
}
|
||||
|
||||
/// What importing `text` (a `wanderbooks.toml`) and, if given,
|
||||
/// `config_json` (its `receipt_policy` wins over the file's receipt
|
||||
/// settings, as it does for the server) would change in books holding
|
||||
/// `current`, kept in `base_currency`.
|
||||
pub fn import(
|
||||
text: &str,
|
||||
config_json: Option<&str>,
|
||||
current: &Settings,
|
||||
base_currency: &str,
|
||||
) -> Result<Import, String> {
|
||||
// The file as the server reads it today: what the import must match.
|
||||
let legacy = crate::parse(text)?;
|
||||
let legacy_policy = crate::effective_receipt_policy_text(config_json, &legacy)?;
|
||||
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut out = Import {
|
||||
comments: comments(text),
|
||||
..Import::default()
|
||||
};
|
||||
let mut wanted: BTreeMap<&'static str, Value> = BTreeMap::new();
|
||||
for (section, body) in &table {
|
||||
let toml::Value::Table(body) = body else {
|
||||
out.unknown.push(format!("{section} (outside any table)"));
|
||||
continue;
|
||||
};
|
||||
for (name, v) in body {
|
||||
match (section.as_str(), name.as_str()) {
|
||||
("company", "base_currency") => {
|
||||
let c = v.as_str().unwrap_or_default().trim().to_uppercase();
|
||||
if c != base_currency {
|
||||
return Err(format!(
|
||||
"[company] base_currency {c:?}: these books are kept in {base_currency}, fixed when they were created"
|
||||
));
|
||||
}
|
||||
out.notes.push(format!(
|
||||
"[company] base_currency: the books keep it themselves ({base_currency}, fixed when they were created)"
|
||||
));
|
||||
}
|
||||
("books", "data_dir") => out.notes.push(
|
||||
"[books] data_dir: where the books live is the server's to know, not a setting; dropped".into(),
|
||||
),
|
||||
("vault", "key_file") => {
|
||||
out.vault_key_file = v.as_str().map(str::to_string);
|
||||
out.notes.push(
|
||||
"[vault] key_file: the server's to know (start it with --vault-key-file), not a books setting; dropped".into(),
|
||||
);
|
||||
}
|
||||
("viewer", n) => out.notes.push(format!(
|
||||
"[viewer] {n}: the agent's preference, not a books setting; dropped (record it as a convention if it matters)"
|
||||
)),
|
||||
(s, n) => match key_at(s, n) {
|
||||
Some(k) => {
|
||||
wanted.insert(k.name, json_of(v));
|
||||
}
|
||||
None => out.unknown.push(format!("[{s}] {n}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(raw) = config_json {
|
||||
let j: Value = serde_json::from_str(raw).map_err(|e| format!("bad config: {e}"))?;
|
||||
let obj = j.as_object().ok_or("bad config: not a JSON object")?;
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"receipt_policy" => {
|
||||
// Its policy replaces the file's, whole (crate::
|
||||
// effective_receipt_policy_text).
|
||||
let p: ReceiptPolicy = serde_json::from_value(v.clone())
|
||||
.map_err(|e| format!("bad config: {e}"))?;
|
||||
wanted.insert("policy.evidence.threshold", json!(p.require_receipts_above));
|
||||
wanted.insert(
|
||||
"policy.evidence.required_accounts",
|
||||
json!(p.receipt_required_accounts),
|
||||
);
|
||||
wanted.insert(
|
||||
"policy.evidence.exempt_accounts",
|
||||
json!(p.receipt_exempt_accounts),
|
||||
);
|
||||
}
|
||||
"base_currency" => {
|
||||
let c = v.as_str().unwrap_or_default().trim().to_uppercase();
|
||||
if c != base_currency {
|
||||
return Err(format!(
|
||||
"config.json base_currency {c:?}: these books are kept in {base_currency}"
|
||||
));
|
||||
}
|
||||
}
|
||||
other => out.unknown.push(format!("config.json {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Canonical values, and the diff against what the books hold. An
|
||||
// identity value exported masked ("**-***5893") never imports: it is
|
||||
// what the books hold already when its last four match, else a note,
|
||||
// and either way the books' own value stays (INVARIANT 4).
|
||||
let mut canon: BTreeMap<&'static str, Value> = BTreeMap::new();
|
||||
let mut keep: Vec<&'static str> = Vec::new();
|
||||
for (name, v) in &wanted {
|
||||
let k = settings::key(name).expect("taken from KEYS");
|
||||
let masked =
|
||||
k.sensitivity == Sensitivity::Identity && v.as_str().is_some_and(|s| s.contains('*'));
|
||||
if masked {
|
||||
let same = current.get(name).is_some_and(|r| {
|
||||
v.as_str() == Some(settings::display(k, &r.value, false).as_str())
|
||||
});
|
||||
if !same {
|
||||
out.notes.push(format!(
|
||||
"[{}] {}: masked in this file, so not imported; set it with books.set_setting",
|
||||
k.toml.0, k.toml.1
|
||||
));
|
||||
}
|
||||
keep.push(k.name);
|
||||
continue;
|
||||
}
|
||||
let value =
|
||||
settings::check(k, v).map_err(|e| format!("[{}] {}: {e}", k.toml.0, k.toml.1))?;
|
||||
// An empty list is the same as no list: not a change.
|
||||
if value.as_array().is_some_and(Vec::is_empty) && k.kind != Kind::Members {
|
||||
continue;
|
||||
}
|
||||
canon.insert(k.name, value);
|
||||
}
|
||||
for k in KEYS {
|
||||
let have = current.get(k.name).map(|r| &r.value);
|
||||
match (canon.get(k.name), have) {
|
||||
(Some(want), Some(have)) if want == have => {}
|
||||
(Some(want), _) => out.changes.push(Change {
|
||||
key: k.name.into(),
|
||||
value: Some(want.clone()),
|
||||
}),
|
||||
(None, Some(_)) if keep.contains(&k.name) => {}
|
||||
(None, Some(_)) => out.changes.push(Change {
|
||||
key: k.name.into(),
|
||||
value: None,
|
||||
}),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
// INVARIANT 2: the books, after the import, read as the file does.
|
||||
let mut next = current.clone();
|
||||
let probe = Source {
|
||||
decided_by: "import".into(),
|
||||
decided_on: chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("a date"),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
next.apply(&out.changes, &probe, u64::MAX)?;
|
||||
next.check_whole()?;
|
||||
let ours = books_settings(&next, base_currency);
|
||||
let our_policy = receipt_policy(&next);
|
||||
let differs = differences(&legacy, &legacy_policy, &ours, &our_policy);
|
||||
if !differs.is_empty() {
|
||||
return Err(format!(
|
||||
"importing would read these settings differently than the file does: {}",
|
||||
differs.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Where two readings of the settings differ, by field name.
|
||||
fn differences(
|
||||
a: &BooksSettings,
|
||||
ap: &ReceiptPolicy,
|
||||
b: &BooksSettings,
|
||||
bp: &ReceiptPolicy,
|
||||
) -> Vec<&'static str> {
|
||||
let (ca, cb) = (&a.company, &b.company);
|
||||
let threshold = |p: &ReceiptPolicy| crate::decimal(p.require_receipts_above.trim());
|
||||
[
|
||||
("company name", ca.name == cb.name),
|
||||
(
|
||||
"fiscal year start",
|
||||
ca.fiscal_year_start == cb.fiscal_year_start,
|
||||
),
|
||||
("members", ca.members == cb.members),
|
||||
("accounting basis", ca.basis == cb.basis),
|
||||
("tax form", ca.tax_form == cb.tax_form),
|
||||
("close_into", ca.close_into == cb.close_into),
|
||||
("close_sweep", ca.close_sweep == cb.close_sweep),
|
||||
("receipt threshold", threshold(ap) == threshold(bp)),
|
||||
(
|
||||
"receipt required accounts",
|
||||
ap.receipt_required_accounts == bp.receipt_required_accounts,
|
||||
),
|
||||
(
|
||||
"receipt exempt accounts",
|
||||
ap.receipt_exempt_accounts == bp.receipt_exempt_accounts,
|
||||
),
|
||||
("cash accounts", a.cash_accounts == b.cash_accounts),
|
||||
(
|
||||
"financing accounts",
|
||||
a.financing_accounts == b.financing_accounts,
|
||||
),
|
||||
(
|
||||
"investing accounts",
|
||||
a.investing_accounts == b.investing_accounts,
|
||||
),
|
||||
(
|
||||
"asset tag prefix",
|
||||
a.asset_tag_prefix() == b.asset_tag_prefix(),
|
||||
),
|
||||
("parties", a.parties == b.parties),
|
||||
("time zone", a.timezone == b.timezone),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, same)| !same)
|
||||
.map(|(n, _)| n)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The settings as the server and the viewer use them, from the books'
|
||||
/// own (`ledger_core::settings`): what [`crate::parse`] gives for a
|
||||
/// settings file. `[vault] key_file` is never a books setting.
|
||||
pub fn books_settings(s: &Settings, base_currency: &str) -> BooksSettings {
|
||||
BooksSettings {
|
||||
company: s.company(base_currency),
|
||||
receipt_threshold: s.receipt_threshold().map(str::to_string),
|
||||
receipt_required_accounts: s.receipt_required_accounts(),
|
||||
receipt_exempt_accounts: s.receipt_exempt_accounts(),
|
||||
cash_accounts: s.cash_accounts(),
|
||||
financing_accounts: s.financing_accounts(),
|
||||
investing_accounts: s.investing_accounts(),
|
||||
asset_tag_prefix: s.asset_tag_prefix().map(str::to_string),
|
||||
parties: s.parties(),
|
||||
vault_key_file: None,
|
||||
timezone: s.timezone().map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt policy the books' own settings set.
|
||||
pub fn receipt_policy(s: &Settings) -> ReceiptPolicy {
|
||||
ReceiptPolicy {
|
||||
require_receipts_above: s
|
||||
.receipt_threshold()
|
||||
.unwrap_or(settings::DEFAULT_THRESHOLD)
|
||||
.to_string(),
|
||||
receipt_required_accounts: s.receipt_required_accounts(),
|
||||
receipt_exempt_accounts: s.receipt_exempt_accounts(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One TOML line `name = value`, the value spelled as TOML.
|
||||
fn toml_line(name: &str, v: &Value) -> String {
|
||||
let tv = match v {
|
||||
Value::String(s) => toml::Value::String(s.clone()),
|
||||
Value::Array(a) => toml::Value::Array(
|
||||
a.iter()
|
||||
.map(|x| toml::Value::String(x.as_str().unwrap_or_default().to_string()))
|
||||
.collect(),
|
||||
),
|
||||
other => toml::Value::String(other.to_string()),
|
||||
};
|
||||
let mut t = toml::Table::new();
|
||||
t.insert(name.to_string(), tv);
|
||||
toml::to_string(&t)
|
||||
.unwrap_or_default()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn provenance(out: &mut String, r: &settings::Recorded) {
|
||||
let s = &r.source;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# {}, {} (journal line {})",
|
||||
s.decided_by, s.decided_on, r.seq
|
||||
);
|
||||
for extra in [&s.citation, &s.note].into_iter().flatten() {
|
||||
for line in extra.lines() {
|
||||
let _ = writeln!(out, "# {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The books' settings as a settings file (`wanderbooks.toml` text):
|
||||
/// every setting set, under its table, with who decided it and when as a
|
||||
/// comment; identity values masked (INVARIANT 4).
|
||||
pub fn export(s: &Settings, base_currency: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# wanderbooks settings, exported from the books{}.",
|
||||
s.last_seq()
|
||||
.map(|q| format!(" (through journal line {q})"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
out.push_str(
|
||||
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing until it is imported (books.import_settings).\n",
|
||||
);
|
||||
for table in ["company", "books"] {
|
||||
let _ = write!(out, "\n[{table}]\n");
|
||||
if table == "company" {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# Fixed when the books were created.\nbase_currency = {:?}",
|
||||
base_currency
|
||||
);
|
||||
}
|
||||
let mut members = None;
|
||||
for (k, r) in s.iter().filter(|(k, _)| k.toml.0 == table) {
|
||||
if k.kind == Kind::Members {
|
||||
members = Some((k, r));
|
||||
continue;
|
||||
}
|
||||
provenance(&mut out, r);
|
||||
let shown = match k.sensitivity {
|
||||
Sensitivity::Identity => Value::String(settings::display(k, &r.value, false)),
|
||||
Sensitivity::Public => r.value.clone(),
|
||||
};
|
||||
let _ = writeln!(out, "{}", toml_line(k.toml.1, &shown));
|
||||
}
|
||||
if let Some((_, r)) = members {
|
||||
let _ = writeln!(out);
|
||||
provenance(&mut out, r);
|
||||
for m in r.value.as_array().into_iter().flatten() {
|
||||
out.push_str("[[company.members]]\n");
|
||||
for f in [
|
||||
"name",
|
||||
"share",
|
||||
"capital_account",
|
||||
"distribution_account",
|
||||
"files_jointly_with",
|
||||
] {
|
||||
if let Some(v) = m.get(f) {
|
||||
let _ = writeln!(out, "{}", toml_line(f, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WANDERLAND: &str = r#"
|
||||
[company]
|
||||
name = "Wanderland Labs LLC"
|
||||
entity_type = "LLC, multi-member, member-managed"
|
||||
tax_classification = "partnership"
|
||||
base_currency = "USD"
|
||||
fiscal_year_start = "01-01"
|
||||
formation_state = "UT"
|
||||
formed = "2026-10-04"
|
||||
state_entity_number = "14745117-0160"
|
||||
ein = "43-2215893"
|
||||
irs_name_control = "WALN"
|
||||
duns = "149976972"
|
||||
address = "15281 S Tall Woods Dr, Draper, UT 84020"
|
||||
registered_agent = "Stephen Waits"
|
||||
partnership_representative = "Stephen Waits"
|
||||
cpa = "Krista Baker"
|
||||
|
||||
[[company.members]]
|
||||
name = "Stephen Waits"
|
||||
share = "50%"
|
||||
capital_account = "3000"
|
||||
files_jointly_with = "Katherine Pendergast"
|
||||
|
||||
[[company.members]]
|
||||
name = "Katherine Pendergast"
|
||||
share = "50%"
|
||||
capital_account = "3001"
|
||||
files_jointly_with = "Stephen Waits"
|
||||
|
||||
[books]
|
||||
data_dir = "books-data"
|
||||
accounting_basis = "accrual"
|
||||
receipt_threshold = "0.00"
|
||||
cash_accounts = ["1000"]
|
||||
financing_accounts = ["2100"]
|
||||
parties = "require"
|
||||
|
||||
[viewer]
|
||||
open_at_start = "ask"
|
||||
"#;
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
|
||||
citation: None,
|
||||
note: Some("imported".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn imported(text: &str) -> Settings {
|
||||
let i = import(text, None, &Settings::default(), "USD").unwrap();
|
||||
let mut s = Settings::default();
|
||||
let changes = s.check_changes(&i.changes, &src()).unwrap();
|
||||
s.apply(&changes, &src(), 1).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// INVARIANTS 1-3 on a real company's file.
|
||||
#[test]
|
||||
fn a_real_file_imports_whole() {
|
||||
let i = import(WANDERLAND, None, &Settings::default(), "USD").unwrap();
|
||||
assert!(i.unknown.is_empty(), "{:?}", i.unknown);
|
||||
assert_eq!(i.notes.len(), 3, "{:?}", i.notes);
|
||||
// 14 company facts, the members, and 5 [books] settings.
|
||||
assert_eq!(i.changes.len(), 20);
|
||||
let s = imported(WANDERLAND);
|
||||
assert_eq!(s.get("company.ein").unwrap().value, json!("43-2215893"));
|
||||
assert_eq!(s.parties(), crate::PartyPolicy::Require);
|
||||
// Already imported: nothing to change.
|
||||
let again = import(WANDERLAND, None, &s, "USD").unwrap();
|
||||
assert!(again.changes.is_empty(), "{:?}", again.changes);
|
||||
}
|
||||
|
||||
/// INVARIANT 4: an export imports as no change at all.
|
||||
#[test]
|
||||
fn export_round_trips() {
|
||||
let s = imported(WANDERLAND);
|
||||
let text = export(&s, "USD");
|
||||
assert!(text.contains("ein = \"**-***5893\""), "{text}");
|
||||
assert!(!text.contains("2215893"), "{text}");
|
||||
let back = import(&text, None, &s, "USD").unwrap();
|
||||
assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes);
|
||||
assert!(back.unknown.is_empty(), "{:?}", back.unknown);
|
||||
// And the export reads, as a settings file, as the books do.
|
||||
assert_eq!(
|
||||
crate::parse(&text).unwrap().company.name,
|
||||
"Wanderland Labs LLC"
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 1: keys the file leaves out are cleared.
|
||||
#[test]
|
||||
fn what_the_file_leaves_out_is_cleared() {
|
||||
let s = imported(WANDERLAND);
|
||||
let i = import(
|
||||
"[company]\nname = \"Wanderland Labs LLC\"\n",
|
||||
None,
|
||||
&s,
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
i.changes
|
||||
.iter()
|
||||
.any(|c| c.key == "company.members" && c.value.is_none())
|
||||
);
|
||||
assert!(!i.changes.iter().any(|c| c.key == "company.name"));
|
||||
}
|
||||
|
||||
/// INVARIANT 3, and the currency is the books'.
|
||||
#[test]
|
||||
fn unknown_and_currency() {
|
||||
let i = import(
|
||||
"[company]\nwebsite = \"x\"\n[extra]\na = 1\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(i.unknown, ["[company] website", "[extra] a"]);
|
||||
assert!(
|
||||
import(
|
||||
"[company]\nbase_currency = \"EUR\"\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// config.json's receipt policy wins, whole, as it does for the server.
|
||||
#[test]
|
||||
fn config_json_policy_wins() {
|
||||
let j = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#;
|
||||
let i = import(
|
||||
"[books]\nreceipt_threshold = \"0.00\"\n",
|
||||
Some(j),
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.apply(&i.changes, &src(), 1).unwrap();
|
||||
let p = receipt_policy(&s);
|
||||
assert_eq!(p.require_receipts_above, "10.00");
|
||||
assert_eq!(p.receipt_exempt_accounts, ["6999"]);
|
||||
}
|
||||
|
||||
/// INVARIANT 2: a value the books would read differently is refused
|
||||
/// (here: a time zone the file names that isn't one).
|
||||
#[test]
|
||||
fn never_reads_differently() {
|
||||
assert!(
|
||||
import(
|
||||
"[company]\ntimezone = \"Nowhere/Land\"\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
import(
|
||||
"[company]\nfiscal_year_start = \"07-01\"\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
/// A comment is what follows a `#` outside a string: a `#` inside a
|
||||
/// basic string (even after an escaped quote) or a literal string
|
||||
/// (where a backslash escapes nothing) is text; blank comments are
|
||||
/// left out.
|
||||
#[test]
|
||||
fn comments_are_what_follows_a_hash_outside_strings() {
|
||||
let text = "# top\n\
|
||||
name = \"a # not\" # one\n\
|
||||
esc = \"q \\\" # still\" # two\n\
|
||||
lit = 'c:\\' # three\n\
|
||||
#\n\
|
||||
x = 1 # \n\
|
||||
y = 2#five\n";
|
||||
assert_eq!(comments(text), ["top", "one", "two", "three", "five"]);
|
||||
assert!(comments("a = 'x' \"y\"\n").is_empty());
|
||||
}
|
||||
|
||||
/// The import lists the file's comments (INVARIANT 3).
|
||||
#[test]
|
||||
fn an_import_lists_the_comments() {
|
||||
let i = import(
|
||||
"# Kept settings\n[company]\nname = \"X LLC\" # from the certificate\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(i.comments, ["Kept settings", "from the certificate"]);
|
||||
}
|
||||
|
||||
/// config.json's base_currency must be the books'.
|
||||
#[test]
|
||||
fn config_json_currency_is_the_books() {
|
||||
let ok = import(
|
||||
"",
|
||||
Some(r#"{"base_currency": " usd "}"#),
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ok.changes.is_empty() && ok.unknown.is_empty(), "{ok:?}");
|
||||
let err = import(
|
||||
"",
|
||||
Some(r#"{"base_currency": "EUR"}"#),
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("config.json base_currency \"EUR\""), "{err}");
|
||||
}
|
||||
|
||||
/// INVARIANT 4: a masked identity value never imports. It keeps the
|
||||
/// books' own value either way, with a note only when its last four
|
||||
/// differ from what the books hold.
|
||||
#[test]
|
||||
fn a_masked_identity_keeps_the_books_value() {
|
||||
let s = imported(WANDERLAND);
|
||||
let masked =
|
||||
|ein: &str| WANDERLAND.replace("ein = \"43-2215893\"", &format!("ein = \"{ein}\""));
|
||||
let note =
|
||||
"[company] ein: masked in this file, so not imported; set it with books.set_setting";
|
||||
let same = import(&masked("**-***5893"), None, &s, "USD").unwrap();
|
||||
assert!(same.changes.is_empty(), "{:?}", same.changes);
|
||||
assert!(!same.notes.iter().any(|n| n == note), "{:?}", same.notes);
|
||||
let other = import(&masked("**-***0000"), None, &s, "USD").unwrap();
|
||||
assert!(other.changes.is_empty(), "{:?}", other.changes);
|
||||
assert!(other.notes.iter().any(|n| n == note), "{:?}", other.notes);
|
||||
// Into books that hold none: still not imported, and noted.
|
||||
let empty = import(
|
||||
"[company]\nein = \"**-***5893\"\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(empty.changes.is_empty(), "{:?}", empty.changes);
|
||||
assert_eq!(empty.notes, [note]);
|
||||
}
|
||||
|
||||
/// An empty list is no list: not a change.
|
||||
#[test]
|
||||
fn an_empty_list_is_no_change() {
|
||||
let i = import(
|
||||
"[books]\ncash_accounts = []\nfinancing_accounts = []\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(i.changes.is_empty(), "{:?}", i.changes);
|
||||
}
|
||||
|
||||
/// A value the file changes is a change, exactly that one.
|
||||
#[test]
|
||||
fn a_changed_value_is_one_change() {
|
||||
let s = imported(WANDERLAND);
|
||||
let text = WANDERLAND.replace("\"Wanderland Labs LLC\"", "\"Other Labs LLC\"");
|
||||
let i = import(&text, None, &s, "USD").unwrap();
|
||||
assert_eq!(
|
||||
i.changes,
|
||||
[Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!("Other Labs LLC")),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 2's check names each field read differently.
|
||||
#[test]
|
||||
fn differences_name_each_field() {
|
||||
let a = BooksSettings::default();
|
||||
let p = ReceiptPolicy::default();
|
||||
assert!(differences(&a, &p, &a, &p).is_empty());
|
||||
let mut b = a.clone();
|
||||
b.company.name = "Other".into();
|
||||
b.timezone = Some("America/Denver".into());
|
||||
let mut q = p.clone();
|
||||
q.require_receipts_above = format!("{}0", p.require_receipts_above);
|
||||
// The default asset tag prefix is made from the company's name.
|
||||
let named = ["company name", "asset tag prefix", "time zone"];
|
||||
assert_eq!(differences(&a, &p, &b, &q), named);
|
||||
q.require_receipts_above = "1.00".into();
|
||||
assert_eq!(
|
||||
differences(&a, &p, &b, &q),
|
||||
[
|
||||
"company name",
|
||||
"receipt threshold",
|
||||
"asset tag prefix",
|
||||
"time zone"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// An export says, for each setting, who decided it, when, and its
|
||||
/// note.
|
||||
#[test]
|
||||
fn an_export_keeps_each_settings_source() {
|
||||
let text = export(&imported(WANDERLAND), "USD");
|
||||
assert!(
|
||||
text.contains("\n# Owner, 2026-10-17 (journal line 1)\n# imported\n"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+75
-212
@@ -9,128 +9,15 @@
|
||||
//! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before
|
||||
//! the next start, so the two can't disagree.
|
||||
|
||||
pub mod codec;
|
||||
pub mod receipt_policy;
|
||||
pub use ledger_core::settings::{Company, Member, PartyPolicy, decimal};
|
||||
pub use receipt_policy::ReceiptPolicy;
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// The company the books belong to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Company {
|
||||
/// Legal name, for report headers.
|
||||
pub name: String,
|
||||
/// Month and day the fiscal year starts. Default January 1.
|
||||
pub fiscal_year_start: (u32, u32),
|
||||
/// Members of a multi-member LLC; empty for other entities.
|
||||
pub members: Vec<Member>,
|
||||
/// "Accrual" or "Cash", for report footers. Default "Accrual".
|
||||
pub basis: String,
|
||||
/// The income tax return the business files, if known: from
|
||||
/// `tax_form`, else from `tax_classification`.
|
||||
pub tax_form: Option<ledger_core::TaxForm>,
|
||||
/// `[books] close_into`: the equity account a period close puts net
|
||||
/// income in (a sole proprietor's capital, say). Unset: Retained
|
||||
/// Earnings, or members' capital accounts when there are members.
|
||||
pub close_into: Option<String>,
|
||||
/// `[books] close_sweep`: equity accounts (draws, dividends,
|
||||
/// distributions) a close zeroes into `close_into`.
|
||||
pub close_sweep: Vec<String>,
|
||||
/// Every simple `[company]` setting (name, EIN, formation date, ...),
|
||||
/// in file order, as written: the full profile for display.
|
||||
pub profile: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// One member of a multi-member LLC.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Member {
|
||||
/// Name, as it appears on reports.
|
||||
pub name: String,
|
||||
/// Share of income, e.g. 0.50.
|
||||
pub share: Decimal,
|
||||
/// Code of their capital account in the chart.
|
||||
pub capital_account: String,
|
||||
/// Code of their distributions account, if distributions are recorded
|
||||
/// separately from capital (zeroed into capital at close).
|
||||
pub distribution_account: Option<String>,
|
||||
/// `files_jointly_with`: the other member this member files a joint
|
||||
/// individual return with (spouses). Each still gets their own K-1
|
||||
/// (a partnership reports each partner separately, IRS Instructions
|
||||
/// for Form 1065, Schedule K-1); both K-1s go on the one Form 1040
|
||||
/// (Schedule E, Part II). Informational: the CPA package says so.
|
||||
pub files_jointly_with: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Company {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Unnamed company".to_string(),
|
||||
fiscal_year_start: (1, 1),
|
||||
members: Vec::new(),
|
||||
basis: "Accrual".to_string(),
|
||||
tax_form: None,
|
||||
close_into: None,
|
||||
close_sweep: Vec::new(),
|
||||
profile: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Company {
|
||||
/// The members as party shares see them ([`ledger_core::PartyMember`]):
|
||||
/// each member whose name (ignoring case and surrounding space) is a
|
||||
/// party with the member role, and whose capital account is in the
|
||||
/// chart. The party not archived of that name when there is one, else
|
||||
/// the most recent archived one: archiving a member ("no longer dealt
|
||||
/// with; history stays") never takes their history away. Members
|
||||
/// missing either are left out, so their shares can't be derived (and
|
||||
/// the viewer shows no capital statement for them).
|
||||
pub fn party_members(&self, ledger: &ledger_core::Ledger) -> Vec<ledger_core::PartyMember> {
|
||||
let is_member = |p: &&ledger_core::Party| p.roles.contains(&ledger_core::Role::Member);
|
||||
self.members
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let key = ledger_core::parties::name_key(&m.name);
|
||||
let party = match ledger.party_by_name(&m.name) {
|
||||
Some(p) => Some(p).filter(is_member)?,
|
||||
None => ledger
|
||||
.parties()
|
||||
.filter(|p| p.archived && ledger_core::parties::name_key(&p.name) == key)
|
||||
.filter(is_member)
|
||||
.max_by_key(|p| p.id)?,
|
||||
};
|
||||
Some(ledger_core::PartyMember {
|
||||
party: party.id,
|
||||
share: m.share,
|
||||
capital: ledger.chart().find_by_code(&m.capital_account)?.id,
|
||||
distributions: m
|
||||
.distribution_account
|
||||
.as_deref()
|
||||
.and_then(|c| ledger.chart().find_by_code(c))
|
||||
.map(|a| a.id),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
|
||||
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
|
||||
let (m, day) = self.fiscal_year_start;
|
||||
let on = |year| NaiveDate::from_ymd_opt(year, m, day).expect("validated month-day");
|
||||
let start = if on(d.year()) <= d {
|
||||
on(d.year())
|
||||
} else {
|
||||
on(d.year() - 1)
|
||||
};
|
||||
let end = on(start.year() + 1)
|
||||
.pred_opt()
|
||||
.expect("not the minimum date");
|
||||
(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the server takes from `wanderbooks.toml`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BooksSettings {
|
||||
@@ -138,6 +25,12 @@ pub struct BooksSettings {
|
||||
pub company: Company,
|
||||
/// `[books] receipt_threshold`, validated as a decimal string.
|
||||
pub receipt_threshold: Option<String>,
|
||||
/// `[books] receipt_required_accounts`: account codes whose entries
|
||||
/// always need a receipt (unless `config.json` sets a policy).
|
||||
pub receipt_required_accounts: Vec<String>,
|
||||
/// `[books] receipt_exempt_accounts`: account codes exempt from the
|
||||
/// receipt rule (unless `config.json` sets a policy).
|
||||
pub receipt_exempt_accounts: Vec<String>,
|
||||
/// `[books] cash_accounts`: account codes that hold cash (bank,
|
||||
/// savings, cash on hand), for cash reports and dashboards. Empty:
|
||||
/// callers decide (e.g. by account name).
|
||||
@@ -160,6 +53,9 @@ pub struct BooksSettings {
|
||||
/// `[vault] key_file`: the TIN vault's key file. Unset: the vault's
|
||||
/// default (`~/.config/wanderbooks/vault.key`).
|
||||
pub vault_key_file: Option<std::path::PathBuf>,
|
||||
/// `[company] timezone`: the company's time zone (an IANA name), what
|
||||
/// "today" means for the books. Unset: the computer's.
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl BooksSettings {
|
||||
@@ -231,6 +127,7 @@ struct CompanyToml {
|
||||
fiscal_year_start: Option<String>,
|
||||
tax_form: Option<String>,
|
||||
tax_classification: Option<String>,
|
||||
timezone: Option<String>,
|
||||
#[serde(default)]
|
||||
members: Vec<MemberToml>,
|
||||
}
|
||||
@@ -247,6 +144,10 @@ struct MemberToml {
|
||||
#[derive(Default, Deserialize)]
|
||||
struct BooksSection {
|
||||
receipt_threshold: Option<String>,
|
||||
#[serde(default)]
|
||||
receipt_required_accounts: Vec<String>,
|
||||
#[serde(default)]
|
||||
receipt_exempt_accounts: Vec<String>,
|
||||
accounting_basis: Option<String>,
|
||||
close_into: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -261,47 +162,6 @@ struct BooksSection {
|
||||
parties: Option<String>,
|
||||
}
|
||||
|
||||
/// The return a federal tax classification files (docs/spec/sources/
|
||||
/// irs-entity-classification.md), read by whole words, ignoring case:
|
||||
/// "partnership" → 1065; "S corporation" (S corp, S-corp, scorp) →
|
||||
/// 1120-S; "C corporation", or "corporation", "corp", or "association"
|
||||
/// alone → 1120; "disregarded", "sole proprietor(ship)", or
|
||||
/// "single-member" → Schedule C. Anything else, or words naming two
|
||||
/// returns ("corporation or S corporation"), names none.
|
||||
fn form_for_classification(c: &str) -> Option<ledger_core::TaxForm> {
|
||||
use ledger_core::TaxForm;
|
||||
let lower = c.to_lowercase();
|
||||
let words: Vec<&str> = lower
|
||||
.split(|ch: char| !ch.is_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect();
|
||||
let has = |w: &str| words.contains(&w);
|
||||
let mut found = Vec::new();
|
||||
if has("partnership") {
|
||||
found.push(TaxForm::Form1065);
|
||||
}
|
||||
for (i, w) in words.iter().enumerate() {
|
||||
let prev = i.checked_sub(1).map(|p| words[p]);
|
||||
let form = match *w {
|
||||
"scorp" => Some(TaxForm::Form1120S),
|
||||
"ccorp" | "association" => Some(TaxForm::Form1120),
|
||||
"corp" | "corporation" if prev == Some("s") => Some(TaxForm::Form1120S),
|
||||
"corp" | "corporation" => Some(TaxForm::Form1120),
|
||||
_ => None,
|
||||
};
|
||||
found.extend(form);
|
||||
}
|
||||
if has("disregarded") || has("sole") || words.windows(2).any(|p| p == ["single", "member"]) {
|
||||
found.push(TaxForm::ScheduleC);
|
||||
}
|
||||
found.sort();
|
||||
found.dedup();
|
||||
match found.as_slice() {
|
||||
[one] => Some(*one),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a share: "50%" or "0.5".
|
||||
fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
|
||||
let s = s.trim();
|
||||
@@ -348,22 +208,6 @@ fn codes(key: &str, list: Vec<String>) -> Result<Vec<String>, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A decimal string as the books take one: exactly `-?digits[.digits]`.
|
||||
/// The decimal parser alone also takes "1_000", "+5", ".5", and
|
||||
/// exponents ("1e3"); a setting or tool argument never means those.
|
||||
pub fn decimal(s: &str) -> Option<Decimal> {
|
||||
let digits = s.strip_prefix('-').unwrap_or(s);
|
||||
let (whole, frac) = match digits.split_once('.') {
|
||||
Some((w, f)) => (w, Some(f)),
|
||||
None => (digits, None),
|
||||
};
|
||||
let all_digits = |t: &str| !t.is_empty() && t.bytes().all(|b| b.is_ascii_digit());
|
||||
if !all_digits(whole) || frac.is_some_and(|f| !all_digits(f)) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// The receipt policy in force: `config_json`'s `receipt_policy` when the
|
||||
/// file sets one, else `[books] receipt_threshold`, else the default
|
||||
/// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and
|
||||
@@ -371,28 +215,52 @@ pub fn decimal(s: &str) -> Option<Decimal> {
|
||||
pub fn effective_receipt_policy(
|
||||
config_json: &Path,
|
||||
settings: &BooksSettings,
|
||||
) -> Result<ReceiptPolicy, String> {
|
||||
let raw = if config_json.exists() {
|
||||
Some(
|
||||
std::fs::read_to_string(config_json)
|
||||
.map_err(|e| format!("{}: {e}", config_json.display()))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
effective_receipt_policy_text(raw.as_deref(), settings).map_err(|e| {
|
||||
e.replace(
|
||||
"bad config",
|
||||
&format!("bad config {}", config_json.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`effective_receipt_policy`] from `config.json`'s text (`None`: no
|
||||
/// such file).
|
||||
pub fn effective_receipt_policy_text(
|
||||
config_json: Option<&str>,
|
||||
settings: &BooksSettings,
|
||||
) -> Result<ReceiptPolicy, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct ConfigJson {
|
||||
#[serde(default)]
|
||||
receipt_policy: Option<ReceiptPolicy>,
|
||||
}
|
||||
let from_json = if config_json.exists() {
|
||||
let raw = std::fs::read_to_string(config_json)
|
||||
.map_err(|e| format!("{}: {e}", config_json.display()))?;
|
||||
serde_json::from_str::<ConfigJson>(&raw)
|
||||
.map_err(|e| format!("bad config {}: {e}", config_json.display()))?
|
||||
.receipt_policy
|
||||
} else {
|
||||
None
|
||||
let from_json = match config_json {
|
||||
Some(raw) => {
|
||||
serde_json::from_str::<ConfigJson>(raw)
|
||||
.map_err(|e| format!("bad config: {e}"))?
|
||||
.receipt_policy
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let policy = match (from_json, &settings.receipt_threshold) {
|
||||
(Some(p), _) => p,
|
||||
(None, Some(t)) => ReceiptPolicy {
|
||||
require_receipts_above: t.clone(),
|
||||
..ReceiptPolicy::default()
|
||||
let policy = match from_json {
|
||||
Some(p) => p,
|
||||
None => ReceiptPolicy {
|
||||
require_receipts_above: settings
|
||||
.receipt_threshold
|
||||
.clone()
|
||||
.unwrap_or_else(|| ledger_core::settings::DEFAULT_THRESHOLD.to_string()),
|
||||
receipt_required_accounts: settings.receipt_required_accounts.clone(),
|
||||
receipt_exempt_accounts: settings.receipt_exempt_accounts.clone(),
|
||||
},
|
||||
(None, None) => ReceiptPolicy::default(),
|
||||
};
|
||||
policy.validate()?;
|
||||
Ok(policy)
|
||||
@@ -521,7 +389,7 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
|
||||
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120")
|
||||
})?),
|
||||
(None, Some(c)) => Some(form_for_classification(c).ok_or_else(|| {
|
||||
(None, Some(c)) => Some(ledger_core::settings::form_for_classification(c).ok_or_else(|| {
|
||||
format!(
|
||||
"tax_classification {c:?}: expected partnership, S corporation, C corporation, or disregarded entity (or set tax_form)"
|
||||
)
|
||||
@@ -578,9 +446,26 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let timezone = match raw.company.timezone {
|
||||
Some(z) => {
|
||||
let key = ledger_core::settings::key("company.timezone").expect("a known key");
|
||||
let v = ledger_core::settings::check(key, &toml_json(&z))?;
|
||||
v.as_str().map(str::to_string)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(BooksSettings {
|
||||
company,
|
||||
receipt_threshold,
|
||||
receipt_required_accounts: codes(
|
||||
"receipt_required_accounts",
|
||||
raw.books.receipt_required_accounts,
|
||||
)?,
|
||||
receipt_exempt_accounts: codes(
|
||||
"receipt_exempt_accounts",
|
||||
raw.books.receipt_exempt_accounts,
|
||||
)?,
|
||||
timezone,
|
||||
cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?,
|
||||
financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?,
|
||||
investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?,
|
||||
@@ -622,31 +507,8 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `[books] parties`: whether entries name their counterparty
|
||||
/// (docs/parties-and-attachments.md "Counterparties"; DECISION, Stephen,
|
||||
/// 2026-10-15: the default suggests, books may require or turn it off).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum PartyPolicy {
|
||||
/// Never mention counterparties.
|
||||
Off,
|
||||
/// A draft or preview without a party names the likely one; nothing
|
||||
/// is refused.
|
||||
#[default]
|
||||
Suggest,
|
||||
/// Posting is refused until the entry names a party or is marked as
|
||||
/// having none.
|
||||
Require,
|
||||
}
|
||||
|
||||
impl PartyPolicy {
|
||||
/// Its wire name.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
PartyPolicy::Off => "off",
|
||||
PartyPolicy::Suggest => "suggest",
|
||||
PartyPolicy::Require => "require",
|
||||
}
|
||||
}
|
||||
fn toml_json(s: &str) -> serde_json::Value {
|
||||
serde_json::Value::String(s.to_string())
|
||||
}
|
||||
|
||||
/// Load `wanderbooks.toml` from a path.
|
||||
@@ -658,6 +520,7 @@ pub fn load(path: &Path) -> Result<BooksSettings, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// files_jointly_with: names another member, on both sides.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 71eaf212cfff55fb9489fddf65627997660881f193be6bcbb21bc2a2add6a420 # shrinks to picks = [(44, false)]
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The settings file codec over settings built at random from valid and
|
||||
//! refused changes (plan/served.md P1: toml_round_trip_zero_events).
|
||||
//!
|
||||
//! ```text
|
||||
//! INVARIANTS (crate::codec):
|
||||
//! 1. An export imports into the same books as no change at all.
|
||||
//! 2. An export imports into empty books as exactly those settings,
|
||||
//! identity values aside (exported masked: they stay unset).
|
||||
//! 3. The export reads, as a settings file, as the books do.
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_config::codec;
|
||||
use ledger_core::settings::{Change, Settings, Source};
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "Pat Rivera".into(),
|
||||
decided_on: NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
|
||||
citation: Some("Operating agreement §4.1\nsecond line".into()),
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn candidates() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("company.name", json!("Acme \"Widgets\" LLC")),
|
||||
("company.name", json!("Ünïcödé Holdings")),
|
||||
("company.entity_type", json!("LLC, multi-member")),
|
||||
("company.tax_classification", json!("S corporation")),
|
||||
("company.tax_classification", json!("partnership")),
|
||||
("company.tax_form", json!("1065")),
|
||||
("company.fiscal_year_start", json!("07-01")),
|
||||
("company.timezone", json!("America/Denver")),
|
||||
("company.formation_state", json!("UT")),
|
||||
("company.formed", json!("2026-10-04")),
|
||||
("company.state_entity_number", json!("14745117-0160")),
|
||||
("company.ein", json!("43-2215893")),
|
||||
("company.irs_name_control", json!("waln")),
|
||||
("company.duns", json!("149-976-972")),
|
||||
("company.address", json!("1 Main St\nSuite 2, Draper, UT")),
|
||||
("company.registered_agent", json!("Pat Rivera")),
|
||||
("company.partnership_representative", json!("Pat Rivera")),
|
||||
("company.cpa", json!("Krista Baker")),
|
||||
(
|
||||
"company.members",
|
||||
json!([{"name": "A", "share": "50%", "capital_account": "3000",
|
||||
"distribution_account": "3100", "files_jointly_with": "B"},
|
||||
{"name": "B", "share": "0.5", "capital_account": "3001",
|
||||
"files_jointly_with": "A"}]),
|
||||
),
|
||||
(
|
||||
"company.members",
|
||||
json!([{"name": "Solo", "share": "100%", "capital_account": "3000"}]),
|
||||
),
|
||||
("books.accounting_basis", json!("cash")),
|
||||
("books.cash_accounts", json!(["1000", "1010"])),
|
||||
("books.financing_accounts", json!(["2100"])),
|
||||
("books.investing_accounts", json!(["1400"])),
|
||||
("books.close_into", json!("3000")),
|
||||
("books.close_sweep", json!(["3100"])),
|
||||
("books.asset_tag_prefix", json!("WL")),
|
||||
("policy.evidence.threshold", json!("0.00")),
|
||||
("policy.evidence.threshold", json!("25.5")),
|
||||
("policy.evidence.required_accounts", json!(["6200"])),
|
||||
("policy.evidence.exempt_accounts", json!(["6999", "6998"])),
|
||||
("policy.parties", json!("require")),
|
||||
("policy.approval_rules", json!("enforce")),
|
||||
]
|
||||
}
|
||||
|
||||
fn build(picks: &[(usize, bool)]) -> Settings {
|
||||
let pool = candidates();
|
||||
let mut s = Settings::default();
|
||||
for (seq, (i, clear)) in picks.iter().enumerate() {
|
||||
let (k, v) = &pool[i % pool.len()];
|
||||
let change = Change {
|
||||
key: (*k).into(),
|
||||
value: (!clear).then(|| v.clone()),
|
||||
};
|
||||
// Refused changes (settings that wouldn't hold together) are
|
||||
// skipped, as the books skip them.
|
||||
if let Ok(made) = s.check_changes(&[change], &src()) {
|
||||
s.apply(&made, &src(), seq as u64 + 1).unwrap();
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(512))]
|
||||
|
||||
#[test]
|
||||
fn exports_round_trip(picks in proptest::collection::vec((0usize..64, prop::bool::weighted(0.15)), 0..30)) {
|
||||
let books = build(&picks);
|
||||
let text = codec::export(&books, "USD");
|
||||
// INVARIANT 3: the export reads as a settings file.
|
||||
let file = ledger_config::parse(&text)
|
||||
.unwrap_or_else(|e| panic!("the export reads as a settings file: {e}\n{text}"));
|
||||
prop_assert_eq!(&file.company.name, &books.company("USD").name);
|
||||
// INVARIANT 1.
|
||||
let same = codec::import(&text, None, &books, "USD")
|
||||
.unwrap_or_else(|e| panic!("{e}\n{text}"));
|
||||
prop_assert!(same.changes.is_empty(), "{:?}\n{}", same.changes, text);
|
||||
prop_assert!(same.unknown.is_empty(), "{:?}", same.unknown);
|
||||
// INVARIANT 2.
|
||||
let fresh = codec::import(&text, None, &Settings::default(), "USD")
|
||||
.unwrap_or_else(|e| panic!("{e}\n{text}"));
|
||||
let mut other = Settings::default();
|
||||
other.apply(&fresh.changes, &src(), 1).unwrap();
|
||||
for (k, r) in books.iter() {
|
||||
if k.name == "company.ein" {
|
||||
prop_assert!(other.get(k.name).is_none());
|
||||
} else {
|
||||
prop_assert_eq!(other.get(k.name).map(|o| &o.value), Some(&r.value), "{}", k.name);
|
||||
}
|
||||
}
|
||||
prop_assert_eq!(other.iter().count() + usize::from(books.get("company.ein").is_some()), books.iter().count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! The receipt policy in force when the settings come from files:
|
||||
//! config.json's when it sets one, else the settings file's threshold.
|
||||
|
||||
use ledger_config::{BooksSettings, effective_receipt_policy};
|
||||
|
||||
fn dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("ledger-config-policy-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_json_wins_else_the_settings_file() {
|
||||
let d = dir("files");
|
||||
let json = d.join("config.json");
|
||||
let settings = BooksSettings {
|
||||
receipt_threshold: Some("0.00".into()),
|
||||
..BooksSettings::default()
|
||||
};
|
||||
// No config.json: the settings file's threshold.
|
||||
let p = effective_receipt_policy(&json, &settings).unwrap();
|
||||
assert_eq!(p.require_receipts_above, "0.00");
|
||||
// config.json's policy, whole.
|
||||
std::fs::write(
|
||||
&json,
|
||||
r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = effective_receipt_policy(&json, &settings).unwrap();
|
||||
assert_eq!(p.require_receipts_above, "10.00");
|
||||
assert_eq!(p.receipt_exempt_accounts, ["6999"]);
|
||||
// A broken one is refused, by name.
|
||||
std::fs::write(&json, "{ not json").unwrap();
|
||||
let err = effective_receipt_policy(&json, &settings).unwrap_err();
|
||||
assert!(
|
||||
err.starts_with(&format!("bad config {}", json.display())),
|
||||
"{err}"
|
||||
);
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
@@ -12,6 +12,9 @@ thiserror = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# company.timezone: IANA names, with the zone database built in so every
|
||||
# computer reads a name the same way.
|
||||
jiff = { version = "0.2.35", default-features = false, features = ["std", "tz-system", "tzdb-bundle-always"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -163,7 +163,7 @@ impl TaxBasis {
|
||||
self.check_journaled()?;
|
||||
match self.contributor_basis {
|
||||
Some(b) if b > crate::kernel::MAX_LINE_AMOUNT => {
|
||||
Err("a basis is at most 1000000000000000".into())
|
||||
Err("a basis is at most one quadrillion".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -32,7 +32,9 @@ pub enum LedgerError {
|
||||
|
||||
/// A line larger than one quadrillion, or with more than ten decimal
|
||||
/// places, is a mistake, and one that could overflow exact arithmetic.
|
||||
#[error("journal line amount is out of range (at most 1000000000000000, ten decimal places)")]
|
||||
#[error(
|
||||
"journal line amount is out of range (at most one quadrillion, with ten decimal places)"
|
||||
)]
|
||||
AmountOutOfRange,
|
||||
|
||||
/// A date outside the calendar the books keep (`crate::calendar`):
|
||||
@@ -44,7 +46,7 @@ pub enum LedgerError {
|
||||
/// payroll figure, a statement balance, a share, a basis) larger than
|
||||
/// one quadrillion or with more than ten decimal places: a mistake, and
|
||||
/// one that could overflow exact arithmetic.
|
||||
#[error("{0} is out of range (at most 1000000000000000, ten decimal places)")]
|
||||
#[error("{0} is out of range (at most one quadrillion, with ten decimal places)")]
|
||||
ValueOutOfRange(String),
|
||||
|
||||
/// A figure the books derive (a sum, or a share of one) too large for
|
||||
@@ -509,4 +511,36 @@ pub enum LedgerError {
|
||||
/// The referenced class does not exist.
|
||||
#[error("unknown class id {0}")]
|
||||
UnknownClass(u32),
|
||||
|
||||
/// A setting change the books can't honor (crate::settings): an
|
||||
/// unknown key, a value its key refuses, or settings that wouldn't
|
||||
/// hold together.
|
||||
#[error("{0}")]
|
||||
InvalidSetting(String),
|
||||
|
||||
/// A convention, decision, open item or approval the books can't
|
||||
/// take (crate::conventions).
|
||||
#[error("{0}")]
|
||||
InvalidConvention(String),
|
||||
|
||||
/// Approval rules are enforced (`policy.approval_rules = enforce`) and
|
||||
/// rules cover this entry that its approvers haven't approved as it
|
||||
/// stands.
|
||||
#[error(
|
||||
"entry {entry} needs approval before it posts: {missing}; record each with books.approve_entry"
|
||||
)]
|
||||
ApprovalRequired {
|
||||
/// The draft.
|
||||
entry: u64,
|
||||
/// Each rule and its approver, e.g. `over-500 (approver: Pat)`.
|
||||
missing: String,
|
||||
},
|
||||
|
||||
/// Approval rules are enforced, and the entry an operation would post
|
||||
/// at once (an invoice, a bill, a payment, payroll, a disposal) is one
|
||||
/// a rule covers: it has no draft to approve.
|
||||
#[error(
|
||||
"approval rules cover the entry this would post at once ({0}): draft it instead (books.draft_entry), have it approved (books.approve_entry), then post it"
|
||||
)]
|
||||
ApprovalNeedsDraft(String),
|
||||
}
|
||||
@@ -204,6 +204,10 @@ pub struct ReconciliationReport {
|
||||
pub uncleared_lines: Vec<UnclearedLine>,
|
||||
}
|
||||
|
||||
/// What a posting template drafts: its description, and the lines
|
||||
/// `(account, side, amount)` ([`Ledger::template_lines`]).
|
||||
pub type TemplateDraft = (Option<String>, Vec<(AccountId, Side, Money)>);
|
||||
|
||||
/// The books. Single-currency (see R-5); owns id allocation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Ledger {
|
||||
@@ -269,6 +273,11 @@ pub struct Ledger {
|
||||
entry_parties: HashMap<EntryId, Vec<crate::PartyShare>>,
|
||||
/// Entries marked as having no counterparty, with why.
|
||||
entry_no_party: HashMap<EntryId, String>,
|
||||
/// The settings the journal holds ([`crate::settings`]).
|
||||
settings: crate::settings::Settings,
|
||||
/// Conventions, decisions, open items and approvals
|
||||
/// ([`crate::conventions`]).
|
||||
conventions: crate::conventions::Conventions,
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
@@ -312,6 +321,8 @@ impl Ledger {
|
||||
next_party_id: 1,
|
||||
entry_parties: HashMap::new(),
|
||||
entry_no_party: HashMap::new(),
|
||||
settings: crate::settings::Settings::default(),
|
||||
conventions: crate::conventions::Conventions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +644,8 @@ impl Ledger {
|
||||
"entry_no_party",
|
||||
self.entry_no_party == other.entry_no_party,
|
||||
),
|
||||
("settings", self.settings == other.settings),
|
||||
("conventions", self.conventions == other.conventions),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, same)| !same)
|
||||
@@ -850,6 +863,19 @@ impl Ledger {
|
||||
if self.chart.find_by_code(code).is_some() {
|
||||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
||||
}
|
||||
// An approval rule names accounts by code: renumbering one it names
|
||||
// would leave the rule covering nothing (or, later, another
|
||||
// account). It is revised first (crate::conventions INVARIANT 3).
|
||||
if let Some((rule, _)) = self
|
||||
.conventions
|
||||
.approval_rules()
|
||||
.find(|(_, r)| r.as_ref().is_none_or(|r| r.accounts.contains(&a.code)))
|
||||
{
|
||||
return Err(LedgerError::InvalidConvention(format!(
|
||||
"approval rule {} names account {}: retire it (books.retire_convention) or revise it without that account (books.set_convention), renumber, then set the rule again with the new code",
|
||||
rule.id, a.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)
|
||||
@@ -2829,7 +2855,7 @@ impl Ledger {
|
||||
id
|
||||
}
|
||||
};
|
||||
let p = self.parties.get_mut(&id).expect("just found or made");
|
||||
let p = self.parties.get_mut(&id).expect("found or made above");
|
||||
if vendor {
|
||||
p.vendor = Some(record);
|
||||
} else {
|
||||
@@ -4597,6 +4623,533 @@ impl Ledger {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Settings, conventions, decisions, open items, approvals
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// The settings the journal holds ([`crate::settings`]).
|
||||
pub fn settings(&self) -> &crate::settings::Settings {
|
||||
&self.settings
|
||||
}
|
||||
|
||||
/// Check a settings change before it is journaled: every value
|
||||
/// canonical, changes that change nothing left out, the result whole
|
||||
/// (crate::settings INVARIANTS 1, 2, 4), and the fiscal year never
|
||||
/// moved under a closed period. Changes nothing.
|
||||
pub fn check_settings(
|
||||
&self,
|
||||
changes: &[crate::settings::Change],
|
||||
source: &crate::settings::Source,
|
||||
) -> Result<Vec<crate::settings::Change>, LedgerError> {
|
||||
self.check_settings_adopting(changes, source, false)
|
||||
}
|
||||
|
||||
/// [`Self::check_settings`] for books moving a settings file in
|
||||
/// (`adopting`): their first `settings.set` takes the fiscal year they
|
||||
/// were kept under (the import proves it is the file's), so it moves
|
||||
/// nothing, closed periods or not.
|
||||
pub fn check_settings_adopting(
|
||||
&self,
|
||||
changes: &[crate::settings::Change],
|
||||
source: &crate::settings::Source,
|
||||
adopting: bool,
|
||||
) -> Result<Vec<crate::settings::Change>, LedgerError> {
|
||||
let made = self
|
||||
.settings
|
||||
.check_changes(changes, source)
|
||||
.map_err(LedgerError::InvalidSetting)?;
|
||||
// The fiscal year can't move under closed periods: they were
|
||||
// closed by it, and the year's earnings would restate (a tax year
|
||||
// changes only with the IRS's consent, Form 1128). Books moving a
|
||||
// settings file in take the year they were kept under.
|
||||
if !(adopting && !self.settings.is_journaled())
|
||||
&& made.iter().any(|c| c.key == "company.fiscal_year_start")
|
||||
{
|
||||
let mut next = self.settings.clone();
|
||||
next.apply(&made, source, 0)
|
||||
.map_err(LedgerError::InvalidSetting)?;
|
||||
let (before, after) = (
|
||||
self.settings.company("").fiscal_year_start,
|
||||
next.company("").fiscal_year_start,
|
||||
);
|
||||
if before != after
|
||||
&& self
|
||||
.periods
|
||||
.values()
|
||||
.any(|p| p.status == crate::PeriodStatus::Closed)
|
||||
{
|
||||
return Err(LedgerError::InvalidSetting(format!(
|
||||
"company.fiscal_year_start can't change from {:02}-{:02} to {:02}-{:02}: periods are closed under it (a changed tax year needs the IRS's consent, Form 1128; ask the CPA)",
|
||||
before.0, before.1, after.0, after.1
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(made)
|
||||
}
|
||||
|
||||
/// Apply a journaled settings change (`settings.set` at `seq`): as
|
||||
/// checked when made, or as replayed (shape only, rules.md B-28).
|
||||
pub fn apply_settings(
|
||||
&mut self,
|
||||
changes: &[crate::settings::Change],
|
||||
source: &crate::settings::Source,
|
||||
seq: u64,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.settings
|
||||
.apply(changes, source, seq)
|
||||
.map_err(LedgerError::InvalidSetting)
|
||||
}
|
||||
|
||||
/// Conventions, decisions, open items and approvals.
|
||||
pub fn conventions(&self) -> &crate::conventions::Conventions {
|
||||
&self.conventions
|
||||
}
|
||||
|
||||
/// Check a convention before it is journaled: its id, text and
|
||||
/// structure (crate::conventions::check_new), and that every account a
|
||||
/// posting template or approval rule names is in the chart (a
|
||||
/// template's, one that takes postings). Returns it as it will be
|
||||
/// journaled; changes nothing.
|
||||
pub fn check_convention(
|
||||
&self,
|
||||
c: &crate::conventions::NewConvention,
|
||||
) -> Result<crate::conventions::NewConvention, LedgerError> {
|
||||
let bad = LedgerError::InvalidConvention;
|
||||
let c = crate::conventions::check_new(c).map_err(bad)?;
|
||||
let old = self.conventions.convention(&c.id);
|
||||
if old.is_some_and(|o| o.retired.is_some()) {
|
||||
return Err(bad(format!(
|
||||
"convention {} is retired; ids are never reused: choose a new one",
|
||||
c.id
|
||||
)));
|
||||
}
|
||||
if let Some(o) = old.filter(|o| o.kind != c.kind) {
|
||||
return Err(bad(format!(
|
||||
"convention {} is a {}; its kind can't change (retire it and set a new id)",
|
||||
c.id,
|
||||
o.kind.key()
|
||||
)));
|
||||
}
|
||||
let probe = crate::conventions::Convention {
|
||||
id: c.id.clone(),
|
||||
kind: c.kind,
|
||||
title: c.title.clone(),
|
||||
body_md: c.body_md.clone(),
|
||||
structured: c.structured.clone(),
|
||||
source: c.source.clone(),
|
||||
seq: 0,
|
||||
revision: 1,
|
||||
retired: None,
|
||||
};
|
||||
if let Some(t) = probe.template() {
|
||||
for l in t
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|l| crate::conventions::slot(&l.account).is_none())
|
||||
{
|
||||
let a = self.chart.find_by_code(&l.account).ok_or_else(|| {
|
||||
bad(format!(
|
||||
"template account {} is not in the chart",
|
||||
l.account
|
||||
))
|
||||
})?;
|
||||
if a.placeholder {
|
||||
return Err(bad(format!(
|
||||
"template account {} is a placeholder; templates post to accounts that take postings",
|
||||
l.account
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = probe.approval_rule() {
|
||||
for code in &r.accounts {
|
||||
match self.chart.find_by_code(code) {
|
||||
None => {
|
||||
return Err(bad(format!(
|
||||
"approval rule account {code} is not in the chart"
|
||||
)));
|
||||
}
|
||||
Some(a) if a.placeholder => {
|
||||
return Err(bad(format!(
|
||||
"approval rule account {code} is a placeholder (a heading), which no line posts to: name the accounts under it"
|
||||
)));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if old.is_some_and(|o| {
|
||||
o.title == c.title && o.body_md == c.body_md && o.structured == c.structured
|
||||
}) {
|
||||
return Err(bad(format!(
|
||||
"convention {} already says exactly that",
|
||||
c.id
|
||||
)));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Apply a journaled `convention.set` at `seq` (checked when made;
|
||||
/// replay takes it as journaled).
|
||||
pub fn apply_convention(
|
||||
&mut self,
|
||||
c: crate::conventions::NewConvention,
|
||||
seq: u64,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.conventions
|
||||
.apply_convention(c, seq)
|
||||
.map_err(LedgerError::InvalidConvention)
|
||||
}
|
||||
|
||||
/// Check a retirement before it is journaled: the convention is live,
|
||||
/// the reason and source are sound.
|
||||
pub fn check_retire_convention(
|
||||
&self,
|
||||
id: &str,
|
||||
reason: &str,
|
||||
source: &crate::settings::Source,
|
||||
) -> Result<String, LedgerError> {
|
||||
let bad = LedgerError::InvalidConvention;
|
||||
source.check().map_err(bad)?;
|
||||
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
|
||||
.map_err(bad)?;
|
||||
match self.conventions.convention(id) {
|
||||
None => Err(bad(format!(
|
||||
"unknown convention {id:?}: books.conventions lists them"
|
||||
))),
|
||||
Some(c) if c.retired.is_some() => {
|
||||
Err(bad(format!("convention {id} is already retired")))
|
||||
}
|
||||
Some(_) => Ok(reason),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a journaled `convention.retired` at `seq`.
|
||||
pub fn apply_retire_convention(
|
||||
&mut self,
|
||||
id: &str,
|
||||
reason: String,
|
||||
source: crate::settings::Source,
|
||||
seq: u64,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.conventions
|
||||
.apply_retire(id, reason, source, seq)
|
||||
.map_err(LedgerError::InvalidConvention)
|
||||
}
|
||||
|
||||
/// Check a decision before it is journaled; returns it as it will be
|
||||
/// journaled, under the id it will get. Changes nothing. (Its
|
||||
/// evidence attachment is the store's to check.)
|
||||
pub fn check_decision(
|
||||
&self,
|
||||
d: &crate::conventions::NewDecision,
|
||||
) -> Result<(u64, crate::conventions::NewDecision), LedgerError> {
|
||||
let bad = LedgerError::InvalidConvention;
|
||||
let d = crate::conventions::check_decision(d).map_err(bad)?;
|
||||
if let Some(old) = d.supersedes {
|
||||
match self.conventions.decision(old) {
|
||||
None => {
|
||||
return Err(bad(format!(
|
||||
"decision {old} doesn't exist: books.decisions lists them"
|
||||
)));
|
||||
}
|
||||
Some(o) if o.superseded_by.is_some() => {
|
||||
return Err(bad(format!(
|
||||
"decision {old} was already superseded by {}",
|
||||
o.superseded_by.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
Ok((self.conventions.next_decision_id(), d))
|
||||
}
|
||||
|
||||
/// Apply a journaled `decision.recorded` under `id` at `seq`.
|
||||
pub fn apply_decision(
|
||||
&mut self,
|
||||
id: u64,
|
||||
d: crate::conventions::NewDecision,
|
||||
seq: u64,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.conventions
|
||||
.apply_decision(id, d, seq)
|
||||
.map_err(LedgerError::InvalidConvention)
|
||||
}
|
||||
|
||||
/// Check an open item before it is journaled; returns it, under the
|
||||
/// id it will get.
|
||||
pub fn check_open_item(
|
||||
&self,
|
||||
i: &crate::conventions::NewOpenItem,
|
||||
) -> Result<(u64, crate::conventions::NewOpenItem), LedgerError> {
|
||||
let i = crate::conventions::check_open_item(i).map_err(LedgerError::InvalidConvention)?;
|
||||
Ok((self.conventions.next_open_item_id(), i))
|
||||
}
|
||||
|
||||
/// Apply a journaled `open_item.raised` under `id` at `seq`.
|
||||
pub fn apply_open_item(
|
||||
&mut self,
|
||||
id: u64,
|
||||
i: crate::conventions::NewOpenItem,
|
||||
seq: u64,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.conventions
|
||||
.apply_raise(id, i, seq)
|
||||
.map_err(LedgerError::InvalidConvention)
|
||||
}
|
||||
|
||||
/// Check a resolution before it is journaled: the item is open, the
|
||||
/// text and source are sound.
|
||||
pub fn check_resolve_item(
|
||||
&self,
|
||||
id: u64,
|
||||
resolution: &str,
|
||||
source: &crate::settings::Source,
|
||||
) -> Result<String, LedgerError> {
|
||||
let bad = LedgerError::InvalidConvention;
|
||||
source.check().map_err(bad)?;
|
||||
let text =
|
||||
crate::settings::long_text("resolution", resolution, crate::settings::MAX_LONG_TEXT)
|
||||
.map_err(bad)?;
|
||||
match self.conventions.open_item(id) {
|
||||
None => Err(bad(format!(
|
||||
"unknown open item {id}: books.open_items lists them"
|
||||
))),
|
||||
Some(i) if i.resolved.is_some() => {
|
||||
Err(bad(format!("open item {id} is already resolved")))
|
||||
}
|
||||
Some(_) => Ok(text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a journaled `open_item.resolved` at `seq`.
|
||||
pub fn apply_resolve_item(
|
||||
&mut self,
|
||||
id: u64,
|
||||
resolution: String,
|
||||
source: crate::settings::Source,
|
||||
seq: u64,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.conventions
|
||||
.apply_resolve(id, resolution, source, seq)
|
||||
.map_err(LedgerError::InvalidConvention)
|
||||
}
|
||||
|
||||
/// Check an approval before it is journaled: `entry` is a draft, the
|
||||
/// approver is named, the note and source are sound. Returns the
|
||||
/// approval as it will be journaled (its fingerprint is the draft's
|
||||
/// now: crate::conventions INVARIANT 3).
|
||||
pub fn check_approval(
|
||||
&self,
|
||||
entry: EntryId,
|
||||
approver: &str,
|
||||
note: Option<&str>,
|
||||
source: &crate::settings::Source,
|
||||
) -> Result<crate::conventions::Approval, LedgerError> {
|
||||
let bad = LedgerError::InvalidConvention;
|
||||
source.check().map_err(bad)?;
|
||||
let e = self
|
||||
.entries
|
||||
.get(&entry)
|
||||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||||
if e.status() == EntryStatus::Posted {
|
||||
return Err(LedgerError::EntryAlreadyPosted(entry));
|
||||
}
|
||||
Ok(crate::conventions::Approval {
|
||||
approver: crate::settings::line_text("approver", approver, crate::settings::MAX_TEXT)
|
||||
.map_err(bad)?,
|
||||
fingerprint: self.approval_fingerprint(e),
|
||||
note: note
|
||||
.map(|n| crate::settings::long_text("note", n, crate::settings::MAX_LONG_TEXT))
|
||||
.transpose()
|
||||
.map_err(bad)?,
|
||||
source: source.clone(),
|
||||
seq: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a journaled `entry.approved` at `seq` (the entry must exist).
|
||||
pub fn apply_approval(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
approval: crate::conventions::Approval,
|
||||
) -> Result<(), LedgerError> {
|
||||
if !self.entries.contains_key(&entry) {
|
||||
return Err(LedgerError::UnknownEntry(entry));
|
||||
}
|
||||
self.conventions.apply_approval(entry, approval);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An entry's fingerprint for approvals, with who it is with
|
||||
/// (crate::conventions::fingerprint).
|
||||
fn approval_fingerprint(&self, e: &JournalEntry) -> String {
|
||||
crate::conventions::fingerprint(
|
||||
e,
|
||||
self.entry_parties
|
||||
.get(&e.id())
|
||||
.map_or(&[][..], Vec::as_slice),
|
||||
self.entry_no_party.get(&e.id()).map(String::as_str),
|
||||
)
|
||||
}
|
||||
|
||||
/// The approvals `entry` still needs before it posts: each live
|
||||
/// approval rule covering it whose approver hasn't approved it as it
|
||||
/// stands, as `(rule id, approver)`. Empty under
|
||||
/// `policy.approval_rules = off` (crate::conventions INVARIANT 3).
|
||||
pub fn approvals_missing(&self, entry: EntryId) -> Result<Vec<(String, String)>, LedgerError> {
|
||||
if self.settings.approval_rules() == crate::settings::ApprovalMode::Off {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let e = self
|
||||
.entries
|
||||
.get(&entry)
|
||||
.ok_or(LedgerError::UnknownEntry(entry))?;
|
||||
let codes: Vec<&str> = e
|
||||
.lines()
|
||||
.iter()
|
||||
.filter_map(|l| self.chart.get(l.account).map(|a| a.code.as_str()))
|
||||
.collect();
|
||||
let debits = e
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount))
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("entry {entry}'s debits")))?;
|
||||
let approvals = self.conventions.approvals(entry);
|
||||
let key = crate::parties::name_key;
|
||||
let mut missing = Vec::new();
|
||||
for (c, r) in self.conventions.approval_rules() {
|
||||
// A rule this version can't read covers everything (fail
|
||||
// closed): no approver it names can be matched.
|
||||
let Some(r) = r else {
|
||||
missing.push((c.id.clone(), "an approver this version can't read".into()));
|
||||
continue;
|
||||
};
|
||||
let by_account =
|
||||
r.accounts.is_empty() || r.accounts.iter().any(|a| codes.contains(&a.as_str()));
|
||||
let by_amount = r
|
||||
.amount_over
|
||||
.as_deref()
|
||||
.and_then(crate::settings::decimal)
|
||||
.is_none_or(|over| debits > over);
|
||||
let covered = by_account && by_amount;
|
||||
let now = self.approval_fingerprint(e);
|
||||
let approved = approvals
|
||||
.iter()
|
||||
.any(|a| key(&a.approver) == key(&r.approver) && a.fingerprint == now);
|
||||
if covered && !approved {
|
||||
missing.push((c.id.clone(), r.approver.clone()));
|
||||
}
|
||||
}
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// The lines a posting template drafts for `amount` (crate::
|
||||
/// conventions INVARIANT 2): `(account, side, amount)`, each side
|
||||
/// summing to `amount` exactly, and the template's description.
|
||||
/// `slots` names the account (by code) for each of the template's
|
||||
/// slots (`{expense}` → `"expense"`): every slot, and nothing else.
|
||||
pub fn template_lines(
|
||||
&self,
|
||||
template_id: &str,
|
||||
amount: Decimal,
|
||||
slots: &std::collections::BTreeMap<String, String>,
|
||||
) -> Result<TemplateDraft, LedgerError> {
|
||||
let bad = LedgerError::InvalidConvention;
|
||||
let c = self.conventions.convention(template_id).ok_or_else(|| {
|
||||
bad(format!(
|
||||
"unknown convention {template_id:?}: books.conventions lists the templates"
|
||||
))
|
||||
})?;
|
||||
if c.retired.is_some() {
|
||||
return Err(bad(format!("template {template_id} is retired")));
|
||||
}
|
||||
let t = c.template().ok_or_else(|| {
|
||||
bad(format!(
|
||||
"convention {template_id} is a {}, not a posting template",
|
||||
c.kind.key()
|
||||
))
|
||||
})?;
|
||||
let wanted: std::collections::BTreeSet<&str> = t
|
||||
.lines
|
||||
.iter()
|
||||
.filter_map(|l| crate::conventions::slot(&l.account))
|
||||
.collect();
|
||||
let missing: Vec<&str> = wanted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|w| !slots.contains_key(*w))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(bad(format!(
|
||||
"template {template_id} needs an account for {}: pass accounts {{{}}}",
|
||||
missing.join(", "),
|
||||
missing
|
||||
.iter()
|
||||
.map(|m| format!("\"{m}\": \"<code>\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)));
|
||||
}
|
||||
if let Some(extra) = slots.keys().find(|k| !wanted.contains(k.as_str())) {
|
||||
return Err(bad(format!(
|
||||
"template {template_id} has no slot {extra:?}{}",
|
||||
if wanted.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"; its slots: {}",
|
||||
wanted.iter().copied().collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
}
|
||||
)));
|
||||
}
|
||||
if amount <= Decimal::ZERO {
|
||||
return Err(bad(format!("amount {amount} must be above zero")));
|
||||
}
|
||||
if amount > crate::kernel::MAX_LINE_AMOUNT {
|
||||
return Err(LedgerError::AmountOutOfRange);
|
||||
}
|
||||
check_minor_units(Money::new(amount, self.base_currency))?;
|
||||
let places = self.base_currency.minor_units();
|
||||
let mut out = Vec::new();
|
||||
for side in [Side::Debit, Side::Credit] {
|
||||
let lines: Vec<_> = t.lines.iter().filter(|l| l.side == side).collect();
|
||||
let shares: Vec<Decimal> = lines
|
||||
.iter()
|
||||
.map(|l| crate::settings::decimal(&l.share).unwrap_or_default())
|
||||
.collect();
|
||||
let parts = crate::conventions::split(amount, &shares, places).ok_or_else(|| {
|
||||
LedgerError::TooLargeToCompute(format!("template {template_id} at {amount}"))
|
||||
})?;
|
||||
for (l, part) in lines.iter().zip(parts) {
|
||||
if part <= Decimal::ZERO {
|
||||
return Err(bad(format!(
|
||||
"{amount} is too small to split by template {template_id}: account {}'s part would be {part}",
|
||||
l.account
|
||||
)));
|
||||
}
|
||||
let code = match crate::conventions::slot(&l.account) {
|
||||
Some(name) => slots[name].as_str(),
|
||||
None => l.account.as_str(),
|
||||
};
|
||||
let a = self
|
||||
.chart
|
||||
.find_by_code(code)
|
||||
.ok_or_else(|| bad(format!("template account {code} is not in the chart")))?;
|
||||
if a.placeholder {
|
||||
return Err(bad(format!(
|
||||
"account {code} is a placeholder (a heading); a template posts to accounts that take postings"
|
||||
)));
|
||||
}
|
||||
out.push((a.id, side, Money::new(part, self.base_currency)));
|
||||
}
|
||||
}
|
||||
Ok((t.description, out))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Classes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod arap;
|
||||
pub mod assets;
|
||||
pub mod calendar;
|
||||
pub mod classes;
|
||||
pub mod conventions;
|
||||
pub mod error;
|
||||
pub mod form1099;
|
||||
pub mod journal;
|
||||
@@ -38,6 +39,7 @@ mod proofs;
|
||||
pub mod reports;
|
||||
pub mod rules;
|
||||
pub mod schedules;
|
||||
pub mod settings;
|
||||
pub mod shares;
|
||||
pub mod tax;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -130,7 +130,7 @@ pub fn check_shares(shares: &[PartyShare], known: impl Fn(PartyId) -> bool) -> R
|
||||
&& a > crate::kernel::MAX_LINE_AMOUNT
|
||||
{
|
||||
return Err(format!(
|
||||
"party {}'s amount {a} is larger than any entry may move (at most 1000000000000000)",
|
||||
"party {}'s amount {a} is larger than any entry may move (at most one quadrillion)",
|
||||
s.party
|
||||
));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Conventions, approvals and templates in the engine, value by value:
|
||||
//! slots, summaries, template bounds, the approval fingerprint (journaled,
|
||||
//! so its meaning is fixed forever), which entries a rule covers, and
|
||||
//! when a convention is a revision at all.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every bound and every rule says exactly what it means; no more,
|
||||
//! no less.
|
||||
//! SOURCE: plan/served.md P1; P1 mutation run (2026-10-17).
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::{self, Convention, ConventionKind, NewConvention};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{AccountType, Currency, EntryId, Ledger, LedgerError, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "A".into(),
|
||||
decided_on: d(2026, 10, 17),
|
||||
citation: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convention(id: &str, kind: ConventionKind, structured: Option<Value>) -> NewConvention {
|
||||
NewConvention {
|
||||
id: id.into(),
|
||||
kind,
|
||||
title: "Title".into(),
|
||||
body_md: "Body.".into(),
|
||||
structured,
|
||||
source: src(),
|
||||
}
|
||||
}
|
||||
|
||||
fn template(lines: Vec<Value>) -> NewConvention {
|
||||
convention(
|
||||
"t",
|
||||
ConventionKind::PostingTemplate,
|
||||
Some(json!({ "lines": lines })),
|
||||
)
|
||||
}
|
||||
|
||||
fn line(account: &str, side: &str, share: &str) -> Value {
|
||||
json!({"account": account, "side": side, "share": share})
|
||||
}
|
||||
|
||||
fn refusal(c: &NewConvention) -> String {
|
||||
conventions::check_new(c).expect_err("refused")
|
||||
}
|
||||
|
||||
/// A slot is a name in braces: lowercase letters, digits and `_`, 1 to 32
|
||||
/// of them.
|
||||
#[test]
|
||||
fn a_slot_is_a_short_lowercase_name_in_braces() {
|
||||
for (account, want) in [
|
||||
("{expense}", Some("expense")),
|
||||
("{a_1}", Some("a_1")),
|
||||
("{_}", Some("_")),
|
||||
("{7}", Some("7")),
|
||||
(
|
||||
"{abcdefghijklmnopqrstuvwxyz012345}",
|
||||
Some("abcdefghijklmnopqrstuvwxyz012345"),
|
||||
),
|
||||
("{abcdefghijklmnopqrstuvwxyz0123456}", None),
|
||||
("{}", None),
|
||||
("{Expense}", None),
|
||||
("{a-b}", None),
|
||||
("{a b}", None),
|
||||
("expense", None),
|
||||
("{expense", None),
|
||||
("6000", None),
|
||||
] {
|
||||
assert_eq!(conventions::slot(account), want, "{account}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A summary is the first line with text, trimmed, at most 160
|
||||
/// characters, with an ellipsis only when it was cut.
|
||||
#[test]
|
||||
fn a_summary_is_the_first_line_with_text() {
|
||||
let summary = |body: &str| {
|
||||
Convention {
|
||||
id: "c".into(),
|
||||
kind: ConventionKind::Note,
|
||||
title: "T".into(),
|
||||
body_md: body.into(),
|
||||
structured: None,
|
||||
source: src(),
|
||||
seq: 1,
|
||||
revision: 1,
|
||||
retired: None,
|
||||
}
|
||||
.summary()
|
||||
};
|
||||
assert_eq!(summary("\n \n First line \nsecond"), "First line");
|
||||
assert_eq!(summary(""), "");
|
||||
assert_eq!(summary("\n \n"), "");
|
||||
let exactly = "é".repeat(160);
|
||||
assert_eq!(summary(&exactly), exactly, "160 characters: whole");
|
||||
let over = "é".repeat(161);
|
||||
assert_eq!(summary(&over), format!("{exactly}…"), "161: cut");
|
||||
assert_eq!(summary(&format!(" {exactly} ")), exactly, "trimmed first");
|
||||
}
|
||||
|
||||
/// A posting template has 2 to 50 lines, each share in (0, 1], each
|
||||
/// side summing to exactly 1; a refusal names the line.
|
||||
#[test]
|
||||
fn template_bounds_are_exact() {
|
||||
// 50 lines: one debit, 49 credits summing to 1.
|
||||
let mut fifty = vec![line("6000", "debit", "1"), line("1000", "credit", "0.04")];
|
||||
fifty.extend((0..48).map(|_| line("1000", "credit", "0.02")));
|
||||
assert_eq!(fifty.len(), 50);
|
||||
conventions::check_new(&template(fifty)).expect("50 lines");
|
||||
let mut fifty_one = vec![line("6000", "debit", "1")];
|
||||
fifty_one.extend((0..50).map(|_| line("1000", "credit", "0.02")));
|
||||
assert!(refusal(&template(fifty_one)).contains("2 to 50 lines"));
|
||||
assert!(refusal(&template(vec![])).contains("2 to 50 lines"));
|
||||
// One line can't balance: refused by its count, not its shares.
|
||||
assert!(refusal(&template(vec![line("6000", "debit", "1")])).contains("2 to 50 lines"));
|
||||
conventions::check_new(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "1"),
|
||||
]))
|
||||
.expect("2 lines");
|
||||
// A zero share is no share.
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("6000", "debit", "0"),
|
||||
line("1000", "credit", "1"),
|
||||
]));
|
||||
assert!(e.starts_with("template line 2: share \"0\""), "{e}");
|
||||
// Over 1, or below zero, even when the side still sums to 1.
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1.5"),
|
||||
line("6000", "debit", "-0.5"),
|
||||
line("1000", "credit", "1"),
|
||||
]));
|
||||
assert!(e.starts_with("template line 1: share \"1.5\""), "{e}");
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "1.5"),
|
||||
line("1000", "credit", "-0.5"),
|
||||
]));
|
||||
assert!(e.starts_with("template line 2: share \"1.5\""), "{e}");
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "0.5"),
|
||||
]));
|
||||
assert!(e.contains("sum to exactly 1"), "{e}");
|
||||
}
|
||||
|
||||
/// FNV-1a, 64 bits, written out here independently of the engine.
|
||||
fn fnv1a(text: &str) -> String {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in text.bytes() {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
/// The approval fingerprint is journaled with every approval and
|
||||
/// recomputed at post: its text and hash are fixed forever.
|
||||
#[test]
|
||||
fn the_fingerprint_is_fixed_forever() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let exp = l
|
||||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let e = l.create_entry(d(2026, 10, 2), "Laptop");
|
||||
let m = Money::new(dec("900.50"), Currency::USD);
|
||||
l.add_line(e, exp, Side::Debit, m).unwrap();
|
||||
l.add_line(e, bank, Side::Credit, m).unwrap();
|
||||
let entry = l.get_entry(e).unwrap();
|
||||
let text = format!(
|
||||
"2026-10-02\u{1f}Laptop\
|
||||
\u{1e}{exp}\u{1f}D\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}\
|
||||
\u{1e}{bank}\u{1f}C\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}"
|
||||
);
|
||||
let plain = conventions::fingerprint(entry, &[], None);
|
||||
assert_eq!(plain, fnv1a(&text));
|
||||
assert_eq!(fnv1a(""), "cbf29ce484222325", "FNV-1a's offset basis");
|
||||
assert_eq!(fnv1a("a"), "af63dc4c8601ec8c", "FNV-1a's published value");
|
||||
let none = conventions::fingerprint(entry, &[], Some("bank fee"));
|
||||
let with_none = format!(
|
||||
"2026-10-02\u{1f}Laptop\u{1d}none\u{1f}bank fee\
|
||||
\u{1e}{exp}\u{1f}D\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}\
|
||||
\u{1e}{bank}\u{1f}C\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}"
|
||||
);
|
||||
assert_eq!(none, fnv1a(&with_none));
|
||||
assert_ne!(none, plain);
|
||||
}
|
||||
|
||||
/// Books enforcing approval rules, with an expense and a bank account.
|
||||
fn enforcing() -> (Ledger, ledger_core::AccountId, ledger_core::AccountId) {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let exp = l
|
||||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let on = [Change {
|
||||
key: "policy.approval_rules".into(),
|
||||
value: Some(json!("enforce")),
|
||||
}];
|
||||
let made = l.check_settings(&on, &src()).unwrap();
|
||||
l.apply_settings(&made, &src(), 1).unwrap();
|
||||
(l, exp, bank)
|
||||
}
|
||||
|
||||
fn set_convention(l: &mut Ledger, c: &NewConvention, seq: u64) {
|
||||
let c = l.check_convention(c).unwrap();
|
||||
l.apply_convention(c, seq).unwrap();
|
||||
}
|
||||
|
||||
fn draft(l: &mut Ledger, lines: &[(ledger_core::AccountId, Side, &str)]) -> EntryId {
|
||||
let e = l.create_entry(d(2026, 10, 2), "Spend");
|
||||
for (a, side, amount) in lines {
|
||||
l.add_line(e, *a, *side, Money::new(dec(amount), Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// A rule's amount is "over": an entry whose debits are exactly it isn't
|
||||
/// covered; one a cent over is. It counts debits, not credits, even on a
|
||||
/// draft that doesn't balance yet.
|
||||
#[test]
|
||||
fn a_rule_covers_debits_strictly_over_its_amount() {
|
||||
let (mut l, exp, bank) = enforcing();
|
||||
set_convention(
|
||||
&mut l,
|
||||
&convention(
|
||||
"big",
|
||||
ConventionKind::ApprovalRule,
|
||||
Some(json!({"approver": "B", "amount_over": "500"})),
|
||||
),
|
||||
2,
|
||||
);
|
||||
let want = vec![("big".to_string(), "B".to_string())];
|
||||
let at = draft(
|
||||
&mut l,
|
||||
&[(exp, Side::Debit, "500"), (bank, Side::Credit, "500")],
|
||||
);
|
||||
assert!(l.approvals_missing(at).unwrap().is_empty(), "exactly 500");
|
||||
let over = draft(
|
||||
&mut l,
|
||||
&[(exp, Side::Debit, "500.01"), (bank, Side::Credit, "500.01")],
|
||||
);
|
||||
assert_eq!(l.approvals_missing(over).unwrap(), want);
|
||||
let debits = draft(&mut l, &[(exp, Side::Debit, "600")]);
|
||||
assert_eq!(l.approvals_missing(debits).unwrap(), want, "debits count");
|
||||
let credits = draft(&mut l, &[(bank, Side::Credit, "600")]);
|
||||
assert!(
|
||||
l.approvals_missing(credits).unwrap().is_empty(),
|
||||
"credits don't"
|
||||
);
|
||||
}
|
||||
|
||||
/// What check_settings returns is what will be journaled.
|
||||
#[test]
|
||||
fn check_settings_returns_the_changes_it_will_journal() {
|
||||
let l = Ledger::new(Currency::USD);
|
||||
let name = Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!(" Acme LLC ")),
|
||||
};
|
||||
let made = l.check_settings(&[name], &src()).unwrap();
|
||||
assert_eq!(
|
||||
made,
|
||||
[Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!("Acme LLC")),
|
||||
}]
|
||||
);
|
||||
assert!(!l.settings().is_journaled(), "checking changes nothing");
|
||||
}
|
||||
|
||||
/// A convention set again unchanged is refused; a change to its title,
|
||||
/// its body or its structure alone is a revision.
|
||||
#[test]
|
||||
fn any_one_change_is_a_revision() {
|
||||
let (mut l, _, _) = enforcing();
|
||||
let base = convention(
|
||||
"big",
|
||||
ConventionKind::ApprovalRule,
|
||||
Some(json!({"approver": "B", "amount_over": "500"})),
|
||||
);
|
||||
set_convention(&mut l, &base, 2);
|
||||
let e = l.check_convention(&base).unwrap_err();
|
||||
assert!(
|
||||
matches!(&e, LedgerError::InvalidConvention(m) if m.contains("already says exactly that")),
|
||||
"{e}"
|
||||
);
|
||||
let title = NewConvention {
|
||||
title: "New title".into(),
|
||||
..base.clone()
|
||||
};
|
||||
let body = NewConvention {
|
||||
body_md: "New body.".into(),
|
||||
..base.clone()
|
||||
};
|
||||
let structured = NewConvention {
|
||||
structured: Some(json!({"approver": "B", "amount_over": "600"})),
|
||||
..base.clone()
|
||||
};
|
||||
for c in [title, body, structured] {
|
||||
assert!(l.check_convention(&c).is_ok(), "{c:?}");
|
||||
}
|
||||
let revised = NewConvention {
|
||||
title: "New title".into(),
|
||||
..base.clone()
|
||||
};
|
||||
set_convention(&mut l, &revised, 3);
|
||||
assert_eq!(l.conventions().convention("big").unwrap().revision, 2);
|
||||
}
|
||||
|
||||
/// A template drafts any amount up to a line's most, and refuses above
|
||||
/// it as out of range.
|
||||
#[test]
|
||||
fn a_template_drafts_up_to_a_lines_most() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
for (code, t) in [("6000", AccountType::Expense), ("1000", AccountType::Asset)] {
|
||||
l.add_account(code, code, t, None, false).unwrap();
|
||||
}
|
||||
set_convention(
|
||||
&mut l,
|
||||
&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "1"),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
let max = ledger_core::kernel::MAX_LINE_AMOUNT;
|
||||
let (_, lines) = l.template_lines("t", max, &BTreeMap::new()).unwrap();
|
||||
assert!(lines.iter().all(|(_, _, m)| m.amount == max));
|
||||
assert!(matches!(
|
||||
l.template_lines("t", max + dec("0.01"), &BTreeMap::new()),
|
||||
Err(LedgerError::AmountOutOfRange)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The settings registry's small parts, value by value: each accessor, each
|
||||
//! check boundary, each tax classification, each masked or joined display.
|
||||
//! `settings.rs`'s own tests cover the invariants; these pin the exact
|
||||
//! outputs the server and viewer rely on (P1 mutation testing,
|
||||
//! 2026-10-17).
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::settings::{
|
||||
Change, Company, Member, PartyPolicy, Settings, Source, check, display,
|
||||
form_for_classification, holds_printed_tin, key, line_text, long_text, parse_month_day,
|
||||
parse_share, shape, today_in,
|
||||
};
|
||||
use ledger_core::{AccountType, Currency, Ledger, PartyField, Role, TaxForm};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::str::FromStr;
|
||||
|
||||
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: date(2026, 10, 17),
|
||||
citation: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings holding `pairs`, checked then applied as one change.
|
||||
fn settings(pairs: &[(&str, Value)]) -> Settings {
|
||||
let changes: Vec<Change> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| Change {
|
||||
key: (*k).into(),
|
||||
value: Some(v.clone()),
|
||||
})
|
||||
.collect();
|
||||
let mut s = Settings::default();
|
||||
let checked = s.check_changes(&changes, &src()).unwrap();
|
||||
s.apply(&checked, &src(), 1).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn checked(name: &str, v: Value) -> Result<Value, String> {
|
||||
check(key(name).unwrap(), &v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_return_what_was_set() {
|
||||
let s = settings(&[
|
||||
("policy.evidence.threshold", json!("0.00")),
|
||||
("policy.evidence.required_accounts", json!(["1000", "2050"])),
|
||||
("policy.evidence.exempt_accounts", json!(["6900"])),
|
||||
("books.cash_accounts", json!(["1000", "1010"])),
|
||||
("books.financing_accounts", json!(["2500"])),
|
||||
("books.investing_accounts", json!(["1500", "1510"])),
|
||||
("books.asset_tag_prefix", json!("WL")),
|
||||
("policy.parties", json!("off")),
|
||||
("books.close_sweep", json!(["3100"])),
|
||||
]);
|
||||
assert_eq!(s.receipt_threshold(), Some("0.00"));
|
||||
assert_eq!(s.receipt_required_accounts(), ["1000", "2050"]);
|
||||
assert_eq!(s.receipt_exempt_accounts(), ["6900"]);
|
||||
assert_eq!(s.cash_accounts(), ["1000", "1010"]);
|
||||
assert_eq!(s.financing_accounts(), ["2500"]);
|
||||
assert_eq!(s.investing_accounts(), ["1500", "1510"]);
|
||||
assert_eq!(s.asset_tag_prefix(), Some("WL"));
|
||||
assert_eq!(s.parties(), PartyPolicy::Off);
|
||||
assert_eq!(s.company("USD").close_sweep, ["3100"]);
|
||||
let s = settings(&[("policy.parties", json!("require"))]);
|
||||
assert_eq!(s.parties(), PartyPolicy::Require);
|
||||
// Unset: nothing, and the default policy.
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.receipt_threshold(), None);
|
||||
assert!(s.cash_accounts().is_empty());
|
||||
assert_eq!(s.asset_tag_prefix(), None);
|
||||
assert_eq!(s.parties(), PartyPolicy::Suggest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_company_profile_is_its_company_keys_name_first() {
|
||||
let s = settings(&[
|
||||
("company.ein", json!("12-3456789")),
|
||||
("company.name", json!("Acme LLC")),
|
||||
("company.duns", json!("149976972")),
|
||||
(
|
||||
"company.members",
|
||||
json!([
|
||||
{"name": "Kat", "share": "50%", "capital_account": "3001"},
|
||||
{"name": "Stephen", "share": "50%", "capital_account": "3000"},
|
||||
]),
|
||||
),
|
||||
("books.accounting_basis", json!("cash")),
|
||||
("books.asset_tag_prefix", json!("WL")),
|
||||
]);
|
||||
let c = s.company("USD");
|
||||
let profile: Vec<(&str, &str)> = c
|
||||
.profile
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
profile,
|
||||
[
|
||||
("name", "Acme LLC"),
|
||||
("base_currency", "USD"),
|
||||
("ein", "12-3456789"),
|
||||
("duns", "149976972"),
|
||||
]
|
||||
);
|
||||
assert_eq!(c.name, "Acme LLC");
|
||||
assert_eq!(c.basis, "Cash");
|
||||
assert_eq!(c.members.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn party_policy_spellings() {
|
||||
assert_eq!(PartyPolicy::Off.key(), "off");
|
||||
assert_eq!(PartyPolicy::Suggest.key(), "suggest");
|
||||
assert_eq!(PartyPolicy::Require.key(), "require");
|
||||
assert_eq!(PartyPolicy::parse("off"), Some(PartyPolicy::Off));
|
||||
assert_eq!(PartyPolicy::parse("suggest"), Some(PartyPolicy::Suggest));
|
||||
assert_eq!(PartyPolicy::parse("require"), Some(PartyPolicy::Require));
|
||||
assert_eq!(PartyPolicy::parse("required"), Some(PartyPolicy::Require));
|
||||
assert_eq!(PartyPolicy::parse("Require"), None);
|
||||
assert_eq!(PartyPolicy::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fiscal_year_bounds() {
|
||||
let c = Company {
|
||||
fiscal_year_start: (7, 1),
|
||||
..Company::default()
|
||||
};
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(date(2026, 3, 15)),
|
||||
(date(2025, 7, 1), date(2026, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(date(2026, 6, 30)),
|
||||
(date(2025, 7, 1), date(2026, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(date(2026, 7, 1)),
|
||||
(date(2026, 7, 1), date(2027, 6, 30))
|
||||
);
|
||||
let calendar = Company::default();
|
||||
assert_eq!(
|
||||
calendar.fiscal_year_containing(date(2024, 2, 29)),
|
||||
(date(2024, 1, 1), date(2024, 12, 31))
|
||||
);
|
||||
}
|
||||
|
||||
/// A member's party is the live one of their name, else the most recent
|
||||
/// archived one of their name: never another archived member's.
|
||||
#[test]
|
||||
fn party_members_find_each_members_own_party() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let capital = l
|
||||
.add_account("3001", "Capital — Kat", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
l.add_account(
|
||||
"3000",
|
||||
"Capital — Stephen",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let kat = l.add_party("Kat", &[Role::Member]).unwrap();
|
||||
let other = l.add_party("Pat", &[Role::Member]).unwrap();
|
||||
l.update_party(kat, PartyField::Archived(true)).unwrap();
|
||||
l.update_party(other, PartyField::Archived(true)).unwrap();
|
||||
let c = Company {
|
||||
members: vec![Member {
|
||||
name: "kat ".into(),
|
||||
share: Decimal::ONE,
|
||||
capital_account: "3001".into(),
|
||||
distribution_account: None,
|
||||
files_jointly_with: None,
|
||||
}],
|
||||
..Company::default()
|
||||
};
|
||||
let got = c.party_members(&l);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].party, kat);
|
||||
assert_eq!(got[0].capital, capital);
|
||||
assert_eq!(got[0].share, Decimal::ONE);
|
||||
assert_eq!(got[0].distributions, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printed_tin_joiners() {
|
||||
// Three spaces is not a joiner; a 3-6 split is no TIN's shape; two
|
||||
// dashes is not one joiner.
|
||||
assert!(!holds_printed_tin("123 45 6789"));
|
||||
assert!(!holds_printed_tin("123 456789"));
|
||||
assert!(!holds_printed_tin("123--45--6789"));
|
||||
// Two spaces, and a dash with a space each side, are.
|
||||
assert!(holds_printed_tin("123 45 6789"));
|
||||
assert!(holds_printed_tin("123 - 45 - 6789"));
|
||||
assert!(holds_printed_tin("12 3456789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_length_boundaries() {
|
||||
assert_eq!(line_text("x", "aaaaa", 5).unwrap(), "aaaaa");
|
||||
assert_eq!(
|
||||
line_text("x", "aaaaaa", 5).unwrap_err(),
|
||||
"x is longer than 5 characters"
|
||||
);
|
||||
assert_eq!(long_text("x", "aaaaa", 5).unwrap(), "aaaaa");
|
||||
assert_eq!(
|
||||
long_text("x", "aaaaaa", 5).unwrap_err(),
|
||||
"x is longer than 5 characters"
|
||||
);
|
||||
assert_eq!(long_text("x", "a\nb\tc", 10).unwrap(), "a\nb\tc");
|
||||
assert_eq!(
|
||||
long_text("x", "a\u{7}b", 10).unwrap_err(),
|
||||
"x holds a control character"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_boundaries() {
|
||||
assert_eq!(parse_share("K", "1").unwrap(), Decimal::ONE);
|
||||
assert_eq!(parse_share("K", "100%").unwrap(), Decimal::ONE);
|
||||
assert_eq!(parse_share("K", "0.5").unwrap(), dec("0.5"));
|
||||
assert_eq!(parse_share("K", "50%").unwrap(), dec("0.5"));
|
||||
assert_eq!(parse_share("K", "0.0001").unwrap(), dec("0.0001"));
|
||||
for bad in ["0", "0%", "-0.5", "1.0001", "101%", "2"] {
|
||||
assert_eq!(
|
||||
parse_share("K", bad).unwrap_err(),
|
||||
format!("member K: share {bad:?} must be like \"50%\" or \"0.5\""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_day_digits_only() {
|
||||
assert_eq!(parse_month_day("07-01").unwrap(), (7, 1));
|
||||
for bad in ["+1-01", "01-+1", "-01", "01-"] {
|
||||
assert_eq!(
|
||||
parse_month_day(bad).unwrap_err(),
|
||||
format!("fiscal_year_start {bad:?}: expected \"MM-DD\", e.g. \"01-01\""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_classification_names_its_return() {
|
||||
use TaxForm::*;
|
||||
for (c, want) in [
|
||||
("partnership", Some(Form1065)),
|
||||
("General Partnership", Some(Form1065)),
|
||||
("S corporation", Some(Form1120S)),
|
||||
("S-corp", Some(Form1120S)),
|
||||
("s corp", Some(Form1120S)),
|
||||
("scorp", Some(Form1120S)),
|
||||
("C corporation", Some(Form1120)),
|
||||
("ccorp", Some(Form1120)),
|
||||
("corporation", Some(Form1120)),
|
||||
("corp", Some(Form1120)),
|
||||
("association", Some(Form1120)),
|
||||
("disregarded entity", Some(ScheduleC)),
|
||||
("sole proprietor", Some(ScheduleC)),
|
||||
("single-member LLC", Some(ScheduleC)),
|
||||
("member single", None),
|
||||
("corporation or S corporation", None),
|
||||
("partnership or disregarded", None),
|
||||
("LLC", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(form_for_classification(c), want, "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_and_choices_keep_their_spelling() {
|
||||
assert_eq!(
|
||||
checked("books.close_into", json!(" 3000 ")),
|
||||
Ok(json!("3000"))
|
||||
);
|
||||
assert_eq!(
|
||||
checked("books.cash_accounts", json!([" 1000", "1010 "])),
|
||||
Ok(json!(["1000", "1010"]))
|
||||
);
|
||||
for (k, v) in [
|
||||
("books.accounting_basis", "accrual"),
|
||||
("books.accounting_basis", "cash"),
|
||||
("policy.parties", "off"),
|
||||
("policy.parties", "suggest"),
|
||||
("policy.parties", "require"),
|
||||
("policy.approval_rules", "off"),
|
||||
("policy.approval_rules", "enforce"),
|
||||
] {
|
||||
assert_eq!(checked(k, json!(v)), Ok(json!(v)), "{k} {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amount_bound_is_inclusive() {
|
||||
let max = ledger_core::kernel::MAX_LINE_AMOUNT.to_string();
|
||||
assert_eq!(
|
||||
checked("policy.evidence.threshold", json!(max)),
|
||||
Ok(json!(max))
|
||||
);
|
||||
let over = format!("{max}.01");
|
||||
assert_eq!(
|
||||
checked("policy.evidence.threshold", json!(over)),
|
||||
Err(format!(
|
||||
"policy.evidence.threshold {over:?} is out of range"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ein_shapes() {
|
||||
let ein = |s: &str| checked("company.ein", json!(s));
|
||||
assert_eq!(ein("12-3456789"), Ok(json!("12-3456789")));
|
||||
assert_eq!(ein("123456789"), Ok(json!("12-3456789")));
|
||||
let refused = Err("company.ein: expected an EIN, NN-NNNNNNN".to_string());
|
||||
for bad in [
|
||||
"12345678",
|
||||
"12-34-567",
|
||||
"12345678x",
|
||||
"123-456789",
|
||||
"1-23456789",
|
||||
] {
|
||||
assert_eq!(ein(bad), refused, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_control_and_tag_prefix_bounds() {
|
||||
let nc = |s: &str| checked("company.irs_name_control", json!(s));
|
||||
for ok in ["WAND", "A&B-", "A&", "A-B", "w"] {
|
||||
assert_eq!(nc(ok), Ok(json!(ok.to_uppercase())), "{ok:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
nc("WANDE"),
|
||||
Err("company.irs_name_control \"WANDE\": one to four letters, digits, & or -".into())
|
||||
);
|
||||
assert!(nc("W.A").is_err());
|
||||
let tag = |s: &str| checked("books.asset_tag_prefix", json!(s));
|
||||
assert_eq!(tag("WL"), Ok(json!("WL")));
|
||||
assert_eq!(tag("ABCDEFGH"), Ok(json!("ABCDEFGH")));
|
||||
assert_eq!(
|
||||
tag("ABCDEFGHI"),
|
||||
Err("books.asset_tag_prefix \"ABCDEFGHI\": 1 to 8 letters or digits".into())
|
||||
);
|
||||
assert!(tag("").is_err());
|
||||
assert!(tag("W-L").is_err());
|
||||
}
|
||||
|
||||
/// Replay checks an amount and a fiscal year start by their shape.
|
||||
#[test]
|
||||
fn replay_shapes_amounts_and_month_starts() {
|
||||
let threshold = key("policy.evidence.threshold").unwrap();
|
||||
assert!(shape(threshold, &json!("25.00")).is_ok());
|
||||
assert_eq!(
|
||||
shape(threshold, &json!("abc")),
|
||||
Err("policy.evidence.threshold \"abc\" is not a decimal".into())
|
||||
);
|
||||
let fy = key("company.fiscal_year_start").unwrap();
|
||||
assert!(shape(fy, &json!("07-15")).is_ok());
|
||||
for bad in ["13-01", "02-30", "07"] {
|
||||
assert_eq!(
|
||||
shape(fy, &json!(bad)),
|
||||
Err(format!("fiscal_year_start {bad:?}: expected \"MM-DD\"")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn today_is_the_companys_today() {
|
||||
let utc = chrono::Utc::now().date_naive();
|
||||
let near = |d: NaiveDate| (d - utc).num_days().abs() <= 1;
|
||||
let s = settings(&[("company.timezone", json!("America/Denver"))]);
|
||||
assert!(near(s.today()), "{}", s.today());
|
||||
assert!(near(today_in(None)));
|
||||
// UTC+14 and UTC-11 are 25 hours apart: never the same day.
|
||||
let east = today_in(Some("Pacific/Kiritimati"));
|
||||
let west = today_in(Some("Pacific/Pago_Pago"));
|
||||
assert!(near(east) && near(west));
|
||||
assert!(east > west, "{east} {west}");
|
||||
assert!(east.year() >= 2026);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_joins_members_and_lists() {
|
||||
let members = key("company.members").unwrap();
|
||||
let v = json!([
|
||||
{"name": "Kat", "share": "0.5", "capital_account": "3001"},
|
||||
{"name": "Stephen", "share": "0.5", "capital_account": "3000"},
|
||||
]);
|
||||
assert_eq!(display(members, &v, false), "Kat 0.5, Stephen 0.5");
|
||||
let cash = key("books.cash_accounts").unwrap();
|
||||
assert_eq!(display(cash, &json!(["1000", "1010"]), false), "1000, 1010");
|
||||
let ein = key("company.ein").unwrap();
|
||||
assert_eq!(display(ein, &json!("12-3456789"), false), "**-***6789");
|
||||
assert_eq!(display(ein, &json!("12-3456789"), true), "12-3456789");
|
||||
}
|
||||
|
||||
/// A date is taken only as written YYYY-MM-DD, one spelling per day.
|
||||
#[test]
|
||||
fn dates_take_one_spelling() {
|
||||
let formed = |s: &str| checked("company.formed", json!(s));
|
||||
assert_eq!(formed("2026-10-04"), Ok(json!("2026-10-04")));
|
||||
assert_eq!(formed(" 2026-10-04 "), Ok(json!("2026-10-04")));
|
||||
for bad in ["2026-1-4", "2026-10-4", "+2026-10-04"] {
|
||||
assert_eq!(
|
||||
formed(bad),
|
||||
Err(format!(
|
||||
"company.formed {bad:?}: expected a date, YYYY-MM-DD"
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -772,7 +772,7 @@ fn a_negative_amount_line_never_posts() {
|
||||
assert_eq!(b.l.posted_entries().count(), 0);
|
||||
}
|
||||
|
||||
/// Oracle: AmountOutOfRange doc ("at most 1000000000000000, ten decimal
|
||||
/// Oracle: AmountOutOfRange doc ("at most one quadrillion, with ten decimal
|
||||
/// places"): exactly one quadrillion and exactly ten places post; one cent
|
||||
/// more, or an eleventh place, never does.
|
||||
#[test]
|
||||
|
||||
@@ -540,12 +540,17 @@ impl Books {
|
||||
let ledger = snap.ledger().clone();
|
||||
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
||||
let journal = snap.journal().to_vec();
|
||||
// The server's own rule (config.json, else wanderbooks.toml), so
|
||||
// the viewer flags exactly what the server would refuse.
|
||||
let policy = ledger_config::effective_receipt_policy(
|
||||
&snap.data_dir().join("config.json"),
|
||||
&settings,
|
||||
)?;
|
||||
// The server's own rule (the books' settings when they keep them,
|
||||
// else config.json, else wanderbooks.toml), so the viewer flags
|
||||
// exactly what the server would refuse.
|
||||
let policy = if ledger.settings().is_journaled() {
|
||||
ledger_config::codec::receipt_policy(ledger.settings())
|
||||
} else {
|
||||
ledger_config::effective_receipt_policy(
|
||||
&snap.data_dir().join("config.json"),
|
||||
&settings,
|
||||
)?
|
||||
};
|
||||
let receipt_threshold = policy.threshold();
|
||||
let cash_accounts = cash_accounts(&ledger, &settings);
|
||||
let members = settings.company.party_members(&ledger);
|
||||
@@ -1516,22 +1521,21 @@ impl Books {
|
||||
/// INVARIANT 1: read only on demand (a click), never on load or paint.
|
||||
/// INVARIANT 2: never creates a key file; a missing key is a locked
|
||||
/// vault ("restore it from your password manager").
|
||||
/// INVARIANT 3: the key file in force is the server's: [vault]
|
||||
/// key_file, else ~/.config/wanderbooks/vault.key; one inside the
|
||||
/// books dir is refused, as the server refuses it.
|
||||
/// INVARIANT 3: the key file in force is the server's: the one given
|
||||
/// on the command line (--vault-key-file), else a settings file's
|
||||
/// [vault] key_file, else ~/.config/wanderbooks/vault.key; one
|
||||
/// inside the books dir is refused, as the server refuses it.
|
||||
/// INVARIANT 4: an error never carries the digits (the vault's errors
|
||||
/// are redacted); a party with no record is "not in the vault".
|
||||
/// ```
|
||||
pub fn tin_from_vault(&self, party: PartyId) -> Result<String, String> {
|
||||
let key = self
|
||||
.settings
|
||||
.vault_key_file
|
||||
.clone()
|
||||
let key = crate::vault_key_file()
|
||||
.or_else(|| self.settings.vault_key_file.clone())
|
||||
.unwrap_or_else(ledger_vault::default_key_file);
|
||||
let books = self.books_dir();
|
||||
if resolved(&key).starts_with(resolved(&books)) {
|
||||
return Err(format!(
|
||||
"The vault key file {} is inside the books folder: move it out and set [vault] key_file.",
|
||||
"The vault key file {} is inside the books folder: move it out, and name its new place to the server and the viewer (--vault-key-file).",
|
||||
key.display()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -68,7 +68,9 @@ pub fn load_once(source: &Source) -> Result<Books, String> {
|
||||
})
|
||||
})?;
|
||||
let integrity = Integrity::check(&snap, &mut HashSet::new());
|
||||
Books::build(&snap, settings(source)?, today(source), None, integrity)
|
||||
let settings = settings(source, &snap)?;
|
||||
let today = today(source, &settings);
|
||||
Books::build(&snap, settings, today, None, integrity)
|
||||
}
|
||||
|
||||
/// What the viewer says when the books can't be read. Books from a newer
|
||||
@@ -88,13 +90,25 @@ pub fn problem(e: &StoreError, context: impl FnOnce() -> String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn today(source: &Source) -> NaiveDate {
|
||||
/// Today: as fixed for tests, else in the company's time zone, else the
|
||||
/// computer's.
|
||||
fn today(source: &Source, settings: &BooksSettings) -> NaiveDate {
|
||||
source
|
||||
.today
|
||||
.unwrap_or_else(|| chrono::Local::now().date_naive())
|
||||
.unwrap_or_else(|| ledger_core::settings::today_in(settings.timezone.as_deref()))
|
||||
}
|
||||
|
||||
fn settings(source: &Source) -> Result<BooksSettings, String> {
|
||||
/// The settings: the books' own when they keep them (the settings file is
|
||||
/// then never read, as the server never reads it), else the settings
|
||||
/// file's.
|
||||
fn settings(source: &Source, snap: &Snapshot) -> Result<BooksSettings, String> {
|
||||
let books = snap.ledger().settings();
|
||||
if books.is_journaled() {
|
||||
return Ok(ledger_config::codec::books_settings(
|
||||
books,
|
||||
snap.ledger().base_currency().code(),
|
||||
));
|
||||
}
|
||||
match &source.books_toml {
|
||||
Some(p) if p.exists() => ledger_config::load(p),
|
||||
_ => Ok(BooksSettings::default()),
|
||||
@@ -128,9 +142,10 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
let mut build = |snap: &Snapshot, prev: Option<u64>| -> (Update, ledger_store::DiskCheck) {
|
||||
let integrity = Integrity::check(snap, &mut verified);
|
||||
let disk = integrity.disk.clone();
|
||||
let update = match settings(&source)
|
||||
.and_then(|s| Books::build(snap, s, today(&source), prev, integrity))
|
||||
{
|
||||
let update = match settings(&source, snap).and_then(|s| {
|
||||
let today = today(&source, &s);
|
||||
Books::build(snap, s, today, prev, integrity)
|
||||
}) {
|
||||
Ok(b) => Update::Books(Arc::new(b)),
|
||||
Err(e) => Update::Error(e),
|
||||
};
|
||||
@@ -181,7 +196,12 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
}
|
||||
}
|
||||
let mut toml_mtime = mtime(&source.books_toml);
|
||||
let mut day = today(&source);
|
||||
let day_now = |snap: &Snapshot| {
|
||||
settings(&source, snap)
|
||||
.map(|s| today(&source, &s))
|
||||
.unwrap_or_else(|_| today(&source, &BooksSettings::default()))
|
||||
};
|
||||
let mut day = day_now(&snap);
|
||||
loop {
|
||||
match ev_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
Ok(()) => {
|
||||
@@ -205,7 +225,7 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
}
|
||||
};
|
||||
let toml_now = mtime(&source.books_toml);
|
||||
let new_day = today(&source);
|
||||
let new_day = day_now(&snap);
|
||||
// The writer journals first and updates its database right after:
|
||||
// a "behind" database usually catches up a moment later, so look
|
||||
// again rather than leave a stale warning up.
|
||||
|
||||
@@ -22,3 +22,18 @@ mod widgets;
|
||||
|
||||
pub use app::App;
|
||||
pub use data::Source;
|
||||
|
||||
/// The vault key file named on the command line (`--vault-key-file`),
|
||||
/// which wins over a settings file's `[vault] key_file`, as it does for
|
||||
/// the server: books that keep their own settings never name it.
|
||||
static VAULT_KEY_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Name the vault key file (once, at start).
|
||||
pub fn set_vault_key_file(path: std::path::PathBuf) {
|
||||
let _ = VAULT_KEY_FILE.set(path);
|
||||
}
|
||||
|
||||
/// The vault key file named on the command line, if one was.
|
||||
pub fn vault_key_file() -> Option<std::path::PathBuf> {
|
||||
VAULT_KEY_FILE.get().cloned()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]`
|
||||
//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]`
|
||||
//!
|
||||
//! Opens the books read-only and follows them live. With no flags it looks
|
||||
//! for `books-data/` and `wanderbooks.toml` in the current directory.
|
||||
@@ -17,11 +17,16 @@ fn main() -> eframe::Result<()> {
|
||||
match a.as_str() {
|
||||
"--data-dir" => data_dir = args.next().map(PathBuf::from),
|
||||
"--books-toml" => books_toml = args.next().map(PathBuf::from),
|
||||
"--vault-key-file" => {
|
||||
if let Some(k) = args.next() {
|
||||
ledger_gui::set_vault_key_file(PathBuf::from(k));
|
||||
}
|
||||
}
|
||||
"--today" => today = args.next().and_then(|d| d.parse().ok()),
|
||||
"--demo" => demo = true,
|
||||
"-h" | "--help" => {
|
||||
println!(
|
||||
"usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]"
|
||||
"usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
ui,
|
||||
icon::BANK,
|
||||
"No cash accounts found",
|
||||
"Name an asset account \"checking\", \"savings\", or \"cash\", or set [books] cash_accounts in wanderbooks.toml.",
|
||||
"Name an asset account \"checking\", \"savings\", or \"cash\", or ask the agent to set the books' cash accounts.",
|
||||
)
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -38,6 +38,98 @@ fn kv(ui: &mut Ui, k: &str, v: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Who decided a setting and when, under its value.
|
||||
fn provenance(ui: &mut Ui, by: &str, on: chrono::NaiveDate) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.label(
|
||||
RichText::new(format!("{by} · {}", fmt::date(on)))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
|
||||
/// How the books are kept, as the owners decided: conventions, decisions,
|
||||
/// and what is open (each only when there is something to show).
|
||||
fn kept_cards(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let kept = b.ledger.conventions();
|
||||
let conventions: Vec<_> = kept.conventions().filter(|c| c.retired.is_none()).collect();
|
||||
if !conventions.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"How the books are kept",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for c in &conventions {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(&c.title).font(theme::semibold(13.0)));
|
||||
widgets::pill(ui, &c.kind.key().replace('_', " "), p.accent, p.accent_soft);
|
||||
});
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(c.summary()).font(theme::regular(12.5)),
|
||||
&c.body_md,
|
||||
);
|
||||
provenance(ui, &c.source.decided_by, c.source.decided_on);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
let decisions: Vec<_> = kept
|
||||
.decisions()
|
||||
.filter(|d| d.superseded_by.is_none())
|
||||
.collect();
|
||||
if !decisions.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Decisions",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for d in decisions.iter().rev() {
|
||||
ui.label(RichText::new(&d.new.subject).font(theme::semibold(13.0)));
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(&d.new.decision_md).font(theme::regular(12.5)),
|
||||
&d.new.decision_md,
|
||||
);
|
||||
provenance(ui, &d.new.decided_by.join(" and "), d.new.decided_on);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
let open: Vec<_> = kept.open_items().filter(|i| i.resolved.is_none()).collect();
|
||||
if !open.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Open items",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for i in open {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(icon::FLAG).color(p.warning));
|
||||
ui.label(RichText::new(&i.new.title).font(theme::regular(13.0)));
|
||||
if let Some(due) = i.new.due {
|
||||
// Late says so in words, not only in colour.
|
||||
let late = due < b.today;
|
||||
let when = if late { "overdue since" } else { "due" };
|
||||
ui.label(
|
||||
RichText::new(format!("{when} {}", fmt::date(due)))
|
||||
.font(theme::regular(12.0))
|
||||
.color(if late { p.warning } else { p.muted }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "company", |ui| {
|
||||
@@ -54,14 +146,25 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
"Profile",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if c.profile.is_empty() {
|
||||
let kept = b.ledger.settings();
|
||||
// The currency is always known: facts are the rest.
|
||||
if c.profile.iter().all(|(k, _)| k == "base_currency") {
|
||||
ui.label(
|
||||
RichText::new("No [company] settings in wanderbooks.toml.")
|
||||
.color(p.muted),
|
||||
RichText::new(if kept.is_journaled() {
|
||||
"No company facts set yet: the agent records them as you give them."
|
||||
} else {
|
||||
"No company facts in the settings file yet: the agent records them as you give them."
|
||||
})
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
for (k, v) in &c.profile {
|
||||
kv(ui, &fmt::label(k), v);
|
||||
// Who set it, and when (settings kept in the
|
||||
// books carry their source).
|
||||
if let Some(r) = kept.get(&format!("company.{k}")) {
|
||||
provenance(ui, &r.source.decided_by, r.source.decided_on);
|
||||
}
|
||||
}
|
||||
kv(ui, "Accounting basis", &c.basis);
|
||||
kv(
|
||||
@@ -70,8 +173,21 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
c.tax_form.map(fmt::tax_form).unwrap_or("not set"),
|
||||
);
|
||||
kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold));
|
||||
if let Some(z) = &b.settings.timezone {
|
||||
kv(ui, "Time zone", z);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(if kept.is_journaled() {
|
||||
"Kept in the books: the agent changes settings, with who decided and when."
|
||||
} else {
|
||||
"Read from the settings file: the agent can move it into the books."
|
||||
})
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
);
|
||||
kept_cards(ui, b);
|
||||
}
|
||||
_ => {
|
||||
widgets::titled_card(
|
||||
|
||||
@@ -834,6 +834,75 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
// The books' record of how they are kept (journal format 2).
|
||||
"settings.set" => {
|
||||
let keys: Vec<&str> = p["changes"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
|
||||
.collect();
|
||||
let by = s(&p["source"], "decided_by");
|
||||
ev(
|
||||
icon::GEAR_SIX,
|
||||
if keys.is_empty() {
|
||||
format!("Settings moved into the books ({by})")
|
||||
} else {
|
||||
format!("Settings changed: {} ({by})", keys.join(", "))
|
||||
},
|
||||
Some(Route::Company),
|
||||
)
|
||||
}
|
||||
"convention.set" => ev(
|
||||
icon::SCROLL,
|
||||
format!(
|
||||
"Convention: {} ({}, {})",
|
||||
s(p, "title"),
|
||||
s(p, "kind").replace('_', " "),
|
||||
s(&p["source"], "decided_by")
|
||||
),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"convention.retired" => ev(
|
||||
icon::SCROLL,
|
||||
format!("Convention {} retired: {}", s(p, "id"), s(p, "reason")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"decision.recorded" => ev(
|
||||
icon::GAVEL,
|
||||
format!(
|
||||
"Decision: {} ({}, {})",
|
||||
s(p, "subject"),
|
||||
p["decided_by"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and "),
|
||||
s(p, "decided_on")
|
||||
),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"open_item.raised" => ev(
|
||||
icon::FLAG,
|
||||
format!("Open item #{id}: {}", s(p, "title")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"open_item.resolved" => ev(
|
||||
icon::CHECK_CIRCLE,
|
||||
format!("Open item #{id} resolved: {}", s(p, "resolution")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"entry.approved" => ev(
|
||||
icon::SEAL_CHECK,
|
||||
format!(
|
||||
"Entry #{} approved by {}",
|
||||
entry_id.unwrap_or(0),
|
||||
s(p, "approver")
|
||||
),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
other => ev(
|
||||
icon::DOT,
|
||||
other.to_string(),
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! The Company page shows how the books are kept: live conventions (not
|
||||
//! retired ones), standing decisions (not superseded ones), and open items
|
||||
//! (not resolved ones), each with who decided it and when; an open item
|
||||
//! past due says so in words. Books that keep their settings but hold no
|
||||
//! company facts say where the facts will come from.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::Currency;
|
||||
use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_gui::nav::Route;
|
||||
use ledger_gui::{App, data, fmt};
|
||||
use ledger_store::Store;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
let d = std::env::temp_dir().join(format!("ledger-gui-kept-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
TempDir(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
d(2026, 10, 17)
|
||||
}
|
||||
|
||||
fn src(by: &str, on: NaiveDate) -> Source {
|
||||
Source {
|
||||
decided_by: by.into(),
|
||||
decided_on: on,
|
||||
citation: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn note(id: &str, title: &str, body: &str, on: NaiveDate) -> NewConvention {
|
||||
NewConvention {
|
||||
id: id.into(),
|
||||
kind: ConventionKind::Policy,
|
||||
title: title.into(),
|
||||
body_md: body.into(),
|
||||
structured: None,
|
||||
source: src("Stephen Waits", on),
|
||||
}
|
||||
}
|
||||
|
||||
fn item(title: &str, due: Option<NaiveDate>) -> NewOpenItem {
|
||||
NewOpenItem {
|
||||
title: title.into(),
|
||||
detail_md: None,
|
||||
due,
|
||||
source: src("Stephen Waits", d(2026, 10, 16)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the Company page of the books in `dir`.
|
||||
fn company(dir: &TempDir) -> Harness<'static, App> {
|
||||
let books = data::load_once(&ledger_gui::Source {
|
||||
data_dir: dir.0.join("books-data"),
|
||||
books_toml: None,
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap();
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 2400.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Company));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn shows(h: &Harness<'_, App>, text: &str) -> bool {
|
||||
h.query_all_by_label_contains(text).next().is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_company_page_shows_how_the_books_are_kept() {
|
||||
let dir = TempDir::new("cards");
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
s.set_convention(¬e(
|
||||
"joint-money",
|
||||
"Joint money",
|
||||
"Owner funds are 50/50.\nUnless Stephen says otherwise.",
|
||||
d(2026, 10, 11),
|
||||
))
|
||||
.unwrap();
|
||||
s.set_convention(¬e(
|
||||
"old-way",
|
||||
"The old way",
|
||||
"Gone now.",
|
||||
d(2026, 10, 12),
|
||||
))
|
||||
.unwrap();
|
||||
s.retire_convention(
|
||||
"old-way",
|
||||
"replaced",
|
||||
&src("Stephen Waits", d(2026, 10, 14)),
|
||||
)
|
||||
.unwrap();
|
||||
let first = s
|
||||
.record_decision(&NewDecision {
|
||||
subject: "Materiality, first cut".into(),
|
||||
decision_md: "Expense up to $2,500.".into(),
|
||||
decided_by: vec!["Stephen Waits".into()],
|
||||
decided_on: d(2026, 10, 11),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
})
|
||||
.unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
subject: "Materiality policy".into(),
|
||||
decision_md: "Tangible property up to $2,500; prepaids by term.".into(),
|
||||
decided_by: vec!["Stephen Waits".into(), "Katherine Pendergast".into()],
|
||||
decided_on: d(2026, 10, 16),
|
||||
evidence_attachment: None,
|
||||
supersedes: Some(first),
|
||||
citation: None,
|
||||
})
|
||||
.unwrap();
|
||||
s.raise_open_item(&item("Mail the IRS letter", Some(d(2026, 10, 16))))
|
||||
.unwrap();
|
||||
s.raise_open_item(&item("Close October", Some(today())))
|
||||
.unwrap();
|
||||
s.raise_open_item(&item("Ask about the domains", None))
|
||||
.unwrap();
|
||||
let done = s
|
||||
.raise_open_item(&item("File the EIN letter", None))
|
||||
.unwrap();
|
||||
s.resolve_open_item(done, "filed", &src("Stephen Waits", d(2026, 10, 17)))
|
||||
.unwrap();
|
||||
drop(s);
|
||||
|
||||
let h = company(&dir);
|
||||
// Live conventions, their kind and summary (the first line), and who
|
||||
// decided them; never a retired one.
|
||||
assert!(shows(&h, "How the books are kept"));
|
||||
assert!(shows(&h, "Joint money"));
|
||||
assert!(shows(&h, "policy"));
|
||||
assert!(shows(&h, "Owner funds are 50/50."));
|
||||
assert!(
|
||||
!shows(&h, "Unless Stephen says otherwise"),
|
||||
"a summary is its first line"
|
||||
);
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!("Stephen Waits · {}", fmt::date(d(2026, 10, 11)))
|
||||
));
|
||||
assert!(
|
||||
!shows(&h, "The old way"),
|
||||
"a retired convention is not shown"
|
||||
);
|
||||
// The standing decision, by both members; never the one it replaced.
|
||||
assert!(shows(&h, "Decisions"));
|
||||
assert!(shows(&h, "Materiality policy"));
|
||||
assert!(shows(
|
||||
&h,
|
||||
"Tangible property up to $2,500; prepaids by term."
|
||||
));
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!(
|
||||
"Stephen Waits and Katherine Pendergast · {}",
|
||||
fmt::date(d(2026, 10, 16))
|
||||
)
|
||||
));
|
||||
assert!(
|
||||
!shows(&h, "Materiality, first cut"),
|
||||
"a superseded decision is not shown"
|
||||
);
|
||||
// Open items: late in words, due today is not late, no due no date;
|
||||
// never a resolved one.
|
||||
assert!(shows(&h, "Open items"));
|
||||
assert!(shows(&h, "Mail the IRS letter"));
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!("overdue since {}", fmt::date(d(2026, 10, 16)))
|
||||
));
|
||||
assert!(shows(&h, "Close October"));
|
||||
assert!(
|
||||
h.query_by_label(&format!("due {}", fmt::date(today())))
|
||||
.is_some()
|
||||
);
|
||||
assert!(!shows(&h, &format!("overdue since {}", fmt::date(today()))));
|
||||
assert!(shows(&h, "Ask about the domains"));
|
||||
assert!(
|
||||
!shows(&h, "File the EIN letter"),
|
||||
"a resolved item is not shown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_kept_shows_no_cards() {
|
||||
let dir = TempDir::new("none");
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
s.set_convention(¬e(
|
||||
"old-way",
|
||||
"The old way",
|
||||
"Gone now.",
|
||||
d(2026, 10, 12),
|
||||
))
|
||||
.unwrap();
|
||||
s.retire_convention(
|
||||
"old-way",
|
||||
"replaced",
|
||||
&src("Stephen Waits", d(2026, 10, 14)),
|
||||
)
|
||||
.unwrap();
|
||||
let only = s.raise_open_item(&item("Done already", None)).unwrap();
|
||||
s.resolve_open_item(only, "done", &src("Stephen Waits", d(2026, 10, 17)))
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let h = company(&dir);
|
||||
for card in ["How the books are kept", "Decisions", "Open items"] {
|
||||
assert!(!shows(&h, card), "{card} shown with nothing in it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn books_that_keep_their_settings_say_where_facts_come_from() {
|
||||
let dir = TempDir::new("facts");
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
// Journaled, with no company fact yet.
|
||||
s.change_settings(
|
||||
&[Change {
|
||||
key: "books.asset_tag_prefix".into(),
|
||||
value: Some(serde_json::json!("WL")),
|
||||
}],
|
||||
&src("Stephen Waits", today()),
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let h = company(&dir);
|
||||
assert!(shows(
|
||||
&h,
|
||||
"No company facts set yet: the agent records them as you give them."
|
||||
));
|
||||
drop(h);
|
||||
let mut s = Store::open(&dir.0.join("books-data")).unwrap();
|
||||
s.change_settings(
|
||||
&[Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(serde_json::json!("Wanderland Labs LLC")),
|
||||
}],
|
||||
&src("Stephen Waits", today()),
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let h = company(&dir);
|
||||
assert!(!shows(&h, "No company facts"), "the facts are there");
|
||||
assert!(shows(&h, "Wanderland Labs LLC"));
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!("Stephen Waits · {}", fmt::date(today()))
|
||||
));
|
||||
}
|
||||
@@ -62,11 +62,11 @@ fn says_update(text: &str) {
|
||||
#[test]
|
||||
fn the_viewer_says_update_for_newer_books_and_corrupt_for_damaged_ones() {
|
||||
for (tag, line_type, payload) in [
|
||||
("type", "settings.set", json!({"key": "company.name"})),
|
||||
("type", "period.attested", json!({"key": "company.name"})),
|
||||
(
|
||||
"format",
|
||||
"journal.format_set",
|
||||
json!({"format": 2, "features": []}),
|
||||
json!({"format": 3, "features": []}),
|
||||
),
|
||||
] {
|
||||
let data = books(tag);
|
||||
|
||||
@@ -202,25 +202,48 @@ fn show(h: &mut Harness<'_, App>, route: Route) {
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
/// INVARIANTS 1-2, in four shards run side by side.
|
||||
/// How many shards the old books are rendered in, side by side.
|
||||
const SHARDS: usize = 8;
|
||||
|
||||
/// INVARIANTS 1-2, in eight shards run side by side.
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_0() {
|
||||
every_page_renders(0, 4);
|
||||
every_page_renders(0, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_1() {
|
||||
every_page_renders(1, 4);
|
||||
every_page_renders(1, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_2() {
|
||||
every_page_renders(2, 4);
|
||||
every_page_renders(2, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_3() {
|
||||
every_page_renders(3, 4);
|
||||
every_page_renders(3, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_4() {
|
||||
every_page_renders(4, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_5() {
|
||||
every_page_renders(5, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_6() {
|
||||
every_page_renders(6, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_7() {
|
||||
every_page_renders(7, SHARDS);
|
||||
}
|
||||
|
||||
/// Every page over the old books whose index is `shard` mod `of`.
|
||||
@@ -396,3 +419,42 @@ fn figures_that_cant_be_computed_are_said() {
|
||||
b.attention
|
||||
);
|
||||
}
|
||||
|
||||
/// Books that keep their own settings (written under P1) show them, never
|
||||
/// the settings file beside them, which here says otherwise (another
|
||||
/// name, a July fiscal year, a $25 receipt rule): the viewer reads the
|
||||
/// settings as the server does (plan/served.md P1, Accept).
|
||||
#[test]
|
||||
fn books_that_keep_their_settings_never_read_the_file() {
|
||||
let dir = stage("p1_record");
|
||||
let b = load(&dir);
|
||||
assert_eq!(b.settings.company.name, "Fixture Labs LLC");
|
||||
assert_eq!(b.settings.company.fiscal_year_start, (1, 1));
|
||||
assert_eq!(b.receipt_threshold, rust_decimal::Decimal::ZERO);
|
||||
assert_eq!(b.settings.timezone, None, "the fixture cleared it");
|
||||
assert_eq!(b.settings.company.members.len(), 2);
|
||||
assert!(
|
||||
b.settings
|
||||
.company
|
||||
.profile
|
||||
.iter()
|
||||
.any(|(k, v)| k == "ein" && v == "98-7654321"),
|
||||
"the viewer is the human's: the EIN in full"
|
||||
);
|
||||
// Every new event has its own sentence in the journal page.
|
||||
for line in b.journal.iter().filter(|l| {
|
||||
[
|
||||
"settings.set",
|
||||
"convention.set",
|
||||
"convention.retired",
|
||||
"decision.recorded",
|
||||
"open_item.raised",
|
||||
"open_item.resolved",
|
||||
"entry.approved",
|
||||
]
|
||||
.contains(&l.line_type.as_str())
|
||||
}) {
|
||||
let e = ledger_gui::pages::journal::describe(&b, line).expect("described");
|
||||
assert_ne!(e.text, line.line_type, "{} has no sentence", line.line_type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! The viewer's command line: `--vault-key-file` names the vault key and
|
||||
//! wins over a settings file's `[vault] key_file`, as it does for the
|
||||
//! server; an unknown argument stops the viewer with status 2. (Its own
|
||||
//! test binary: the key file is named once per process.)
|
||||
|
||||
use ledger_core::{Role, TinFacts, TinType};
|
||||
use ledger_gui::{Source, data};
|
||||
use ledger_store::Store;
|
||||
use ledger_vault::{Tin, Vault};
|
||||
|
||||
const SSN: &str = "123-45-6789";
|
||||
|
||||
#[test]
|
||||
fn the_key_named_on_the_command_line_wins() {
|
||||
let root = std::env::temp_dir().join(format!("ledger-gui-key-flag-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let books = root.join("books");
|
||||
let key = root.join("keys/vault.key");
|
||||
std::fs::create_dir_all(&books).unwrap();
|
||||
let mut s = Store::create(&books.join("books-data"), ledger_core::Currency::USD).unwrap();
|
||||
let party = s.add_party("Pat Lee", &[Role::Member]).unwrap();
|
||||
let v = Vault::open(&books.join("vault"), &key, true).unwrap();
|
||||
let hash = v
|
||||
.put_tin(party, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap())
|
||||
.unwrap();
|
||||
s.set_party_tin(
|
||||
party,
|
||||
Some(TinFacts {
|
||||
tin_type: TinType::Ssn,
|
||||
last4: "6789".into(),
|
||||
vault_record: Some(hash),
|
||||
purged: None,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
|
||||
// Named first, so the default key location is never consulted.
|
||||
assert_eq!(ledger_gui::vault_key_file(), None);
|
||||
ledger_gui::set_vault_key_file(key.clone());
|
||||
assert_eq!(ledger_gui::vault_key_file(), Some(key.clone()));
|
||||
// Named once: a second naming changes nothing.
|
||||
ledger_gui::set_vault_key_file(root.join("elsewhere.key"));
|
||||
assert_eq!(ledger_gui::vault_key_file(), Some(key.clone()));
|
||||
|
||||
let mut b = data::load_once(&Source {
|
||||
data_dir: books.join("books-data"),
|
||||
books_toml: None,
|
||||
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()),
|
||||
})
|
||||
.unwrap();
|
||||
// The settings name a key that isn't there; the command line wins.
|
||||
b.settings.vault_key_file = Some(root.join("missing/vault.key"));
|
||||
assert_eq!(b.tin_from_vault(party).unwrap(), SSN);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_argument_stops_the_viewer() {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui"))
|
||||
.arg("--no-such-flag")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("unknown argument --no-such-flag"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let help = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui"))
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(help.status.success());
|
||||
assert!(String::from_utf8_lossy(&help.stdout).contains("--vault-key-file PATH"));
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.
|
||||
|
||||
- These tools are the only way to the books. Never look for the books (their journal, receipts or vault) in local files.
|
||||
- One exception, until settings move into the books: the settings file that books.settings names (books_toml) may be edited as the settings-and-conventions section says. No other local file.
|
||||
- Start every session with books.start: the rules in force, this company's conventions, what is open, and next steps. Follow the company's conventions (books.conventions).
|
||||
- Settings, conventions, decisions and open items live in the books: change them only through these tools (the settings-and-conventions section). One exception, for books that still read a settings file (books.settings says source "file"): that file may be edited until books.import_settings moves it in. No other local file.
|
||||
- Money is exact decimal. Always pass amounts as decimal strings ("123.45"), never floats. Amounts in every response are decimal strings: treat them as exact, and echo them to the human as written.
|
||||
- Never post directly: draft, add lines, preview, show the human the preview, then post (the entries section).
|
||||
- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
# Settings
|
||||
# Settings, conventions, decisions and open items
|
||||
|
||||
Until settings move into the books themselves (planned next), they live in the settings file books.settings names (books_toml), a TOML file; this section changes then.
|
||||
The books keep their own record of how they are kept. Every agent, anywhere, reads it through these tools; none of it lives in local files.
|
||||
|
||||
- That file 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.
|
||||
- Change it when the human tells you a company fact, when a tool refuses naming a setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions.
|
||||
- The server reloads it whenever it changes, with no restart: after each change, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty.
|
||||
- Never put a member's SSN or any TIN in it.
|
||||
## Settings
|
||||
|
||||
- books.settings lists every setting the books know: what it means and takes, its value (null: unset, its default applies), who set it and when, and problems (settings that disagree with the chart, each saying what to change).
|
||||
- Change settings with books.set_setting: one or more {key, value} together, with who decided (decided_by) and when (decided_on, default today). A value is checked and kept in one spelling; null clears a key back to its default. Change a setting when the human tells you a company fact or decides a policy, when a tool refuses naming a setting, when books.settings or books.digest lists a problem, and after books.renumber_account lists config_mentions.
|
||||
- The company's EIN is the one identity number the settings keep; it is shown masked. No other setting, note or convention ever holds a full TIN: those go only into the vault (the parties-and-tins section).
|
||||
- company.timezone sets what "today" means for the books.
|
||||
- books.export_settings writes the settings out as a settings file (TOML), each with who decided it. It is a copy: editing it changes nothing.
|
||||
|
||||
## Books that still read a settings file
|
||||
|
||||
If books.settings says source "file", these books predate settings in the books and still read the settings file books.settings names. Move it in once, with the human:
|
||||
|
||||
1. Read that file's text, whole and unedited, and pass it to books.import_settings as toml_text (and, if books.settings shows config_json_present, the receipt policy as config_json_text: {"receipt_policy": {"require_receipts_above", "receipt_required_accounts", "receipt_exempt_accounts"}} from books.settings). The import is refused unless the text is the file the server reads.
|
||||
2. Show the human the preview: every change, the notes (what isn't a books setting and where it goes), anything unknown, and the file's comments, which are not imported: record what matters in them (where a value came from, a discrepancy, something pending) as a decision, an open item or a convention.
|
||||
3. Confirm with confirmed: true, decided_by the human. The import is refused if any setting would read differently afterwards.
|
||||
4. If the file named the vault's key file ([vault] key_file), the server must already be started naming the same file, and the viewer too, or the vault won't open: tell the human before the import.
|
||||
|
||||
From then on no file is read; books.set_setting changes settings. Until then, that file is the one local file you may edit, and books.set_setting is refused (it would drop the rest of the file).
|
||||
|
||||
## Conventions
|
||||
|
||||
How this company keeps its books, as the owners decided, is recorded as conventions (books.set_convention, read with books.conventions; books.start lists them):
|
||||
|
||||
- posting_template: how a kind of transaction posts, as lines with shares of the amount ({lines: [{account, side, share}]}, each side's shares summing to 1). An account may be a slot, a name in braces such as "{expense}", that each draft names (books.draft_from_template accounts: {"expense": "6300"}). books.draft_from_template drafts an entry from one, split to the cent; preview and post it as usual.
|
||||
- approval_rule: who approves which entries ({approver, accounts?, amount_over?}). They block posting only when policy.approval_rules is enforce (off by default): then a post's preview lists approvals still needed, books.approve_entry records each once the approver says yes, and a tool that would post a covered entry at once (an invoice, a bill, a payment, payroll, a disposal) is refused: draft the entry instead. Reversals, closes, depreciation and amortization are exempt. An account a rule names can't be renumbered until the rule is revised.
|
||||
- policy, close_step, calendar, mapping, note: in words.
|
||||
|
||||
Follow the company's conventions; when the human decides a new way of doing something, record it. A convention that no longer applies is retired (books.retire_convention), never deleted; its id is never reused.
|
||||
|
||||
## Decisions and open items
|
||||
|
||||
- books.record_decision records what the owners decided (a policy, a treatment, a cutover) with who decided, when, and the filed document behind it if there is one. That is the whole record: no signatures or memos are needed unless an outside party requires one.
|
||||
- books.raise_item records something open (waiting on someone, due later); books.open_items lists them and books.resolve_item closes one with how. Raise open items with the human when they come due.
|
||||
@@ -12,8 +12,8 @@
|
||||
//! in the handbook, by section.
|
||||
//! INVARIANTS:
|
||||
//! 1. The connect text is at most CONNECT_MAX characters, and its first
|
||||
//! paragraph is at most CONNECT_LEAD characters and names
|
||||
//! books.handbook.
|
||||
//! paragraph is at most CONNECT_LEAD characters and names books.start
|
||||
//! and books.handbook.
|
||||
//! 2. The connect text and every handbook section state the receipt
|
||||
//! rule in force (from the settings, in the books' base currency:
|
||||
//! ReceiptPolicy::rule), never a fixed one; the connect text states
|
||||
@@ -21,6 +21,9 @@
|
||||
//! 3. No connect text or handbook section names a filesystem path, a
|
||||
//! helper command, a pull request, or where the books are; none
|
||||
//! names the settings file, which is the one books.settings names.
|
||||
//! Books that keep their own settings are told no local file is
|
||||
//! theirs to edit; books that still read a settings file, that it is
|
||||
//! the one they may edit, until it is imported.
|
||||
//! 4. The connect text's first paragraph stands alone: the entry
|
||||
//! point, the local-files rule, decimal strings, and the entry flow.
|
||||
//! 5. The company's name is shown as one line of plain text (no
|
||||
@@ -169,11 +172,11 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
|
||||
};
|
||||
let lead = format!(
|
||||
"wanderbooks: the double-entry books of {name}. You are their bookkeeping agent. \
|
||||
Read books.handbook (or books://handbook) first: it holds every procedure. These \
|
||||
tools are the only way to the books; never look for them in local files. Amounts \
|
||||
are decimal strings (\"123.45\"), never floats. Entries: books.draft_entry, \
|
||||
books.add_line, a preview from books.post_entry without confirmed, shown to the \
|
||||
human, then books.post_entry with confirmed: true."
|
||||
Call books.start first each session: rules, conventions, open items. \
|
||||
books.handbook has every procedure. These tools are the only way to the books; \
|
||||
never look for them in local files. Amounts are decimal strings (\"123.45\"), \
|
||||
never floats. Entries: books.draft_entry, books.add_line, books.post_entry without \
|
||||
confirmed for a preview shown to the human, then with confirmed: true."
|
||||
);
|
||||
// Closes, depreciation and amortization post with no party: the rule
|
||||
// is for the entries the agent posts.
|
||||
@@ -197,6 +200,23 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
|
||||
// (with the hint back), then the hint and the account codes the rule
|
||||
// lists (counted, with books.settings listing them), and last the
|
||||
// words that only help.
|
||||
// Where settings live: in the books (changed through the tools), or,
|
||||
// for books that still read a settings file, that one file.
|
||||
let settings_rule = |why: bool| {
|
||||
if settings.journaled {
|
||||
" Settings and conventions live in the books: change them only with these tools."
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
" The only local file you may edit is the settings file books.settings names{}.",
|
||||
if why {
|
||||
" (until books.import_settings)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
}
|
||||
};
|
||||
let rest = |why: bool, codes: usize, trace: bool, terse: bool| {
|
||||
let rule = settings.policy.rule_listing(currency, codes);
|
||||
let because = |s: &'static str| if why { s } else { "" };
|
||||
@@ -209,8 +229,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
|
||||
depreciation, amortization{} and closes{} need none. Invoice, bill, payment and \
|
||||
payroll tools post at once{}: attach their evidence right after. A full TIN goes \
|
||||
only into the vault{}: never repeat one. After each change, show the affected \
|
||||
balances{}; after posting, check books.digest and offer it to the human.{} The \
|
||||
only local file you may edit is the settings file books.settings names{}.",
|
||||
balances{}; after posting, check books.digest and offer it to the human.{}{}",
|
||||
unless_terse(", previewed the same way"),
|
||||
because(" (their schedules are the evidence)"),
|
||||
because(" (derived from the books)"),
|
||||
@@ -222,7 +241,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
|
||||
} else {
|
||||
""
|
||||
},
|
||||
because(" (handbook: settings-and-conventions)"),
|
||||
settings_rule(why),
|
||||
)
|
||||
};
|
||||
let mut text = String::new();
|
||||
@@ -283,7 +302,7 @@ mod tests {
|
||||
fn instructions_budget() {
|
||||
use ledger_config::PartyPolicy;
|
||||
let long = "A Very Long Legal Name ".repeat(20);
|
||||
for s in [
|
||||
let every = [
|
||||
settings("", Decimal::ZERO),
|
||||
settings("Example Labs LLC", Decimal::new(2500, 2)),
|
||||
settings("Tiny", Decimal::new(1, 3)),
|
||||
@@ -292,7 +311,14 @@ mod tests {
|
||||
longest_rule(&long, PartyPolicy::Require),
|
||||
longest_rule(&long, PartyPolicy::Suggest),
|
||||
longest_rule(&long, PartyPolicy::Off),
|
||||
] {
|
||||
];
|
||||
// Each as books still reading a settings file, and as books that
|
||||
// keep their own settings.
|
||||
let journaled = every.clone().map(|mut s| {
|
||||
s.journaled = true;
|
||||
s
|
||||
});
|
||||
for s in every.into_iter().chain(journaled) {
|
||||
let text = connect_text(&s, "USD");
|
||||
let chars = text.chars().count();
|
||||
assert!(chars <= CONNECT_MAX, "{chars} chars: {text}");
|
||||
@@ -303,10 +329,12 @@ mod tests {
|
||||
lead.chars().count()
|
||||
);
|
||||
for must in [
|
||||
"books.start",
|
||||
"books.handbook",
|
||||
"never look for them in local files",
|
||||
"decimal strings",
|
||||
"books.post_entry with confirmed: true",
|
||||
"books.post_entry without confirmed for a preview",
|
||||
"then with confirmed: true",
|
||||
] {
|
||||
assert!(lead.contains(must), "the lead lacks {must:?}: {lead}");
|
||||
}
|
||||
@@ -317,16 +345,26 @@ mod tests {
|
||||
.any(|&n| text.contains(&s.policy.rule_listing("USD", n))),
|
||||
"{text}"
|
||||
);
|
||||
// What every entry must be, and the one file the agent edits.
|
||||
// What every entry must be, and where settings live: the one
|
||||
// file the agent may edit, or none.
|
||||
for must in [
|
||||
"balances exactly",
|
||||
"real account from books.chart",
|
||||
"the settings file books.settings names",
|
||||
"post at once",
|
||||
"after posting, check books.digest and offer it to the human.",
|
||||
] {
|
||||
assert!(text.contains(must), "lacks {must:?}: {text}");
|
||||
}
|
||||
assert_eq!(
|
||||
text.contains("the settings file books.settings names"),
|
||||
!s.journaled,
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
text.contains("Settings and conventions live in the books"),
|
||||
s.journaled,
|
||||
"{text}"
|
||||
);
|
||||
// The parties rule in force (INVARIANT 2), and only it.
|
||||
let party = [
|
||||
(
|
||||
@@ -476,10 +514,22 @@ mod tests {
|
||||
for why in [
|
||||
"(their schedules are the evidence)",
|
||||
"(derived from the books)",
|
||||
"(handbook: settings-and-conventions)",
|
||||
"(until books.import_settings)",
|
||||
"(the parties setting requires it)",
|
||||
] {
|
||||
assert!(text.contains(why), "{text}");
|
||||
assert!(text.contains(why), "{} chars: {text}", text.chars().count());
|
||||
}
|
||||
// The same company once its settings are in the books.
|
||||
s.journaled = true;
|
||||
let text = connect_text(&s, "USD");
|
||||
assert!(text.chars().count() <= CONNECT_MAX);
|
||||
for why in [
|
||||
"(their schedules are the evidence)",
|
||||
"(the parties setting requires it)",
|
||||
"Settings and conventions live in the books",
|
||||
] {
|
||||
assert!(text.contains(why), "{} chars: {text}", text.chars().count());
|
||||
}
|
||||
assert!(!text.contains("local file you may edit"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,15 @@ fn default_data_dir() -> PathBuf {
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--http ADDR] [--import-root DIR]..."
|
||||
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR] [--import-root DIR]..."
|
||||
);
|
||||
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
|
||||
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
|
||||
eprintln!(
|
||||
" --books-toml company settings (default <data-dir>/../wanderbooks.toml, optional)"
|
||||
" --books-toml company settings, for books that don't keep their own yet (default <data-dir>/../wanderbooks.toml, optional)"
|
||||
);
|
||||
eprintln!(
|
||||
" --vault-key-file the TIN vault's key file (default ~/.config/wanderbooks/vault.key; wins over a settings file's [vault] key_file)"
|
||||
);
|
||||
eprintln!(
|
||||
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
|
||||
@@ -68,6 +71,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut data_dir = default_data_dir();
|
||||
let mut config_path: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut vault_key_file: Option<PathBuf> = None;
|
||||
let mut http_addr: Option<String> = None;
|
||||
let mut import_roots: Vec<PathBuf> = Vec::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
@@ -84,6 +88,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
args.next().ok_or("--books-toml needs a path")?,
|
||||
));
|
||||
}
|
||||
"--vault-key-file" => {
|
||||
vault_key_file = Some(PathBuf::from(
|
||||
args.next().ok_or("--vault-key-file needs a path")?,
|
||||
));
|
||||
}
|
||||
"--http" => {
|
||||
http_addr = Some(args.next().ok_or("--http needs an address")?);
|
||||
}
|
||||
@@ -109,12 +118,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ImportRoots::new(import_roots)
|
||||
};
|
||||
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
|
||||
let config: Config = if config_path.exists() {
|
||||
let raw = std::fs::read_to_string(&config_path)?;
|
||||
serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?
|
||||
} else {
|
||||
Config::default()
|
||||
// config.json's base currency matters only to books being created: it
|
||||
// is read then, and never otherwise (books that keep their own
|
||||
// settings read no file).
|
||||
let read_config = || -> Result<Config, Box<dyn std::error::Error>> {
|
||||
if config_path.exists() {
|
||||
let raw = std::fs::read_to_string(&config_path)?;
|
||||
Ok(serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?)
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
}
|
||||
};
|
||||
// Logs go to stderr: stdout is the MCP channel.
|
||||
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
||||
@@ -130,9 +144,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}),
|
||||
config_json: config_path.clone(),
|
||||
};
|
||||
let live = settings::Live::watching(source)?;
|
||||
eprintln!("ledger-mcp: company {:?}", live.current().company.name);
|
||||
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
|
||||
|
||||
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||
match open_waiting_for_lock(&data_dir) {
|
||||
@@ -146,6 +157,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
let config = read_config()?;
|
||||
let code = config.base_currency.as_deref().unwrap_or("USD");
|
||||
let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?;
|
||||
eprintln!("ledger-mcp: creating new store (base currency {code})");
|
||||
@@ -160,6 +172,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(why) = &report.healed {
|
||||
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
|
||||
}
|
||||
// Books that keep their own settings never read a settings file, not
|
||||
// even to start (settings INVARIANT 1); others read it as before.
|
||||
let mut live = if store.ledger().settings().is_journaled() {
|
||||
settings::Live::journaled(source)
|
||||
} else {
|
||||
settings::Live::watching(source)?
|
||||
}
|
||||
.with_vault_key_file(vault_key_file);
|
||||
live.sync(store.ledger().settings(), store.base_currency().code());
|
||||
eprintln!(
|
||||
"ledger-mcp: company {:?} (settings from {})",
|
||||
live.current().company.name,
|
||||
if live.is_journaled() {
|
||||
"the books"
|
||||
} else {
|
||||
"the settings file"
|
||||
}
|
||||
);
|
||||
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
|
||||
// No import root may be `/`, the home folder, or hold the books, the
|
||||
// vault or a key (local_files.rs INVARIANT 0): a named one stops the
|
||||
// server here, a default one is dropped.
|
||||
|
||||
+396
-45
@@ -332,36 +332,57 @@ fn vendor_1099_report(
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// The wanderbooks.toml settings that name account `code`.
|
||||
/// The settings that name account `code`, by key (or, for books
|
||||
/// that still read a settings file, as the file spells them).
|
||||
fn config_mentions(&self, code: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (key, codes) in [
|
||||
("[books] cash_accounts", &self.settings().roles.cash),
|
||||
let settings = self.settings();
|
||||
for (key, in_file, codes) in [
|
||||
(
|
||||
"[books] financing_accounts",
|
||||
&self.settings().roles.financing,
|
||||
"books.cash_accounts",
|
||||
"[books] cash_accounts",
|
||||
&settings.roles.cash,
|
||||
),
|
||||
(
|
||||
"books.financing_accounts",
|
||||
"[books] financing_accounts",
|
||||
&settings.roles.financing,
|
||||
),
|
||||
(
|
||||
"books.investing_accounts",
|
||||
"[books] investing_accounts",
|
||||
&self.settings().roles.investing,
|
||||
&settings.roles.investing,
|
||||
),
|
||||
(
|
||||
"books.close_sweep",
|
||||
"[books] close_sweep",
|
||||
&settings.company.close_sweep,
|
||||
),
|
||||
(
|
||||
"policy.evidence.required_accounts",
|
||||
"receipt_required_accounts",
|
||||
&settings.policy.receipt_required_accounts,
|
||||
),
|
||||
(
|
||||
"policy.evidence.exempt_accounts",
|
||||
"receipt_exempt_accounts",
|
||||
&settings.policy.receipt_exempt_accounts,
|
||||
),
|
||||
] {
|
||||
if codes.iter().any(|c| c == code) {
|
||||
out.push(key.to_string());
|
||||
out.push(self.setting_label(key, in_file));
|
||||
}
|
||||
}
|
||||
if self.settings().company.close_into.as_deref() == Some(code) {
|
||||
out.push("[company] close_into".to_string());
|
||||
if settings.company.close_into.as_deref() == Some(code) {
|
||||
out.push(self.setting_label("books.close_into", "[books] close_into"));
|
||||
}
|
||||
for m in &self.settings().company.members {
|
||||
let members = self.setting_label("company.members", "[[company.members]]");
|
||||
for m in &settings.company.members {
|
||||
if m.capital_account == code {
|
||||
out.push(format!("[[company.members]] {} capital_account", m.name));
|
||||
out.push(format!("{members} {} capital_account", m.name));
|
||||
}
|
||||
if m.distribution_account.as_deref() == Some(code) {
|
||||
out.push(format!(
|
||||
"[[company.members]] {} distribution_account",
|
||||
m.name
|
||||
));
|
||||
out.push(format!("{members} {} distribution_account", m.name));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -418,6 +439,56 @@ impl BooksServer {
|
||||
self.settings.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The settings in force as the books would keep them: the books' own,
|
||||
/// or, for books that still read a settings file, that file's reading
|
||||
/// (no source: the file is the source).
|
||||
fn settings_as_kept(&self, store: &Store) -> ledger_core::settings::Settings {
|
||||
let books = store.ledger().settings();
|
||||
if books.is_journaled() {
|
||||
return books.clone();
|
||||
}
|
||||
let Some((toml, json)) = self.live().file_texts() else {
|
||||
return books.clone();
|
||||
};
|
||||
let empty = ledger_core::settings::Settings::default();
|
||||
match ledger_config::codec::import(
|
||||
toml.as_deref().unwrap_or(""),
|
||||
json.as_deref(),
|
||||
&empty,
|
||||
self.currency(),
|
||||
) {
|
||||
Ok(i) => {
|
||||
let mut s = empty;
|
||||
let file = ledger_core::settings::Source {
|
||||
decided_by: "the settings file".into(),
|
||||
decided_on: today(),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
match s.apply(&i.changes, &file, 0) {
|
||||
Ok(()) => s,
|
||||
Err(_) => books.clone(),
|
||||
}
|
||||
}
|
||||
Err(_) => books.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a message names a setting: by its key when the books keep
|
||||
/// their own settings, else as the settings file spells it.
|
||||
fn setting_label(&self, key: &str, in_file: &str) -> String {
|
||||
if self.settings().journaled {
|
||||
key.to_string()
|
||||
} else {
|
||||
in_file.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tool the server serves.
|
||||
pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> {
|
||||
Self::core_router() + Self::kept_router()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -485,10 +556,10 @@ impl BooksServer {
|
||||
/// statement of members' equity.
|
||||
fn member_capitals(&self, ledger: &Ledger) -> Result<Vec<ledger_core::MemberCapital>, String> {
|
||||
if self.settings().company.members.is_empty() {
|
||||
return Err(
|
||||
"no members configured: add [[company.members]] (name, share, capital_account) to the settings"
|
||||
.to_string(),
|
||||
);
|
||||
return Err(format!(
|
||||
"no members configured: set {} (name, share, capital_account for each)",
|
||||
self.setting_label("company.members", "[[company.members]] in the settings")
|
||||
));
|
||||
}
|
||||
self.settings()
|
||||
.company
|
||||
@@ -820,6 +891,16 @@ impl BooksServer {
|
||||
if self.store.stopped() {
|
||||
return Err(STOPPED.to_string());
|
||||
}
|
||||
// Books that keep their own settings: take any change (crate::
|
||||
// settings INVARIANT 1).
|
||||
let timezone = {
|
||||
let mut live = self.live();
|
||||
live.sync(guard.ledger().settings(), self.store.currency.code());
|
||||
live.current().timezone.clone()
|
||||
};
|
||||
// "Today" from here on is the zone in force now (a change another
|
||||
// session made while this request waited included).
|
||||
TIMEZONE.with(|z| *z.borrow_mut() = timezone);
|
||||
Ok(StoreGuard {
|
||||
guard,
|
||||
shared: &self.store,
|
||||
@@ -921,15 +1002,59 @@ fn parse_side(s: &str) -> Result<Side, String> {
|
||||
/// 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).
|
||||
/// The live conventions that name account `code` (a posting template's
|
||||
/// line, an approval rule's accounts): after a renumber, each still names
|
||||
/// the old code until revised.
|
||||
fn convention_mentions(ledger: &Ledger, code: &str) -> Vec<String> {
|
||||
convention_codes(ledger)
|
||||
.into_iter()
|
||||
.filter(|(_, c)| c == code)
|
||||
.map(|(id, _)| format!("convention {id} (revise it with books.set_convention)"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every account code a live convention names: (convention id, code).
|
||||
fn convention_codes(ledger: &Ledger) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for c in ledger
|
||||
.conventions()
|
||||
.conventions()
|
||||
.filter(|c| c.retired.is_none())
|
||||
{
|
||||
if let Some(t) = c.template() {
|
||||
for l in t.lines {
|
||||
if ledger_core::conventions::slot(&l.account).is_none() {
|
||||
out.push((c.id.clone(), l.account));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = c.approval_rule() {
|
||||
for a in r.accounts {
|
||||
out.push((c.id.clone(), a));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
// Named as the books keep them, or as the settings file spells them.
|
||||
let label =
|
||||
|key: &'static str, in_file: &'static str| if cfg.journaled { key } else { in_file };
|
||||
let how = if cfg.journaled {
|
||||
" with books.set_setting"
|
||||
} else {
|
||||
" in the settings"
|
||||
};
|
||||
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 the settings"
|
||||
"{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}"
|
||||
)),
|
||||
Some(a) if !want.contains(&a.account_type) => out.push(format!(
|
||||
"{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting} in the settings",
|
||||
"{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting}{how}",
|
||||
a.name,
|
||||
a.account_type,
|
||||
want.iter().map(|t| format!("{t:?}")).collect::<Vec<_>>().join(" or "),
|
||||
@@ -939,34 +1064,71 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
|
||||
};
|
||||
use AccountType::*;
|
||||
for code in &cfg.roles.cash {
|
||||
check("[books] cash_accounts", code, &[Asset]);
|
||||
check(
|
||||
label("books.cash_accounts", "[books] cash_accounts"),
|
||||
code,
|
||||
&[Asset],
|
||||
);
|
||||
}
|
||||
for code in &cfg.roles.financing {
|
||||
check("[books] financing_accounts", code, &[Liability, Equity]);
|
||||
check(
|
||||
label("books.financing_accounts", "[books] financing_accounts"),
|
||||
code,
|
||||
&[Liability, Equity],
|
||||
);
|
||||
}
|
||||
for code in &cfg.roles.investing {
|
||||
check("[books] investing_accounts", code, &[Asset]);
|
||||
check(
|
||||
label("books.investing_accounts", "[books] investing_accounts"),
|
||||
code,
|
||||
&[Asset],
|
||||
);
|
||||
}
|
||||
let c = &cfg.company;
|
||||
for m in &c.members {
|
||||
check(
|
||||
&format!("[[company.members]] {} capital_account", m.name),
|
||||
&format!(
|
||||
"{} {} capital_account",
|
||||
label("company.members", "[[company.members]]"),
|
||||
m.name
|
||||
),
|
||||
&m.capital_account,
|
||||
&[Equity],
|
||||
);
|
||||
if let Some(d) = &m.distribution_account {
|
||||
check(
|
||||
&format!("[[company.members]] {} distribution_account", m.name),
|
||||
&format!(
|
||||
"{} {} distribution_account",
|
||||
label("company.members", "[[company.members]]"),
|
||||
m.name
|
||||
),
|
||||
d,
|
||||
&[Equity],
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(code) = &c.close_into {
|
||||
check("[books] close_into", code, &[Equity]);
|
||||
check(
|
||||
label("books.close_into", "[books] close_into"),
|
||||
code,
|
||||
&[Equity],
|
||||
);
|
||||
}
|
||||
for code in &c.close_sweep {
|
||||
check("[books] close_sweep", code, &[Equity]);
|
||||
check(
|
||||
label("books.close_sweep", "[books] close_sweep"),
|
||||
code,
|
||||
&[Equity],
|
||||
);
|
||||
}
|
||||
// Conventions naming an account no longer in the chart (renumbered or
|
||||
// never there): a template can't draft, a rule covers nothing.
|
||||
for (id, code) in convention_codes(ledger) {
|
||||
if ledger.chart().find_by_code(&code).is_none() {
|
||||
out.push(format!(
|
||||
"convention {id} names account {code}, which isn't in the chart: revise it with books.set_convention"
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1218,6 +1380,24 @@ fn resource_list() -> Vec<Resource> {
|
||||
r
|
||||
};
|
||||
let resources = vec![
|
||||
mk(
|
||||
"books://start",
|
||||
"start",
|
||||
"Start here",
|
||||
"The books in brief: rules in force, settings, conventions, open items, next steps (books.start).",
|
||||
),
|
||||
mk(
|
||||
"books://settings",
|
||||
"settings",
|
||||
"Settings",
|
||||
"Every setting the books know, its value and who set it (books.settings).",
|
||||
),
|
||||
mk(
|
||||
"books://conventions",
|
||||
"conventions",
|
||||
"Conventions",
|
||||
"How this company keeps its books: templates, rules, policies, close steps (books.conventions).",
|
||||
),
|
||||
mk(
|
||||
"books://chart",
|
||||
"chart",
|
||||
@@ -1925,7 +2105,7 @@ fn party_required(what: &str) -> String {
|
||||
serde_json::json!({
|
||||
"code": "party_required",
|
||||
"message": format!(
|
||||
"these books require a counterparty on every entry ([books] parties = \"require\"): {what} (add the party with books.add_party first if it's new)"
|
||||
"these books require a counterparty on every entry (their parties setting is \"require\"): {what} (add the party with books.add_party first if it's new)"
|
||||
),
|
||||
})
|
||||
.to_string()
|
||||
@@ -2274,7 +2454,7 @@ fn cash_flow_statement(
|
||||
let cash_accounts = resolve(cash_codes, "cash")?;
|
||||
if cash_accounts.is_empty() {
|
||||
return Err(
|
||||
"cash_accounts is required for the cash flow statement (pass it, or set [books] cash_accounts in the settings)"
|
||||
"cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -2313,10 +2493,38 @@ fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDa
|
||||
}
|
||||
}
|
||||
|
||||
/// Today in the books' local time zone. Not UTC: in the evening (US
|
||||
/// time) UTC is already tomorrow, and "through today" must not include it.
|
||||
thread_local! {
|
||||
/// The company's time zone for the request running on this thread
|
||||
/// (set at its start from the settings in force: one version per
|
||||
/// request), so every "today" in it agrees.
|
||||
static TIMEZONE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Poll `fut` with "today" in the request's zone on this thread, each
|
||||
/// time it runs: the request is driven on its own thread (run_blocking),
|
||||
/// so the zone is the request's alone. It starts as `timezone`; when the
|
||||
/// request locks the books the zone in force then replaces it (lock), and
|
||||
/// that carries over to the request's later polls.
|
||||
async fn in_timezone_async<T>(
|
||||
timezone: Option<String>,
|
||||
fut: impl std::future::Future<Output = T>,
|
||||
) -> T {
|
||||
let mut fut = std::pin::pin!(fut);
|
||||
let mut zone = timezone;
|
||||
std::future::poll_fn(move |cx| {
|
||||
let before = TIMEZONE.with(|z| z.replace(zone.take()));
|
||||
let polled = fut.as_mut().poll(cx);
|
||||
zone = TIMEZONE.with(|z| z.replace(before));
|
||||
polled
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Today in the company's time zone (`company.timezone`, for the request
|
||||
/// running), else the computer's. Not UTC: in the evening (US time) UTC
|
||||
/// is already tomorrow, and "through today" must not include it.
|
||||
fn today() -> NaiveDate {
|
||||
chrono::Local::now().date_naive()
|
||||
TIMEZONE.with(|z| ledger_core::settings::today_in(z.borrow().as_deref()))
|
||||
}
|
||||
|
||||
/// The `since` of a `books://digest` read: the query's `since=`, or, bare
|
||||
@@ -2454,7 +2662,11 @@ fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec<StatementLineOut> {
|
||||
// Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tool_router]
|
||||
#[path = "tools/kept.rs"]
|
||||
mod kept;
|
||||
pub use kept::SettingKeyOut;
|
||||
|
||||
#[tool_router(router = core_router)]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.handbook",
|
||||
@@ -2949,10 +3161,35 @@ impl BooksServer {
|
||||
no_party_reason,
|
||||
party_missing,
|
||||
party_suggestions,
|
||||
approvals_missing: store
|
||||
.ledger()
|
||||
.approvals_missing(p.entry_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|(rule, approver)| ApprovalNeededOut { rule, approver })
|
||||
.collect(),
|
||||
};
|
||||
if !p.confirmed.unwrap_or(false) {
|
||||
return Ok(Json(out));
|
||||
}
|
||||
if !out.approvals_missing.is_empty() {
|
||||
let missing: Vec<String> = out
|
||||
.approvals_missing
|
||||
.iter()
|
||||
.map(|a| format!("{} (rule {})", a.approver, a.rule))
|
||||
.collect();
|
||||
return Err(serde_json::json!({
|
||||
"code": "approval_required",
|
||||
"message": format!(
|
||||
"entry {} needs approval before it posts (policy.approval_rules is enforce): {}. Ask each approver; when they approve, record it with books.approve_entry, then post.",
|
||||
p.entry_id,
|
||||
missing.join("; ")
|
||||
),
|
||||
"entry_id": p.entry_id,
|
||||
"remedy_tool": "books.approve_entry",
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
if let Some(why) = &out.parties_error {
|
||||
return Err(serde_json::json!({
|
||||
"code": "party_shares",
|
||||
@@ -2974,8 +3211,9 @@ impl BooksServer {
|
||||
return Err(serde_json::json!({
|
||||
"code": "party_required",
|
||||
"message": format!(
|
||||
"entry {} names no counterparty, and these books require one ([books] parties = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}",
|
||||
"entry {} names no counterparty, and these books require one ({} = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}",
|
||||
p.entry_id,
|
||||
self.setting_label("policy.parties", "[books] parties"),
|
||||
if names.is_empty() { String::new() } else { format!(" Likely: {}.", names.join("; ")) }
|
||||
),
|
||||
"entry_id": p.entry_id,
|
||||
@@ -4747,7 +4985,11 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(RenumberAccountOut {
|
||||
id,
|
||||
config_mentions: self.config_mentions(&previous_code),
|
||||
config_mentions: self
|
||||
.config_mentions(&previous_code)
|
||||
.into_iter()
|
||||
.chain(convention_mentions(store.ledger(), &previous_code))
|
||||
.collect(),
|
||||
previous_code,
|
||||
code: p.code,
|
||||
}))
|
||||
@@ -4972,15 +5214,26 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.settings",
|
||||
title = "Settings in force",
|
||||
description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target), where they come from, and problems: settings that disagree with the chart, each saying what to change. The server reloads them whenever they change, with no restart; after a change, call this to confirm it loaded (reload_error says why one didn't, and the last good settings stay in force).",
|
||||
description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target, time zone, approval rules), every key the books know with its value and who set it, where they come from (source: \"books\", changed with books.set_setting; or \"file\", a settings file still read, reloaded on change: reload_error says why an edit didn't load), and problems: settings or conventions that disagree with the chart, each saying what to change.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn settings_tool(&self) -> Result<Json<SettingsOut>, String> {
|
||||
// The books first: their settings sync as they lock, so the
|
||||
// status and the settings read after are one version.
|
||||
let store = self.lock()?;
|
||||
let status = self.live().status();
|
||||
let cfg = self.settings();
|
||||
let store = self.lock()?;
|
||||
let c = &cfg.company;
|
||||
Ok(Json(SettingsOut {
|
||||
source: if status.journaled { "books" } else { "file" }.to_string(),
|
||||
timezone: cfg.timezone.clone(),
|
||||
today: today().to_string(),
|
||||
approval_rules: match store.ledger().settings().approval_rules() {
|
||||
ledger_core::settings::ApprovalMode::Off => "off",
|
||||
ledger_core::settings::ApprovalMode::Enforce => "enforce",
|
||||
}
|
||||
.to_string(),
|
||||
keys: kept::keys_out(&self.settings_as_kept(&store)),
|
||||
books_toml: status.books_toml.map(|p| p.display().to_string()),
|
||||
books_toml_exists: status.books_toml_exists,
|
||||
config_json_present: status.config_json_present,
|
||||
@@ -9032,6 +9285,20 @@ impl BooksServer {
|
||||
None => (uri, ""),
|
||||
};
|
||||
match path {
|
||||
"books://start" => {
|
||||
let Json(out) = self.start(Parameters(kept::StartParams::default())).await?;
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
"books://settings" => {
|
||||
let Json(out) = self.settings_tool().await?;
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
"books://conventions" => {
|
||||
let Json(out) = self
|
||||
.conventions(Parameters(kept::ConventionsParams::default()))
|
||||
.await?;
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
"books://chart" => {
|
||||
let Json(out) = self.chart().await?;
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
@@ -9097,12 +9364,14 @@ impl ServerHandler for BooksServer {
|
||||
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
|
||||
// Pick up an edited settings file before the tool runs.
|
||||
self.live().refresh();
|
||||
let timezone = self.settings().timezone.clone();
|
||||
let server = self.clone();
|
||||
let result = run_blocking(&self.store, move || {
|
||||
Box::pin(async move {
|
||||
let tcc =
|
||||
rmcp::handler::server::tool::ToolCallContext::new(&server, request, context);
|
||||
Self::tool_router().call(tcc).await
|
||||
let router = Self::tool_router();
|
||||
in_timezone_async(timezone, router.call(tcc)).await
|
||||
})
|
||||
})
|
||||
.await;
|
||||
@@ -9120,7 +9389,14 @@ impl ServerHandler for BooksServer {
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerConfig {
|
||||
// A client connecting after a settings change sees the change.
|
||||
// A client connecting after a settings change sees the change:
|
||||
// the books' own when they keep them (never waiting for the books
|
||||
// here; a request holding them syncs them when it locks), else the
|
||||
// files'.
|
||||
if let Ok(store) = self.store.store.try_lock() {
|
||||
self.live()
|
||||
.sync(store.ledger().settings(), self.store.currency.code());
|
||||
}
|
||||
self.live().refresh();
|
||||
ServerConfig::new(
|
||||
ServerCapabilities::builder()
|
||||
@@ -9151,11 +9427,12 @@ impl ServerHandler for BooksServer {
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
|
||||
self.live().refresh();
|
||||
let timezone = self.settings().timezone.clone();
|
||||
let uri = request.uri.clone();
|
||||
let server = self.clone();
|
||||
let at = uri.clone();
|
||||
let text = run_blocking(&self.store, move || {
|
||||
Box::pin(async move { server.resource_text(&at).await })
|
||||
Box::pin(async move { in_timezone_async(timezone, server.resource_text(&at)).await })
|
||||
})
|
||||
.await?;
|
||||
// A refusal quotes the URI it was given: masked like a tool's.
|
||||
@@ -9393,7 +9670,7 @@ mod tests {
|
||||
..Company::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(solo.config_mentions("3000"), ["[company] close_into"]);
|
||||
assert_eq!(solo.config_mentions("3000"), ["[books] close_into"]);
|
||||
}
|
||||
|
||||
/// Reports name each member with their accounts, in order.
|
||||
@@ -9570,8 +9847,22 @@ mod tests {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||
let table: [(&str, Hints); 94] = [
|
||||
let table: [(&str, Hints); 108] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
("books.set_setting", DI),
|
||||
("books.import_settings", DI),
|
||||
("books.export_settings", R),
|
||||
("books.conventions", R),
|
||||
("books.set_convention", W),
|
||||
("books.retire_convention", D),
|
||||
("books.record_decision", W),
|
||||
("books.decisions", R),
|
||||
("books.raise_item", W),
|
||||
("books.resolve_item", W),
|
||||
("books.open_items", R),
|
||||
("books.draft_from_template", W),
|
||||
("books.approve_entry", W),
|
||||
("books.chart", R),
|
||||
("books.add_account", W),
|
||||
("books.trial_balance", R),
|
||||
@@ -9703,8 +9994,8 @@ mod tests {
|
||||
"{name} has no openWorldHint false"
|
||||
);
|
||||
}
|
||||
// docs/mcp-interface.md names the 25 destructive tools.
|
||||
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 25);
|
||||
// docs/mcp-interface.md names the 28 destructive tools.
|
||||
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 28);
|
||||
}
|
||||
|
||||
/// Every refusal an agent can be given whose text is fixed here, or
|
||||
@@ -9801,6 +10092,66 @@ mod tests {
|
||||
] {
|
||||
out.push((what.to_string(), text.to_string()));
|
||||
}
|
||||
// Refusals and notes built inline, where the books' own record is
|
||||
// kept: every string literal of these sources outside their tests.
|
||||
for (what, src) in [
|
||||
("tools/kept.rs", include_str!("tools/kept.rs")),
|
||||
(
|
||||
"ledger-config codec",
|
||||
include_str!("../../ledger-config/src/codec.rs"),
|
||||
),
|
||||
(
|
||||
"ledger-core settings",
|
||||
include_str!("../../ledger-core/src/settings.rs"),
|
||||
),
|
||||
(
|
||||
"ledger-core conventions",
|
||||
include_str!("../../ledger-core/src/conventions.rs"),
|
||||
),
|
||||
(
|
||||
"ledger-core ledger",
|
||||
include_str!("../../ledger-core/src/ledger.rs"),
|
||||
),
|
||||
("settings.rs", include_str!("settings.rs")),
|
||||
] {
|
||||
let code = src.split("#[cfg(test)]").next().unwrap_or(src);
|
||||
let literals = string_literals(code);
|
||||
assert!(!literals.is_empty(), "{what}: no literals found");
|
||||
out.extend(literals.into_iter().map(|l| (format!("{what} literal"), l)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text of every plain string literal ("...") in Rust source,
|
||||
/// skipping comments (a rough scanner: enough for the lint).
|
||||
fn string_literals(src: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for line in src.lines() {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
let mut chars = line.chars().peekable();
|
||||
let mut cur: Option<String> = None;
|
||||
while let Some(c) = chars.next() {
|
||||
match (&mut cur, c) {
|
||||
(None, '"') => cur = Some(String::new()),
|
||||
(None, '/') if chars.peek() == Some(&'/') => break,
|
||||
(Some(s), '\\') => {
|
||||
if let Some(n) = chars.next() {
|
||||
s.push('\\');
|
||||
s.push(n);
|
||||
}
|
||||
}
|
||||
(Some(s), '"') => {
|
||||
out.push(std::mem::take(s));
|
||||
cur = None;
|
||||
}
|
||||
(Some(s), c) => s.push(c),
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
//! The server's settings (`wanderbooks.toml`, and the receipt policy in
|
||||
//! `books-data/config.json`), reloaded whenever their files change.
|
||||
//! The server's settings: the books' own (journaled, ledger_core::
|
||||
//! settings), or, for books that hold none yet, the settings files
|
||||
//! (`wanderbooks.toml`, and the receipt policy in `books-data/config.json`)
|
||||
//! reloaded whenever they 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.
|
||||
//! GOAL: every request sees the settings in force, one version for the
|
||||
//! whole request; books that keep their own settings never read a
|
||||
//! file; a broken file edit never takes the server down.
|
||||
//! SOURCE: plan/served.md I-3 (the journal holds all books state; the
|
||||
//! server never reads a settings file for books that hold their
|
||||
//! settings), 2.1 ("dev-stdio keeps legacy_files mode until a book
|
||||
//! is adopted"); DECISION (Stephen, 2026-10-15): file settings are
|
||||
//! 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,
|
||||
//! 1. Books whose journal holds settings (any settings.set) use those,
|
||||
//! and from then on no file is read: sync takes them whenever the
|
||||
//! journal's last settings.set changes.
|
||||
//! 2. Otherwise every request (tool call, resource read) sees the files'
|
||||
//! contents as of its start: a request after an edit sees the edit
|
||||
//! (compared by content, not by timestamp, so two edits in one second
|
||||
//! are both seen).
|
||||
//! 3. 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
|
||||
//! 4. 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.
|
||||
//! named with --books-toml that goes missing is a failure (3).
|
||||
//! 5. Only the settings change while serving: the data dir, the books'
|
||||
//! base currency, and a vault key file given on the command line are
|
||||
//! fixed when the server starts (the key file given wins over any
|
||||
//! file's [vault] key_file).
|
||||
//! ```
|
||||
|
||||
use crate::policy::ReceiptPolicy;
|
||||
@@ -56,6 +67,11 @@ pub struct Settings {
|
||||
/// `[books] parties`: suggest, require, or never mention each entry's
|
||||
/// counterparty.
|
||||
pub parties: ledger_config::PartyPolicy,
|
||||
/// The company's time zone (an IANA name), if set: what "today" means.
|
||||
pub timezone: Option<String>,
|
||||
/// Whether these are the books' own settings (journaled) rather than a
|
||||
/// file's.
|
||||
pub journaled: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -67,6 +83,26 @@ impl Settings {
|
||||
.unwrap_or_else(ledger_vault::default_key_file)
|
||||
}
|
||||
|
||||
/// The settings the books keep (`ledger_core::settings`), as the
|
||||
/// server uses them.
|
||||
pub fn from_books(books: &ledger_core::settings::Settings, currency: &str) -> Self {
|
||||
let b = ledger_config::codec::books_settings(books, currency);
|
||||
Settings {
|
||||
policy: ledger_config::codec::receipt_policy(books),
|
||||
asset_tag_prefix: b.asset_tag_prefix(),
|
||||
vault_key_file: None,
|
||||
parties: b.parties,
|
||||
timezone: b.timezone.clone(),
|
||||
roles: CashRoles {
|
||||
cash: b.cash_accounts,
|
||||
financing: b.financing_accounts,
|
||||
investing: b.investing_accounts,
|
||||
},
|
||||
company: b.company,
|
||||
journaled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix for generated asset tags ("FA" when none is set).
|
||||
pub fn tag_prefix(&self) -> &str {
|
||||
if self.asset_tag_prefix.is_empty() {
|
||||
@@ -114,6 +150,8 @@ impl Source {
|
||||
asset_tag_prefix: books.asset_tag_prefix(),
|
||||
vault_key_file: books.vault_key_file.clone(),
|
||||
parties: books.parties,
|
||||
timezone: books.timezone.clone(),
|
||||
journaled: false,
|
||||
policy,
|
||||
roles: CashRoles {
|
||||
cash: books.cash_accounts,
|
||||
@@ -133,6 +171,13 @@ pub struct Live {
|
||||
seen: Seen,
|
||||
error: Option<String>,
|
||||
loads: u64,
|
||||
/// The seq of the books' last settings.set taken (INVARIANT 1).
|
||||
journal_seq: Option<u64>,
|
||||
/// A vault key file given on the command line (INVARIANT 5).
|
||||
vault_key_file: Option<PathBuf>,
|
||||
/// Whether the settings files may be read: false once the books keep
|
||||
/// their own (INVARIANT 1).
|
||||
read_files: bool,
|
||||
}
|
||||
|
||||
/// What books.settings reports about the files.
|
||||
@@ -150,6 +195,9 @@ pub struct Status {
|
||||
pub error: Option<String>,
|
||||
/// How many times the settings have loaded, the first included.
|
||||
pub loads: u64,
|
||||
/// Whether the settings are the books' own (journaled): then no file
|
||||
/// is read.
|
||||
pub journaled: bool,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
@@ -161,6 +209,9 @@ impl Live {
|
||||
seen: (None, None),
|
||||
error: None,
|
||||
loads: 1,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +226,79 @@ impl Live {
|
||||
seen,
|
||||
error: None,
|
||||
loads: 1,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// For books that keep their own settings: nothing is read from
|
||||
/// `source` (INVARIANT 1), not even at start, so a broken settings
|
||||
/// file beside them can't stop the server; `sync` brings the books'
|
||||
/// settings in.
|
||||
pub fn journaled(source: Source) -> Self {
|
||||
Self {
|
||||
current: Arc::new(Settings {
|
||||
journaled: true,
|
||||
..Settings::default()
|
||||
}),
|
||||
source: Some(source),
|
||||
seen: (None, None),
|
||||
error: None,
|
||||
loads: 0,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Use this vault key file whatever the settings say (INVARIANT 5).
|
||||
pub fn with_vault_key_file(mut self, path: Option<PathBuf>) -> Self {
|
||||
self.vault_key_file = path;
|
||||
if let Some(p) = &self.vault_key_file {
|
||||
Arc::make_mut(&mut self.current).vault_key_file = Some(p.clone());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Take the books' own settings when they hold any and they changed
|
||||
/// since the last look (INVARIANT 1). The server calls this whenever
|
||||
/// it holds the books.
|
||||
pub fn sync(&mut self, books: &ledger_core::settings::Settings, currency: &str) {
|
||||
if !books.is_journaled() || books.last_seq() == self.journal_seq {
|
||||
return;
|
||||
}
|
||||
let mut s = Settings::from_books(books, currency);
|
||||
s.vault_key_file = self.vault_key_file.clone();
|
||||
self.current = Arc::new(s);
|
||||
self.journal_seq = books.last_seq();
|
||||
self.read_files = false;
|
||||
self.error = None;
|
||||
self.loads += 1;
|
||||
}
|
||||
|
||||
/// The settings files' texts as the server reads them now
|
||||
/// (`wanderbooks.toml`, `config.json`; `None` for one absent), when the
|
||||
/// settings come from files; `None` when the books keep their own or
|
||||
/// the settings were built in code.
|
||||
pub fn file_texts(&self) -> Option<(Option<String>, Option<String>)> {
|
||||
let source = self.source.as_ref().filter(|_| self.read_files)?;
|
||||
Some((
|
||||
std::fs::read_to_string(&source.books_toml).ok(),
|
||||
std::fs::read_to_string(&source.config_json).ok(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The vault key file given on the command line, if any.
|
||||
pub fn vault_key_override(&self) -> Option<PathBuf> {
|
||||
self.vault_key_file.clone()
|
||||
}
|
||||
|
||||
/// Whether the settings in force are the books' own.
|
||||
pub fn is_journaled(&self) -> bool {
|
||||
self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
|
||||
}
|
||||
|
||||
/// The settings in force.
|
||||
pub fn current(&self) -> Arc<Settings> {
|
||||
self.current.clone()
|
||||
@@ -187,12 +308,20 @@ impl Live {
|
||||
/// once at the start of every request, so the whole request sees one
|
||||
/// version.
|
||||
pub fn refresh(&mut self) {
|
||||
if !self.read_files {
|
||||
// The books keep their own settings (or the settings were
|
||||
// built in code): no file is read (INVARIANT 1).
|
||||
return;
|
||||
}
|
||||
if let Some(source) = &self.source {
|
||||
let now = source.read();
|
||||
if now != self.seen {
|
||||
self.seen = now;
|
||||
match source.load() {
|
||||
Ok(s) => {
|
||||
Ok(mut s) => {
|
||||
if let Some(p) = &self.vault_key_file {
|
||||
s.vault_key_file = Some(p.clone());
|
||||
}
|
||||
self.current = Arc::new(s);
|
||||
self.error = None;
|
||||
self.loads += 1;
|
||||
@@ -219,12 +348,18 @@ impl Live {
|
||||
/// Where the settings come from and whether the last change loaded.
|
||||
pub fn status(&mut self) -> Status {
|
||||
self.refresh();
|
||||
let journaled = self.is_journaled();
|
||||
Status {
|
||||
books_toml: self.source.as_ref().map(|s| s.books_toml.clone()),
|
||||
books_toml: self
|
||||
.source
|
||||
.as_ref()
|
||||
.filter(|_| !journaled)
|
||||
.map(|s| s.books_toml.clone()),
|
||||
books_toml_exists: self.source.as_ref().is_some_and(|s| s.books_toml.exists()),
|
||||
config_json_present: self.source.as_ref().is_some_and(|s| s.config_json.exists()),
|
||||
error: self.error.clone(),
|
||||
loads: self.loads,
|
||||
journaled,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,4 +462,67 @@ mod tests {
|
||||
let s = live.status();
|
||||
assert_eq!((s.loads, s.books_toml), (1, None));
|
||||
}
|
||||
|
||||
/// Books that keep their own settings: no file is read, even a broken
|
||||
/// one; their settings come in once per change (INVARIANT 1).
|
||||
#[test]
|
||||
fn journaled_books_take_their_own_settings_once_per_change() {
|
||||
let d = dir("journaled");
|
||||
std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap();
|
||||
let mut live = Live::journaled(source(&d, true));
|
||||
assert!(live.current().journaled);
|
||||
assert!(live.is_journaled());
|
||||
assert!(live.file_texts().is_none());
|
||||
let mut books = ledger_core::settings::Settings::default();
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.status().loads, 0, "books without settings");
|
||||
let src = ledger_core::settings::Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
let change = |name: &str| ledger_core::settings::Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(serde_json::json!(name)),
|
||||
};
|
||||
books.apply(&[change("Kept Co")], &src, 7).unwrap();
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.current().company.name, "Kept Co");
|
||||
assert_eq!(live.status().loads, 1);
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.status().loads, 1, "unchanged: not taken again");
|
||||
books.apply(&[change("Kept Co 2")], &src, 9).unwrap();
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.current().company.name, "Kept Co 2");
|
||||
assert_eq!(live.status().loads, 2);
|
||||
assert!(live.current().journaled && live.is_journaled());
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
/// Settings built in code are the books' own once synced.
|
||||
#[test]
|
||||
fn fixed_settings_synced_are_journaled() {
|
||||
let mut live = Live::fixed(Settings::default());
|
||||
assert!(!live.is_journaled());
|
||||
let mut books = ledger_core::settings::Settings::default();
|
||||
let src = ledger_core::settings::Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
books
|
||||
.apply(
|
||||
&[ledger_core::settings::Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(serde_json::json!("Kept Co")),
|
||||
}],
|
||||
&src,
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
live.sync(&books, "USD");
|
||||
assert!(live.is_journaled());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -562,8 +562,21 @@ pub struct MemberSettingOut {
|
||||
/// 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).
|
||||
/// Where the settings come from: "books" (the books keep them; change
|
||||
/// them with books.set_setting) or "file" (a settings file these books
|
||||
/// still read: move it in with books.import_settings).
|
||||
pub source: String,
|
||||
/// The company's time zone (IANA), or null: the computer's.
|
||||
pub timezone: Option<String>,
|
||||
/// Today, in that time zone.
|
||||
pub today: String,
|
||||
/// Whether approval rules block posting: "off" or "enforce".
|
||||
pub approval_rules: String,
|
||||
/// Every setting the books know: what it takes, its value (null:
|
||||
/// unset, its default applies), and who set it.
|
||||
pub keys: Vec<crate::server::SettingKeyOut>,
|
||||
/// The wanderbooks.toml the server reads (null: the books keep their
|
||||
/// own settings, or settings built in).
|
||||
pub books_toml: Option<String>,
|
||||
/// Whether that file exists (absent: the defaults apply).
|
||||
pub books_toml_exists: bool,
|
||||
@@ -1192,6 +1205,21 @@ pub struct PostEntryOut {
|
||||
/// with `books.set_entry_party`, or add the party with
|
||||
/// `books.add_party` first.
|
||||
pub party_suggestions: Vec<PartySuggestionOut>,
|
||||
/// Approvals the entry still needs before it posts, when the books
|
||||
/// enforce approval rules (`policy.approval_rules = enforce`): each
|
||||
/// rule covering it and its approver. Empty otherwise. Record each
|
||||
/// with `books.approve_entry` once the approver says yes.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub approvals_missing: Vec<ApprovalNeededOut>,
|
||||
}
|
||||
|
||||
/// An approval an entry needs.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ApprovalNeededOut {
|
||||
/// The approval rule (a convention id).
|
||||
pub rule: String,
|
||||
/// Who approves.
|
||||
pub approver: String,
|
||||
}
|
||||
|
||||
/// A likely counterparty for an entry.
|
||||
|
||||
@@ -156,7 +156,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: the narrow tools; chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 94, "expected 94 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len());
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
//! P1 through the real server: settings moving into the books, the books'
|
||||
//! own settings, conventions, approvals, books.start (plan/served.md P1).
|
||||
//! Drives the real `ledger-mcp` binary over stdio with raw JSON-RPC.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<std::process::ChildStdout>,
|
||||
next_id: u64,
|
||||
child: Child,
|
||||
}
|
||||
|
||||
/// Closing stdin ends the server; wait for it to exit, so the next server
|
||||
/// on the same books never races it for the writer lock.
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn spawn(data_dir: &std::path::Path) -> Self {
|
||||
Self::spawn_with(data_dir, &[])
|
||||
}
|
||||
|
||||
fn spawn_with(data_dir: &std::path::Path, extra: &[&std::ffi::OsStr]) -> Self {
|
||||
Self::spawn_env(data_dir, extra, &[])
|
||||
}
|
||||
|
||||
fn spawn_env(
|
||||
data_dir: &std::path::Path,
|
||||
extra: &[&std::ffi::OsStr],
|
||||
env: &[(&str, &std::path::Path)],
|
||||
) -> Self {
|
||||
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
|
||||
let mut child = Command::new(bin)
|
||||
.arg("--data-dir")
|
||||
.arg(data_dir)
|
||||
.args(extra)
|
||||
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
Self {
|
||||
stdin,
|
||||
stdout,
|
||||
next_id: 1,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
self.stdout
|
||||
.read_line(&mut line)
|
||||
.expect("read response line");
|
||||
let resp: Value = serde_json::from_str(&line).expect("parse response");
|
||||
assert_eq!(resp["id"], id, "id mismatch in {resp}");
|
||||
assert!(
|
||||
resp.get("error").is_none(),
|
||||
"JSON-RPC error for {method}: {}",
|
||||
resp["error"]
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
fn notify(&mut self, method: &str) {
|
||||
let msg = json!({"jsonrpc": "2.0", "method": method, "params": {}});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
}
|
||||
|
||||
/// Call a tool; panic on tool error. Returns structuredContent.
|
||||
fn call(&mut self, tool: &str, args: Value) -> Value {
|
||||
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
|
||||
let result = &resp["result"];
|
||||
assert_ne!(
|
||||
result.get("isError"),
|
||||
Some(&json!(true)),
|
||||
"tool {tool} returned an error: {}",
|
||||
result["content"]
|
||||
);
|
||||
result.get("structuredContent").cloned().unwrap_or_else(|| {
|
||||
let text = result["content"][0]["text"].as_str().unwrap();
|
||||
serde_json::from_str(text).unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
/// Call a tool expecting an error; returns the error text.
|
||||
fn call_err(&mut self, tool: &str, args: Value) -> String {
|
||||
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
|
||||
let result = &resp["result"];
|
||||
assert_eq!(
|
||||
result.get("isError"),
|
||||
Some(&json!(true)),
|
||||
"expected {tool} to fail, got {result}"
|
||||
);
|
||||
result["content"][0]["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-mcp-test-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn init(m: &mut Mcp) -> String {
|
||||
let r = m.request(
|
||||
"initialize",
|
||||
json!({"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "spec-kept", "version": "0"}}),
|
||||
);
|
||||
m.notify("notifications/initialized");
|
||||
r["result"]["instructions"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
const TOML: &str = r#"[company]
|
||||
name = "Kept Co LLC"
|
||||
tax_classification = "partnership" # Form 1065; a Schedule K-1 per member
|
||||
fiscal_year_start = "01-01"
|
||||
ein = "43-2215893"
|
||||
|
||||
[[company.members]]
|
||||
name = "A"
|
||||
share = "50%"
|
||||
capital_account = "3000"
|
||||
|
||||
[[company.members]]
|
||||
name = "B"
|
||||
share = "50%"
|
||||
capital_account = "3001"
|
||||
|
||||
[books]
|
||||
data_dir = "books-data"
|
||||
receipt_threshold = "0.00"
|
||||
cash_accounts = ["1000"]
|
||||
parties = "require"
|
||||
|
||||
[viewer]
|
||||
open_at_start = "ask"
|
||||
"#;
|
||||
|
||||
fn chart(m: &mut Mcp) {
|
||||
for (code, name, t) in [
|
||||
("1000", "Bank", "asset"),
|
||||
("3000", "Capital A", "equity"),
|
||||
("3001", "Capital B", "equity"),
|
||||
("6000", "Software", "expense"),
|
||||
] {
|
||||
m.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": t}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings file moves into the books, once, with the human's go: set
|
||||
/// is refused before (it would drop the rest of the file); the preview
|
||||
/// changes nothing; after, the file is never read, even to start, and the
|
||||
/// company's EIN only ever leaves the server masked.
|
||||
#[test]
|
||||
fn a_settings_file_moves_into_the_books() {
|
||||
let dir = test_dir("kept-move");
|
||||
let data = dir.join("books-data");
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
let told = init(&mut m);
|
||||
assert!(told.contains("books.start"), "{told}");
|
||||
assert!(
|
||||
told.contains("the settings file books.settings names"),
|
||||
"{told}"
|
||||
);
|
||||
chart(&mut m);
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "file");
|
||||
let start = m.call("books.start", json!({}));
|
||||
assert_eq!(start["settings_source"], "file");
|
||||
assert!(
|
||||
start["next_steps"]
|
||||
.to_string()
|
||||
.contains("books.import_settings")
|
||||
);
|
||||
let refused = m.call_err(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.cpa", "value": "Krista"}], "decided_by": "A"}),
|
||||
);
|
||||
assert!(refused.contains("books.import_settings"), "{refused}");
|
||||
// The preview records nothing.
|
||||
let head = |m: &mut Mcp| m.call("books.start", json!({}))["head"]["seq"].clone();
|
||||
let before = head(&mut m);
|
||||
let preview = m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "note": "Moving in."}),
|
||||
);
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(head(&mut m), before);
|
||||
assert!(
|
||||
preview["notes"].to_string().contains("data_dir"),
|
||||
"{preview}"
|
||||
);
|
||||
assert!(
|
||||
preview["unknown"].as_array().unwrap().is_empty(),
|
||||
"{preview}"
|
||||
);
|
||||
// The file's comments are listed, never dropped unseen.
|
||||
assert_eq!(
|
||||
preview["comments"],
|
||||
json!(["Form 1065; a Schedule K-1 per member"]),
|
||||
"{preview}"
|
||||
);
|
||||
let shown = preview.to_string();
|
||||
assert!(!shown.contains("2215893"), "the EIN is masked: {shown}");
|
||||
assert!(shown.contains("**-***5893"), "{shown}");
|
||||
let done = m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(done["status"], "imported");
|
||||
let settings = m.call("books.settings", json!({}));
|
||||
assert_eq!(settings["source"], "books");
|
||||
assert_eq!(settings["books_toml"], Value::Null);
|
||||
assert_eq!(settings["parties"], "require");
|
||||
let ein = settings["keys"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|k| k["key"] == "company.ein")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(ein["value"], "**-***5893");
|
||||
assert_eq!(ein["set_by"]["decided_by"], "A");
|
||||
// The file is no longer read: an edit changes nothing.
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML.replace("0.00", "99.00")).unwrap();
|
||||
let rule = m.call("books.settings", json!({}))["receipt_rule"].clone();
|
||||
assert!(rule.as_str().unwrap().contains("every entry"), "{rule}");
|
||||
// Settings change through the tools now.
|
||||
let set = m.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"},
|
||||
{"key": "company.ein", "value": "12-3456789"}],
|
||||
"decided_by": "B", "decided_on": "2026-10-17"}),
|
||||
);
|
||||
assert!(!set.to_string().contains("3456789"), "{set}");
|
||||
let export = m.call("books.export_settings", json!({}));
|
||||
let text = export["toml_text"].as_str().unwrap();
|
||||
assert!(text.contains("timezone = \"America/Denver\""), "{text}");
|
||||
assert!(!text.contains("3456789"), "{text}");
|
||||
let start = m.call("books.start", json!({}));
|
||||
assert_eq!(start["settings_source"], "books");
|
||||
assert_eq!(start["book"]["timezone"], "America/Denver");
|
||||
assert!(!start.to_string().contains("3456789"));
|
||||
drop(m);
|
||||
// A broken settings file beside books that keep their own can't stop
|
||||
// the server: it is never read.
|
||||
std::fs::write(dir.join("wanderbooks.toml"), "[books\nthis is not toml").unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
let told = init(&mut m);
|
||||
assert!(
|
||||
told.contains("Settings and conventions live in the books"),
|
||||
"{told}"
|
||||
);
|
||||
assert!(!told.contains("local file you may edit"), "{told}");
|
||||
assert!(told.contains("Kept Co LLC"), "{told}");
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
|
||||
assert_eq!(m.call("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// INVARIANT 2 (tools/kept.rs): an import that would read any setting
|
||||
/// differently than the server does now is refused. Here the server reads
|
||||
/// its receipt policy from config.json, which the import must carry.
|
||||
#[test]
|
||||
fn an_import_that_reads_differently_is_refused() {
|
||||
let dir = test_dir("kept-differs");
|
||||
let data = dir.join("books-data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
|
||||
let policy = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#;
|
||||
std::fs::write(data.join("config.json"), policy).unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(e.contains("policy.evidence.threshold"), "{e}");
|
||||
assert!(e.contains("config_json_text"), "{e}");
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "file");
|
||||
m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true,
|
||||
"config_json_text": policy}),
|
||||
);
|
||||
let s = m.call("books.settings", json!({}));
|
||||
assert_eq!(s["source"], "books");
|
||||
assert_eq!(s["receipt_exempt_accounts"], json!(["6999"]));
|
||||
}
|
||||
|
||||
/// The vault key a settings file names must come from the command line
|
||||
/// before the settings move in, or the vault would stop opening.
|
||||
#[test]
|
||||
fn an_import_needs_the_vault_key_on_the_command_line() {
|
||||
let dir = test_dir("kept-vault");
|
||||
let data = dir.join("books-data");
|
||||
let key = dir.join("elsewhere").join("vault.key");
|
||||
let toml = format!("{TOML}\n[vault]\nkey_file = \"{}\"\n", key.display());
|
||||
std::fs::write(dir.join("wanderbooks.toml"), &toml).unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": toml, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(e.contains("--vault-key-file"), "{e}");
|
||||
drop(m);
|
||||
let mut m = Mcp::spawn_with(&data, &["--vault-key-file".as_ref(), key.as_os_str()]);
|
||||
init(&mut m);
|
||||
m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": toml, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
|
||||
}
|
||||
|
||||
/// Approval rules through the server: enforced, a covered post is refused
|
||||
/// until its approver approves, then posts; off, they block nothing.
|
||||
#[test]
|
||||
fn approval_rules_through_the_server() {
|
||||
let dir = test_dir("kept-approvals");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "policy.evidence.threshold", "value": "1000000"},
|
||||
{"key": "policy.approval_rules", "value": "enforce"}],
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "big-software", "kind": "approval_rule", "title": "Big software",
|
||||
"body_md": "Software over 500 needs B.",
|
||||
"structured": {"approver": "B", "accounts": ["6000"], "amount_over": "500"},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
let e = m.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-10-01", "description": "Laptop", "no_party_reason": "test"}),
|
||||
)["entry_id"]
|
||||
.clone();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
m.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": e, "account": account, "side": side, "amount": "900.00"}),
|
||||
);
|
||||
}
|
||||
let preview = m.call("books.post_entry", json!({"entry_id": e}));
|
||||
assert_eq!(
|
||||
preview["approvals_missing"],
|
||||
json!([{"rule": "big-software", "approver": "B"}])
|
||||
);
|
||||
let refused = m.call_err(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": e, "confirmed": true}),
|
||||
);
|
||||
assert!(refused.contains("approval_required"), "{refused}");
|
||||
assert!(refused.contains("books.approve_entry"), "{refused}");
|
||||
m.call(
|
||||
"books.approve_entry",
|
||||
json!({"entry_id": e, "approver": "B"}),
|
||||
);
|
||||
let preview = m.call("books.post_entry", json!({"entry_id": e}));
|
||||
assert!(preview.get("approvals_missing").is_none(), "{preview}");
|
||||
m.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": e, "confirmed": true}),
|
||||
);
|
||||
m.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "policy.approval_rules", "value": null}], "decided_by": "A"}),
|
||||
);
|
||||
assert_eq!(
|
||||
m.call("books.start", json!({}))["rules"]["approval_rules"],
|
||||
"off"
|
||||
);
|
||||
}
|
||||
|
||||
/// A posting template drafts through the server, to the cent.
|
||||
#[test]
|
||||
fn templates_draft_through_the_server() {
|
||||
let dir = test_dir("kept-template");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "contribution", "kind": "posting_template",
|
||||
"title": "Joint contribution", "body_md": "Half each.",
|
||||
"structured": {"description": "Contribution", "lines": [
|
||||
{"account": "1000", "side": "debit", "share": "1"},
|
||||
{"account": "3000", "side": "credit", "share": "0.5"},
|
||||
{"account": "3001", "side": "credit", "share": "0.5"}]},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
let d = m.call(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "contribution", "date": "2026-10-02", "amount": "0.03",
|
||||
"no_party_reason": "between the members"}),
|
||||
);
|
||||
assert_eq!(d["description"], "Contribution");
|
||||
let amounts: Vec<&str> = d["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| l["amount"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(amounts, ["0.03", "0.02", "0.01"]);
|
||||
let e = m.call_err(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "contribution", "date": "2026-10-02", "amount": "0.01"}),
|
||||
);
|
||||
assert!(e.contains("too small"), "{e}");
|
||||
// A slot takes the account each draft names.
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "bank-expense", "kind": "posting_template",
|
||||
"title": "Expense paid from the bank", "body_md": "Dr the expense / Cr 1000.",
|
||||
"structured": {"lines": [
|
||||
{"account": "{expense}", "side": "debit", "share": "1"},
|
||||
{"account": "1000", "side": "credit", "share": "1"}]},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
let e = m.call_err(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "bank-expense", "date": "2026-10-02", "amount": "12.00"}),
|
||||
);
|
||||
assert!(e.contains("expense"), "{e}");
|
||||
let d = m.call(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "bank-expense", "date": "2026-10-02", "amount": "12.00",
|
||||
"accounts": {"expense": "6000"}, "no_party_reason": "test"}),
|
||||
);
|
||||
assert_eq!(d["lines"][0]["account"], "6000");
|
||||
assert_eq!(d["lines"][1]["account"], "1000");
|
||||
}
|
||||
|
||||
/// books.start stays at most 8 KB whatever the books hold, and says what
|
||||
/// it left out.
|
||||
#[test]
|
||||
fn start_fits_in_8_kb() {
|
||||
let dir = test_dir("kept-start");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
let body =
|
||||
"A first line long enough to fill its summary to the brim, and then some more words. "
|
||||
.repeat(3);
|
||||
for i in 0..60 {
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": format!("note-{i}"), "kind": "note",
|
||||
"title": format!("A rather long convention title number {i}"),
|
||||
"body_md": body, "decided_by": "A"}),
|
||||
);
|
||||
}
|
||||
for i in 0..40 {
|
||||
m.call(
|
||||
"books.raise_item",
|
||||
json!({"title": format!("Open item number {i}, with a title of some length"),
|
||||
"due": "2026-12-31", "decided_by": "A"}),
|
||||
);
|
||||
}
|
||||
let resp = m.request(
|
||||
"tools/call",
|
||||
json!({"name": "books.start", "arguments": {}}),
|
||||
);
|
||||
let start = &resp["result"]["structuredContent"];
|
||||
let bytes = serde_json::to_string(start).unwrap().len();
|
||||
assert!(bytes <= 8 * 1024, "{bytes} bytes");
|
||||
let cut = start["truncated"].to_string();
|
||||
assert!(cut.contains("books.conventions"), "{cut}");
|
||||
assert!(start["conventions"].as_array().unwrap().len() >= 10);
|
||||
}
|
||||
|
||||
/// Review of P1 (2026-10-17): the text imported is the file the server
|
||||
/// reads, whole; books that keep their own settings take no import that
|
||||
/// changes them; a settings file never turns approvals on; after the
|
||||
/// move, a broken config.json can't stop the server; and books.settings
|
||||
/// shows a file's values before the move.
|
||||
#[test]
|
||||
fn an_import_is_the_file_the_server_reads() {
|
||||
let dir = test_dir("kept-whole");
|
||||
let data = dir.join("books-data");
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
let keys = m.call("books.settings", json!({}))["keys"].clone();
|
||||
let ein = keys
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|k| k["key"] == "company.ein")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(ein["value"], "**-***5893", "the file's value, masked");
|
||||
// A text that leaves the EIN out is not the file.
|
||||
let short = TOML.replace("ein = \"43-2215893\"\n", "");
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": short, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(e.contains("company.ein"), "{e}");
|
||||
// Approval rules never come in from a file.
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
format!("{TOML}approval_rules = \"enforce\"\n")
|
||||
.replace("[viewer]\nopen_at_start = \"ask\"\n", ""),
|
||||
)
|
||||
.unwrap();
|
||||
let with_rules = std::fs::read_to_string(dir.join("wanderbooks.toml")).unwrap();
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": with_rules, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(e.contains("approval"), "{e}");
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
|
||||
m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
// Imported books: the same text again is no change; another is refused.
|
||||
let again = m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(again["changes"].as_array().unwrap().is_empty(), "{again}");
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": short, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(e.contains("books.set_setting"), "{e}");
|
||||
drop(m);
|
||||
std::fs::write(data.join("config.json"), "{ not json").unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
|
||||
}
|
||||
|
||||
/// A convention that names an account is listed when that account is
|
||||
/// renumbered, and as a problem while it names a code no longer in the
|
||||
/// chart.
|
||||
#[test]
|
||||
fn renumbering_names_the_conventions_to_revise() {
|
||||
let dir = test_dir("kept-renumber");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "software", "kind": "posting_template", "title": "Software",
|
||||
"body_md": "Dr 6000 / Cr 1000.",
|
||||
"structured": {"lines": [
|
||||
{"account": "6000", "side": "debit", "share": "1"},
|
||||
{"account": "1000", "side": "credit", "share": "1"}]},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
let out = m.call(
|
||||
"books.renumber_account",
|
||||
json!({"account": "6000", "code": "6010"}),
|
||||
);
|
||||
assert!(
|
||||
out["config_mentions"]
|
||||
.to_string()
|
||||
.contains("convention software"),
|
||||
"{out}"
|
||||
);
|
||||
let problems = m.call("books.settings", json!({}))["problems"].to_string();
|
||||
assert!(
|
||||
problems.contains("convention software names account 6000"),
|
||||
"{problems}"
|
||||
);
|
||||
}
|
||||
|
||||
/// books://start, books://settings and books://conventions read as their
|
||||
/// tools do.
|
||||
#[test]
|
||||
fn the_kept_resources_read() {
|
||||
let dir = test_dir("kept-resources");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "software", "kind": "note", "title": "Software",
|
||||
"body_md": "Software is 6000.", "decided_by": "A"}),
|
||||
);
|
||||
let read = |m: &mut Mcp, uri: &str| -> Value {
|
||||
let r = m.request("resources/read", json!({"uri": uri}));
|
||||
let text = r["result"]["contents"][0]["text"].as_str().unwrap();
|
||||
serde_json::from_str(text).unwrap()
|
||||
};
|
||||
let start = read(&mut m, "books://start");
|
||||
assert_eq!(start["state"], "active", "{start}");
|
||||
assert_eq!(start["conventions"][0]["id"], "software", "{start}");
|
||||
let settings = read(&mut m, "books://settings");
|
||||
assert_eq!(settings["source"], "file", "{settings}");
|
||||
let conventions = read(&mut m, "books://conventions");
|
||||
assert_eq!(
|
||||
conventions["conventions"][0]["id"], "software",
|
||||
"{conventions}"
|
||||
);
|
||||
drop(m);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A renumber lists exactly the conventions naming the old code.
|
||||
#[test]
|
||||
fn renumbering_lists_only_the_conventions_naming_it() {
|
||||
let dir = test_dir("kept-renumber-only");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
for (id, dr, cr) in [("software", "6000", "1000"), ("deposit", "1000", "3000")] {
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": id, "kind": "posting_template", "title": id,
|
||||
"body_md": format!("Dr {dr} / Cr {cr}."),
|
||||
"structured": {"lines": [
|
||||
{"account": dr, "side": "debit", "share": "1"},
|
||||
{"account": cr, "side": "credit", "share": "1"}]},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
}
|
||||
let out = m.call(
|
||||
"books.renumber_account",
|
||||
json!({"account": "6000", "code": "6010"}),
|
||||
);
|
||||
let mentions: Vec<String> = out["config_mentions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.filter(|s| s.starts_with("convention "))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
mentions,
|
||||
["convention software (revise it with books.set_convention)"],
|
||||
"{out}"
|
||||
);
|
||||
drop(m);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -219,6 +219,16 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
// A settings file naming the vault's key (the happy path's) names
|
||||
// it on the command line too, as books that move their settings
|
||||
// in must.
|
||||
.args(
|
||||
std::fs::read_to_string(toml)
|
||||
.is_ok_and(|t| t.contains("[vault]"))
|
||||
.then(|| ["--vault-key-file".into(), outside(dir).join("vault.key")])
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
)
|
||||
// Local files come only from here (the default is the downloads
|
||||
// folder): never the books, never where the server writes.
|
||||
.arg("--import-root")
|
||||
@@ -1093,6 +1103,77 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"period_id": p, "confirmed": true}),
|
||||
);
|
||||
|
||||
// How the books are kept: start, conventions, decisions, open items,
|
||||
// a template and an approval, and the settings file moving in.
|
||||
m.ok("books.start", json!({}));
|
||||
m.ok("books.start", json!({"focus": "close"}));
|
||||
m.ok("books.conventions", json!({}));
|
||||
m.ok("books.decisions", json!({}));
|
||||
m.ok("books.open_items", json!({}));
|
||||
m.ok(
|
||||
"books.set_convention",
|
||||
json!({"id": "note-1", "kind": "note", "title": "A note",
|
||||
"body_md": "Kept for the test.", "decided_by": "A"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_convention",
|
||||
json!({"id": "contribution", "kind": "posting_template",
|
||||
"title": "Joint contribution",
|
||||
"body_md": "Cash in from both members, half each.",
|
||||
"structured": {"lines": [
|
||||
{"account": "1000", "side": "debit", "share": "1"},
|
||||
{"account": "3000", "side": "credit", "share": "0.5"},
|
||||
{"account": "3001", "side": "credit", "share": "0.5"}]},
|
||||
"decided_by": "A", "decided_on": "2026-10-16"}),
|
||||
);
|
||||
let drafted = m.ok(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "contribution", "date": "2026-06-01",
|
||||
"amount": "100.01", "no_party_reason": "between the members"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.approve_entry",
|
||||
json!({"entry_id": drafted["entry_id"], "approver": "A"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.retire_convention",
|
||||
json!({"id": "note-1", "reason": "Done with it.", "decided_by": "A"}),
|
||||
);
|
||||
m.ok("books.conventions", json!({"include_retired": true}));
|
||||
m.ok(
|
||||
"books.record_decision",
|
||||
json!({"subject": "Basis", "decision_md": "Accrual.",
|
||||
"decided_by": ["A", "B"], "decided_on": "2026-01-01"}),
|
||||
);
|
||||
m.ok("books.decisions", json!({"include_superseded": true}));
|
||||
let item = m.ok(
|
||||
"books.raise_item",
|
||||
json!({"title": "Mail the letter", "due": "2026-11-01", "decided_by": "A"}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.resolve_item",
|
||||
json!({"id": item, "resolution": "Mailed.", "decided_by": "A"}),
|
||||
);
|
||||
m.ok("books.open_items", json!({"include_resolved": true}));
|
||||
let toml_text = std::fs::read_to_string(&toml).unwrap();
|
||||
m.ok(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": toml_text, "decided_by": "A"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": toml_text, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}],
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
m.ok("books.export_settings", json!({}));
|
||||
m.ok("books.start", json!({}));
|
||||
|
||||
// The handbook: its index, and a section.
|
||||
m.ok("books.handbook", json!({}));
|
||||
m.ok("books.handbook", json!({"section": "contract"}));
|
||||
|
||||
@@ -122,6 +122,22 @@ impl Mcp {
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
// A settings file naming the usual key outside the books: the
|
||||
// command line names it too, as books that move their settings
|
||||
// in must (a test naming another key keeps only the file's).
|
||||
.args({
|
||||
let key = outside(dir).join("vault.key");
|
||||
std::fs::read_to_string(toml)
|
||||
.is_ok_and(|t| t.contains(&format!("key_file = \"{}\"", key.display())))
|
||||
.then(|| {
|
||||
[
|
||||
std::ffi::OsString::from("--vault-key-file"),
|
||||
key.into_os_string(),
|
||||
]
|
||||
})
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
// Files handed over by path come only from here.
|
||||
.arg("--import-root")
|
||||
.arg(inbox(dir))
|
||||
@@ -2019,8 +2035,19 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let settings_file =
|
||||
std::fs::read_to_string(m.data.parent().unwrap().join("wanderbooks.toml")).unwrap();
|
||||
let calls: Vec<(&str, Value)> = vec![
|
||||
("books.verify", json!({"blobs": true})),
|
||||
(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": settings_file, "decided_by": "Owner", "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}],
|
||||
"decided_by": "Owner"}),
|
||||
),
|
||||
(
|
||||
"books.run_schedules",
|
||||
json!({"through": "2026-03-31", "confirmed": true}),
|
||||
|
||||
@@ -182,7 +182,7 @@ fn newer_books_are_refused_as_newer_not_corrupt() {
|
||||
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
w.append(
|
||||
"journal.format_set",
|
||||
serde_json::json!({"format": 2, "features": []}),
|
||||
serde_json::json!({"format": 3, "features": []}),
|
||||
)
|
||||
.unwrap();
|
||||
// The next line chains onto it, so its bytes are checked (B-27).
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::journal::{JournalLine, ReadLine};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The newest journal format this version reads and writes.
|
||||
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 1;
|
||||
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
|
||||
|
||||
/// Optional journal features this version reads (none yet).
|
||||
pub const KNOWN_FEATURES: &[&str] = &[];
|
||||
@@ -53,8 +53,12 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"bill.created",
|
||||
"bill.paid",
|
||||
"class.added",
|
||||
"convention.retired",
|
||||
"convention.set",
|
||||
"customer.added",
|
||||
"decision.recorded",
|
||||
"document.filed",
|
||||
"entry.approved",
|
||||
"entry.created",
|
||||
"entry.line_added",
|
||||
"entry.line_class_set",
|
||||
@@ -70,6 +74,8 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
FORMAT_SET,
|
||||
"journal.tail_repaired",
|
||||
"line.cleared",
|
||||
"open_item.raised",
|
||||
"open_item.resolved",
|
||||
"party.added",
|
||||
"party.role_set",
|
||||
"party.tin_set",
|
||||
@@ -90,6 +96,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"role.assigned",
|
||||
"schedule.posted",
|
||||
"schedule.registered",
|
||||
"settings.set",
|
||||
"statement.recorded",
|
||||
"statement.voided",
|
||||
"store.created",
|
||||
@@ -220,6 +227,14 @@ pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError>
|
||||
Ok(JournalFormat { format, features })
|
||||
}
|
||||
|
||||
/// The refusal for a line of a type this version knows that holds a
|
||||
/// value only a newer version writes: a setting key, a convention kind
|
||||
/// (`need` says which). Never "corrupt": the books are fine, this reader
|
||||
/// is old (rules.md B-27).
|
||||
pub(crate) fn newer_content(line: &JournalLine, need: String) -> StoreError {
|
||||
newer(line, need)
|
||||
}
|
||||
|
||||
/// The refusal for a line whose type this version doesn't know.
|
||||
pub(crate) fn unknown_type(line: &JournalLine) -> StoreError {
|
||||
newer(line, "an event type this version doesn't know".into())
|
||||
@@ -347,7 +362,8 @@ mod tests {
|
||||
"{t}: no projection arm"
|
||||
);
|
||||
}
|
||||
let l = line("settings.set", json!({}));
|
||||
// A type a later phase adds (plan/served.md 5.2, P8).
|
||||
let l = line("period.attested", json!({}));
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
assert!(is_newer(&replay_line_into_ledger(
|
||||
&mut ledger,
|
||||
@@ -366,7 +382,7 @@ mod tests {
|
||||
at1.declaration(1, &[]).unwrap(),
|
||||
json!({"format": 1, "features": []})
|
||||
);
|
||||
for (format, features) in [(0, &[][..]), (2, &[][..]), (1, &["x"][..])] {
|
||||
for (format, features) in [(0, &[][..]), (3, &[][..]), (1, &["x"][..])] {
|
||||
assert!(
|
||||
matches!(
|
||||
at1.declaration(format, features),
|
||||
@@ -393,7 +409,10 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(f, JournalFormat::default());
|
||||
f.admit(&line("entry.posted", json!({}))).unwrap();
|
||||
match f.admit(&line(FORMAT_SET, json!({"format": 2, "features": []}))) {
|
||||
f.admit(&line(FORMAT_SET, json!({"format": 2, "features": []})))
|
||||
.unwrap();
|
||||
assert_eq!(f.format, 2, "format 2 is this version's");
|
||||
match f.admit(&line(FORMAT_SET, json!({"format": 3, "features": []}))) {
|
||||
Err(StoreError::NewerJournal {
|
||||
seq,
|
||||
event_type,
|
||||
@@ -403,7 +422,7 @@ mod tests {
|
||||
// The gate alone never vouches for the line's bytes.
|
||||
assert!(!verified);
|
||||
assert_eq!((seq, event_type.as_str()), (7, FORMAT_SET));
|
||||
assert_eq!(need, "journal format 2");
|
||||
assert_eq!(need, "journal format 3");
|
||||
}
|
||||
other => panic!("expected NewerJournal, got {other:?}"),
|
||||
}
|
||||
@@ -417,7 +436,7 @@ mod tests {
|
||||
other => panic!("expected NewerJournal, got {other:?}"),
|
||||
}
|
||||
// A refused line leaves the format in force alone.
|
||||
assert_eq!(f, JournalFormat::default());
|
||||
assert_eq!(f.format, 2);
|
||||
for bad in [
|
||||
json!({"features": []}),
|
||||
json!({"format": 0, "features": []}),
|
||||
@@ -444,9 +463,9 @@ mod tests {
|
||||
fn a_newer_format_is_newer_whatever_its_features_look_like() {
|
||||
let mut f = JournalFormat::default();
|
||||
for newer in [
|
||||
json!({"format": 2, "features": {"actors": true}}),
|
||||
json!({"format": 2, "features": [1, 2]}),
|
||||
json!({"format": 2}),
|
||||
json!({"format": 3, "features": {"actors": true}}),
|
||||
json!({"format": 3, "features": [1, 2]}),
|
||||
json!({"format": 3}),
|
||||
json!({"format": 4_294_967_296_u64, "features": []}),
|
||||
] {
|
||||
assert!(
|
||||
|
||||
@@ -242,6 +242,12 @@ impl JournalWriter {
|
||||
self.next_seq
|
||||
}
|
||||
|
||||
/// The head of the chain: the last line's seq (0 for an empty
|
||||
/// journal) and its hash (the genesis value for an empty one).
|
||||
pub fn head(&self) -> (u64, &str) {
|
||||
(self.next_seq - 1, &self.prev_hash)
|
||||
}
|
||||
|
||||
/// Path of the journal file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod blob;
|
||||
pub mod error;
|
||||
pub mod format;
|
||||
pub mod journal;
|
||||
pub mod meta;
|
||||
pub mod reconcilable;
|
||||
pub mod schema;
|
||||
pub mod snapshot;
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Journal payloads of the books' own record of how they are kept:
|
||||
//! settings, conventions, decisions, open items and approvals (journal
|
||||
//! format 2, plan/served.md 5.2). One encoder and one decoder per event,
|
||||
//! so the writer and every reader (replay, the projection, the viewer)
|
||||
//! read a line the same way.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every event round-trips: decode(encode(x)) == x.
|
||||
//! INVARIANTS:
|
||||
//! 1. A payload missing a field, or with a field of the wrong type, is
|
||||
//! JournalCorrupt; optional fields may be absent or null.
|
||||
//! 2. Decoding checks shape only (rules.md B-28): what a command checks
|
||||
//! (limits, the chart) stays with the command.
|
||||
//! ```
|
||||
|
||||
use crate::error::StoreError;
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// The journal format these events need: an older reader stops at the
|
||||
/// declaration (format gate) instead of misreading them.
|
||||
pub const FORMAT: u32 = 2;
|
||||
|
||||
fn corrupt(event: &str, what: &str) -> StoreError {
|
||||
StoreError::JournalCorrupt(format!("{event}: missing or invalid '{what}'"))
|
||||
}
|
||||
|
||||
fn str_of(p: &Value, event: &str, k: &str) -> Result<String, StoreError> {
|
||||
p.get(k)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| corrupt(event, k))
|
||||
}
|
||||
|
||||
fn opt_str(p: &Value, event: &str, k: &str) -> Result<Option<String>, StoreError> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.clone())),
|
||||
Some(_) => Err(corrupt(event, k)),
|
||||
}
|
||||
}
|
||||
|
||||
fn u64_of(p: &Value, event: &str, k: &str) -> Result<u64, StoreError> {
|
||||
p.get(k)
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| corrupt(event, k))
|
||||
}
|
||||
|
||||
fn opt_u64(p: &Value, event: &str, k: &str) -> Result<Option<u64>, StoreError> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(Some).ok_or_else(|| corrupt(event, k)),
|
||||
}
|
||||
}
|
||||
|
||||
fn date_of(s: &str, event: &str, k: &str) -> Result<NaiveDate, StoreError> {
|
||||
NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| corrupt(event, k))
|
||||
}
|
||||
|
||||
fn opt_date(p: &Value, event: &str, k: &str) -> Result<Option<NaiveDate>, StoreError> {
|
||||
opt_str(p, event, k)?
|
||||
.map(|s| date_of(&s, event, k))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Insert `v` under `k` unless it is `None`.
|
||||
fn put<T: Into<Value>>(o: &mut Map<String, Value>, k: &str, v: Option<T>) {
|
||||
if let Some(v) = v {
|
||||
o.insert(k.to_string(), v.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// A source as journaled: `{decided_by, decided_on, citation?, note?}`.
|
||||
pub fn source_json(s: &Source) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("decided_by".into(), json!(s.decided_by));
|
||||
o.insert("decided_on".into(), json!(s.decided_on.to_string()));
|
||||
put(&mut o, "citation", s.citation.clone());
|
||||
put(&mut o, "note", s.note.clone());
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// A journaled source (`p[k]`).
|
||||
pub fn source_of(p: &Value, event: &str, k: &str) -> Result<Source, StoreError> {
|
||||
let s = p
|
||||
.get(k)
|
||||
.filter(|v| v.is_object())
|
||||
.ok_or_else(|| corrupt(event, k))?;
|
||||
Ok(Source {
|
||||
decided_by: str_of(s, event, "decided_by")?,
|
||||
decided_on: date_of(&str_of(s, event, "decided_on")?, event, "source.decided_on")?,
|
||||
citation: opt_str(s, event, "citation")?,
|
||||
note: opt_str(s, event, "note")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `settings.set {changes: [{key, value|null}], source}`.
|
||||
pub fn settings_set(changes: &[Change], source: &Source) -> Value {
|
||||
json!({
|
||||
"changes": changes
|
||||
.iter()
|
||||
.map(|c| json!({ "key": c.key, "value": c.value.clone().unwrap_or(Value::Null) }))
|
||||
.collect::<Vec<_>>(),
|
||||
"source": source_json(source),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a `settings.set`.
|
||||
pub fn settings_set_of(p: &Value) -> Result<(Vec<Change>, Source), StoreError> {
|
||||
const E: &str = "settings.set";
|
||||
let changes = p
|
||||
.get("changes")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| corrupt(E, "changes"))?
|
||||
.iter()
|
||||
.map(|c| {
|
||||
Ok(Change {
|
||||
key: str_of(c, E, "key")?,
|
||||
value: match c.get("value") {
|
||||
None => return Err(corrupt(E, "value")),
|
||||
Some(Value::Null) => None,
|
||||
Some(v) => Some(v.clone()),
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, StoreError>>()?;
|
||||
Ok((changes, source_of(p, E, "source")?))
|
||||
}
|
||||
|
||||
/// `convention.set {id, kind, title, body_md, structured?, source}`.
|
||||
pub fn convention_set(c: &NewConvention) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("id".into(), json!(c.id));
|
||||
o.insert("kind".into(), json!(c.kind.key()));
|
||||
o.insert("title".into(), json!(c.title));
|
||||
o.insert("body_md".into(), json!(c.body_md));
|
||||
put(&mut o, "structured", c.structured.clone());
|
||||
o.insert("source".into(), source_json(&c.source));
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read a `convention.set`.
|
||||
pub fn convention_set_of(p: &Value) -> Result<NewConvention, StoreError> {
|
||||
const E: &str = "convention.set";
|
||||
Ok(NewConvention {
|
||||
id: str_of(p, E, "id")?,
|
||||
kind: ConventionKind::parse(&str_of(p, E, "kind")?).ok_or_else(|| corrupt(E, "kind"))?,
|
||||
title: str_of(p, E, "title")?,
|
||||
body_md: str_of(p, E, "body_md")?,
|
||||
structured: p.get("structured").filter(|v| !v.is_null()).cloned(),
|
||||
source: source_of(p, E, "source")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `convention.retired {id, reason, source}`.
|
||||
pub fn convention_retired(id: &str, reason: &str, source: &Source) -> Value {
|
||||
json!({ "id": id, "reason": reason, "source": source_json(source) })
|
||||
}
|
||||
|
||||
/// Read a `convention.retired`: `(id, reason, source)`.
|
||||
pub fn convention_retired_of(p: &Value) -> Result<(String, String, Source), StoreError> {
|
||||
const E: &str = "convention.retired";
|
||||
Ok((
|
||||
str_of(p, E, "id")?,
|
||||
str_of(p, E, "reason")?,
|
||||
source_of(p, E, "source")?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `decision.recorded {id, subject, decision_md, decided_by, decided_on,
|
||||
/// evidence_attachment?, supersedes?, citation?}`.
|
||||
pub fn decision_recorded(id: u64, d: &NewDecision) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("id".into(), json!(id));
|
||||
o.insert("subject".into(), json!(d.subject));
|
||||
o.insert("decision_md".into(), json!(d.decision_md));
|
||||
o.insert("decided_by".into(), json!(d.decided_by));
|
||||
o.insert("decided_on".into(), json!(d.decided_on.to_string()));
|
||||
put(&mut o, "evidence_attachment", d.evidence_attachment);
|
||||
put(&mut o, "supersedes", d.supersedes);
|
||||
put(&mut o, "citation", d.citation.clone());
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read a `decision.recorded`: `(id, decision)`.
|
||||
pub fn decision_recorded_of(p: &Value) -> Result<(u64, NewDecision), StoreError> {
|
||||
const E: &str = "decision.recorded";
|
||||
let decided_by = p
|
||||
.get("decided_by")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| {
|
||||
a.iter()
|
||||
.map(|v| v.as_str().map(str::to_string))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.ok_or_else(|| corrupt(E, "decided_by"))?;
|
||||
Ok((
|
||||
u64_of(p, E, "id")?,
|
||||
NewDecision {
|
||||
subject: str_of(p, E, "subject")?,
|
||||
decision_md: str_of(p, E, "decision_md")?,
|
||||
decided_by,
|
||||
decided_on: date_of(&str_of(p, E, "decided_on")?, E, "decided_on")?,
|
||||
evidence_attachment: opt_u64(p, E, "evidence_attachment")?,
|
||||
supersedes: opt_u64(p, E, "supersedes")?,
|
||||
citation: opt_str(p, E, "citation")?,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// `open_item.raised {id, title, detail_md?, due?, source}`.
|
||||
pub fn open_item_raised(id: u64, i: &NewOpenItem) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("id".into(), json!(id));
|
||||
o.insert("title".into(), json!(i.title));
|
||||
put(&mut o, "detail_md", i.detail_md.clone());
|
||||
put(&mut o, "due", i.due.map(|d| d.to_string()));
|
||||
o.insert("source".into(), source_json(&i.source));
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read an `open_item.raised`: `(id, item)`.
|
||||
pub fn open_item_raised_of(p: &Value) -> Result<(u64, NewOpenItem), StoreError> {
|
||||
const E: &str = "open_item.raised";
|
||||
Ok((
|
||||
u64_of(p, E, "id")?,
|
||||
NewOpenItem {
|
||||
title: str_of(p, E, "title")?,
|
||||
detail_md: opt_str(p, E, "detail_md")?,
|
||||
due: opt_date(p, E, "due")?,
|
||||
source: source_of(p, E, "source")?,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// `open_item.resolved {id, resolution, source}`.
|
||||
pub fn open_item_resolved(id: u64, resolution: &str, source: &Source) -> Value {
|
||||
json!({ "id": id, "resolution": resolution, "source": source_json(source) })
|
||||
}
|
||||
|
||||
/// Read an `open_item.resolved`: `(id, resolution, source)`.
|
||||
pub fn open_item_resolved_of(p: &Value) -> Result<(u64, String, Source), StoreError> {
|
||||
const E: &str = "open_item.resolved";
|
||||
Ok((
|
||||
u64_of(p, E, "id")?,
|
||||
str_of(p, E, "resolution")?,
|
||||
source_of(p, E, "source")?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `entry.approved {entry_id, approver, fingerprint, note?, source}`.
|
||||
pub fn entry_approved(entry: u64, a: &Approval) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("entry_id".into(), json!(entry));
|
||||
o.insert("approver".into(), json!(a.approver));
|
||||
o.insert("fingerprint".into(), json!(a.fingerprint));
|
||||
put(&mut o, "note", a.note.clone());
|
||||
o.insert("source".into(), source_json(&a.source));
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read an `entry.approved` at `seq`: `(entry, approval)`.
|
||||
pub fn entry_approved_of(p: &Value, seq: u64) -> Result<(u64, Approval), StoreError> {
|
||||
const E: &str = "entry.approved";
|
||||
Ok((
|
||||
u64_of(p, E, "entry_id")?,
|
||||
Approval {
|
||||
approver: str_of(p, E, "approver")?,
|
||||
fingerprint: str_of(p, E, "fingerprint")?,
|
||||
note: opt_str(p, E, "note")?,
|
||||
source: source_of(p, E, "source")?,
|
||||
seq,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ledger_core::conventions::ConventionKind;
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "A".into(),
|
||||
decided_on: NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
|
||||
citation: Some("c".into()),
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// GOAL: every event round-trips.
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let ch = vec![
|
||||
Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!("X")),
|
||||
},
|
||||
Change {
|
||||
key: "books.close_into".into(),
|
||||
value: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
settings_set_of(&settings_set(&ch, &src())).unwrap(),
|
||||
(ch, src())
|
||||
);
|
||||
let c = NewConvention {
|
||||
id: "t".into(),
|
||||
kind: ConventionKind::PostingTemplate,
|
||||
title: "T".into(),
|
||||
body_md: "B".into(),
|
||||
structured: Some(json!({"lines": []})),
|
||||
source: src(),
|
||||
};
|
||||
assert_eq!(convention_set_of(&convention_set(&c)).unwrap(), c);
|
||||
assert_eq!(
|
||||
convention_retired_of(&convention_retired("t", "r", &src())).unwrap(),
|
||||
("t".into(), "r".into(), src())
|
||||
);
|
||||
let d = NewDecision {
|
||||
subject: "S".into(),
|
||||
decision_md: "D".into(),
|
||||
decided_by: vec!["A".into(), "B".into()],
|
||||
decided_on: src().decided_on,
|
||||
evidence_attachment: Some(7),
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
};
|
||||
assert_eq!(
|
||||
decision_recorded_of(&decision_recorded(3, &d)).unwrap(),
|
||||
(3, d)
|
||||
);
|
||||
let i = NewOpenItem {
|
||||
title: "T".into(),
|
||||
detail_md: Some("d".into()),
|
||||
due: Some(src().decided_on),
|
||||
source: src(),
|
||||
};
|
||||
assert_eq!(
|
||||
open_item_raised_of(&open_item_raised(4, &i)).unwrap(),
|
||||
(4, i)
|
||||
);
|
||||
assert_eq!(
|
||||
open_item_resolved_of(&open_item_resolved(4, "done", &src())).unwrap(),
|
||||
(4, "done".into(), src())
|
||||
);
|
||||
let a = Approval {
|
||||
approver: "A".into(),
|
||||
fingerprint: "00ff".into(),
|
||||
note: None,
|
||||
source: src(),
|
||||
seq: 9,
|
||||
};
|
||||
assert_eq!(
|
||||
entry_approved_of(&entry_approved(5, &a), 9).unwrap(),
|
||||
(5, a)
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 1.
|
||||
#[test]
|
||||
fn bad_shapes_are_corrupt() {
|
||||
assert!(
|
||||
settings_set_of(&json!({"changes": [{"key": "k"}], "source": source_json(&src())}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(settings_set_of(&json!({"changes": []})).is_err());
|
||||
assert!(convention_set_of(&json!({"id": "t", "kind": "nope", "title": "T", "body_md": "B", "source": source_json(&src())})).is_err());
|
||||
assert!(decision_recorded_of(&json!({"id": 1, "subject": "S", "decision_md": "D", "decided_by": [1], "decided_on": "2026-10-17"})).is_err());
|
||||
assert!(
|
||||
open_item_raised_of(
|
||||
&json!({"id": 1, "title": "T", "due": 5, "source": source_json(&src())})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 11;
|
||||
pub const SCHEMA_VERSION: u32 = 12;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -266,5 +266,62 @@ CREATE TABLE IF NOT EXISTS reconciliation_lines(
|
||||
line_index INTEGER NOT NULL,
|
||||
PRIMARY KEY(reconciliation_id, entry_id, line_index)
|
||||
);
|
||||
"#,
|
||||
r#"
|
||||
-- v12: the books' own record of how they are kept (journal format 2,
|
||||
-- plan/served.md 5.2): settings in force (each value as canonical JSON,
|
||||
-- with the seq and source that set it), conventions (the current
|
||||
-- revision of each, and its retirement), decisions, open items, and
|
||||
-- approvals of draft entries.
|
||||
CREATE TABLE IF NOT EXISTS settings(
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
source_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS conventions(
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body_md TEXT NOT NULL,
|
||||
structured_json TEXT NULL,
|
||||
source_json TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
retired_reason TEXT NULL,
|
||||
retired_source_json TEXT NULL,
|
||||
retired_seq INTEGER NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS decisions(
|
||||
id INTEGER PRIMARY KEY,
|
||||
subject TEXT NOT NULL,
|
||||
decision_md TEXT NOT NULL,
|
||||
decided_by_json TEXT NOT NULL,
|
||||
decided_on TEXT NOT NULL,
|
||||
evidence_attachment INTEGER NULL,
|
||||
supersedes INTEGER NULL,
|
||||
citation TEXT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
superseded_by INTEGER NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS open_items(
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
detail_md TEXT NULL,
|
||||
due TEXT NULL,
|
||||
source_json TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
resolution TEXT NULL,
|
||||
resolved_source_json TEXT NULL,
|
||||
resolved_seq INTEGER NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entry_approvals(
|
||||
seq INTEGER PRIMARY KEY,
|
||||
entry_id INTEGER NOT NULL,
|
||||
approver TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
note TEXT NULL,
|
||||
source_json TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -519,6 +519,13 @@ impl Store {
|
||||
self.base_currency
|
||||
}
|
||||
|
||||
/// The journal's head: the last line's seq and its hash (what
|
||||
/// `verify` reports as the head once it has checked the chain).
|
||||
pub fn head(&self) -> (u64, String) {
|
||||
let (seq, hash) = self.journal.head();
|
||||
(seq, hash.to_string())
|
||||
}
|
||||
|
||||
/// The journal format in force: format 1 unless the books declared
|
||||
/// another with `journal.format_set`.
|
||||
pub fn journal_format(&self) -> &JournalFormat {
|
||||
@@ -554,6 +561,209 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// How the books are kept: settings, conventions, decisions, open
|
||||
// items, approvals (journal format 2, crate::meta)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Declare journal format 2 before the first event that needs it, so
|
||||
/// an older reader stops at the declaration (plan/served.md 5.1).
|
||||
fn ensure_format(&mut self) -> Result<(), StoreError> {
|
||||
if self.format.format < crate::meta::FORMAT {
|
||||
let features = self.format.features.clone();
|
||||
let features: Vec<&str> = features.iter().map(String::as_str).collect();
|
||||
self.declare_journal_format(crate::meta::FORMAT, &features)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change settings (`settings.set`): all or nothing, each value
|
||||
/// canonical, the result whole (ledger_core::settings). Returns the
|
||||
/// changes made; none (journaling nothing) when every change is the
|
||||
/// value already set.
|
||||
pub fn change_settings(
|
||||
&mut self,
|
||||
changes: &[ledger_core::settings::Change],
|
||||
source: &ledger_core::settings::Source,
|
||||
) -> Result<Vec<ledger_core::settings::Change>, StoreError> {
|
||||
self.journal_settings(changes, source, false)
|
||||
}
|
||||
|
||||
/// [`Self::change_settings`] for books moving their settings in from a
|
||||
/// settings file: the first `settings.set` is journaled even when it
|
||||
/// changes nothing (a file that sets only defaults), so from then on
|
||||
/// the books keep their own settings and no file is read.
|
||||
pub fn adopt_settings(
|
||||
&mut self,
|
||||
changes: &[ledger_core::settings::Change],
|
||||
source: &ledger_core::settings::Source,
|
||||
) -> Result<Vec<ledger_core::settings::Change>, StoreError> {
|
||||
self.journal_settings(changes, source, true)
|
||||
}
|
||||
|
||||
fn journal_settings(
|
||||
&mut self,
|
||||
changes: &[ledger_core::settings::Change],
|
||||
source: &ledger_core::settings::Source,
|
||||
adopt: bool,
|
||||
) -> Result<Vec<ledger_core::settings::Change>, StoreError> {
|
||||
let changes = self
|
||||
.ledger
|
||||
.check_settings_adopting(changes, source, adopt)?;
|
||||
if changes.is_empty() && !(adopt && !self.ledger.settings().is_journaled()) {
|
||||
return Ok(changes);
|
||||
}
|
||||
self.ensure_format()?;
|
||||
let line =
|
||||
self.append_journal("settings.set", crate::meta::settings_set(&changes, source))?;
|
||||
self.ledger.apply_settings(&changes, source, line.seq)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// Set or revise a convention (`convention.set`). Returns it as
|
||||
/// journaled.
|
||||
pub fn set_convention(
|
||||
&mut self,
|
||||
c: &ledger_core::conventions::NewConvention,
|
||||
) -> Result<ledger_core::conventions::NewConvention, StoreError> {
|
||||
let c = self.ledger.check_convention(c)?;
|
||||
self.ensure_format()?;
|
||||
let line = self.append_journal("convention.set", crate::meta::convention_set(&c))?;
|
||||
self.ledger.apply_convention(c.clone(), line.seq)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Retire a convention (`convention.retired`).
|
||||
pub fn retire_convention(
|
||||
&mut self,
|
||||
id: &str,
|
||||
reason: &str,
|
||||
source: &ledger_core::settings::Source,
|
||||
) -> Result<(), StoreError> {
|
||||
let reason = self.ledger.check_retire_convention(id, reason, source)?;
|
||||
self.ensure_format()?;
|
||||
let line = self.append_journal(
|
||||
"convention.retired",
|
||||
crate::meta::convention_retired(id, &reason, source),
|
||||
)?;
|
||||
self.ledger
|
||||
.apply_retire_convention(id, reason, source.clone(), line.seq)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a decision (`decision.recorded`); its evidence, if any, is
|
||||
/// a filed attachment not withdrawn. Returns its id.
|
||||
pub fn record_decision(
|
||||
&mut self,
|
||||
d: &ledger_core::conventions::NewDecision,
|
||||
) -> Result<u64, StoreError> {
|
||||
let (id, d) = self.ledger.check_decision(d)?;
|
||||
if let Some(a) = d.evidence_attachment
|
||||
&& self.attachment_state(a)?.1
|
||||
{
|
||||
return Err(StoreError::AttachmentWithdrawn(a));
|
||||
}
|
||||
self.ensure_format()?;
|
||||
let line =
|
||||
self.append_journal("decision.recorded", crate::meta::decision_recorded(id, &d))?;
|
||||
self.ledger.apply_decision(id, d, line.seq)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Raise an open item (`open_item.raised`). Returns its id.
|
||||
pub fn raise_open_item(
|
||||
&mut self,
|
||||
i: &ledger_core::conventions::NewOpenItem,
|
||||
) -> Result<u64, StoreError> {
|
||||
let (id, i) = self.ledger.check_open_item(i)?;
|
||||
self.ensure_format()?;
|
||||
let line =
|
||||
self.append_journal("open_item.raised", crate::meta::open_item_raised(id, &i))?;
|
||||
self.ledger.apply_open_item(id, i, line.seq)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Resolve an open item (`open_item.resolved`).
|
||||
pub fn resolve_open_item(
|
||||
&mut self,
|
||||
id: u64,
|
||||
resolution: &str,
|
||||
source: &ledger_core::settings::Source,
|
||||
) -> Result<(), StoreError> {
|
||||
let text = self.ledger.check_resolve_item(id, resolution, source)?;
|
||||
self.ensure_format()?;
|
||||
let line = self.append_journal(
|
||||
"open_item.resolved",
|
||||
crate::meta::open_item_resolved(id, &text, source),
|
||||
)?;
|
||||
self.ledger
|
||||
.apply_resolve_item(id, text, source.clone(), line.seq)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record an approval of a draft entry (`entry.approved`), as the
|
||||
/// draft stands now.
|
||||
pub fn approve_entry(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
approver: &str,
|
||||
note: Option<&str>,
|
||||
source: &ledger_core::settings::Source,
|
||||
) -> Result<ledger_core::conventions::Approval, StoreError> {
|
||||
let a = self.ledger.check_approval(entry, approver, note, source)?;
|
||||
self.ensure_format()?;
|
||||
let line = self.append_journal("entry.approved", crate::meta::entry_approved(entry, &a))?;
|
||||
let a = ledger_core::conventions::Approval { seq: line.seq, ..a };
|
||||
self.ledger.apply_approval(entry, a.clone())?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Draft an entry from a posting template: `amount` split by the
|
||||
/// template's shares (ledger_core::conventions INVARIANT 2), its slots
|
||||
/// filled from `slots` (slot name → account code), dated
|
||||
/// `date`, described as given or as the template says. All or
|
||||
/// nothing: no half-built draft is ever journaled. Returns the draft.
|
||||
pub fn draft_from_template(
|
||||
&mut self,
|
||||
template_id: &str,
|
||||
date: chrono::NaiveDate,
|
||||
amount: Decimal,
|
||||
description: Option<&str>,
|
||||
slots: &std::collections::BTreeMap<String, String>,
|
||||
) -> Result<EntryId, StoreError> {
|
||||
let (default_description, lines) =
|
||||
self.ledger.template_lines(template_id, amount, slots)?;
|
||||
let description = description
|
||||
.map(str::to_string)
|
||||
.or(default_description)
|
||||
.unwrap_or_else(|| {
|
||||
self.ledger
|
||||
.conventions()
|
||||
.convention(template_id)
|
||||
.map(|c| c.title.clone())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
self.rehearse(|l| {
|
||||
let e = l.try_create_entry(date, &description)?;
|
||||
for (account, side, amount) in &lines {
|
||||
l.add_line(e, *account, *side, *amount)?;
|
||||
}
|
||||
Ok(e)
|
||||
})?;
|
||||
let e = self.create_entry(date, &description)?;
|
||||
for (account, side, amount) in lines {
|
||||
self.add_line(e, account, side, amount)?;
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
/// Every torn journal tail set aside on open, oldest first, as
|
||||
/// (when, bytes, file it was saved to).
|
||||
pub fn tail_repairs(&self) -> Result<Vec<(String, u64, String)>, StoreError> {
|
||||
@@ -685,8 +895,75 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate, journal, and post a draft entry.
|
||||
/// Validate, journal, and post a draft entry. An entry an enforced
|
||||
/// approval rule covers posts only once its approvers approved it as
|
||||
/// it stands (ledger_core::conventions INVARIANT 3).
|
||||
pub fn post_entry(&mut self, entry: EntryId) -> Result<(), StoreError> {
|
||||
let missing = self.ledger.approvals_missing(entry)?;
|
||||
if !missing.is_empty() {
|
||||
return Err(LedgerError::ApprovalRequired {
|
||||
entry,
|
||||
missing: missing
|
||||
.iter()
|
||||
.map(|(rule, approver)| format!("{rule} (approver: {approver})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
self.post_entry_unchecked(entry)
|
||||
}
|
||||
|
||||
/// Approval rules (ledger_core::conventions INVARIANT 3): refuse an
|
||||
/// operation that would post at once an entry an enforced rule covers
|
||||
/// (such an entry has no draft to approve: it must be drafted and
|
||||
/// approved instead). `last_only`: only the operation's last posted
|
||||
/// entry is its own (a disposal's; the depreciation before it follows
|
||||
/// from the books and is exempt). Changes nothing.
|
||||
fn approval_gate<T>(
|
||||
&self,
|
||||
op: impl FnOnce(&mut Ledger) -> Result<T, LedgerError>,
|
||||
last_only: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
if self.ledger.settings().approval_rules() == ledger_core::settings::ApprovalMode::Off
|
||||
|| self.ledger.conventions().approval_rules().next().is_none()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let mut probe = self.ledger.clone();
|
||||
let first = probe.next_entry_id();
|
||||
op(&mut probe)?;
|
||||
let posted: Vec<EntryId> = (first..probe.next_entry_id())
|
||||
.filter(|id| {
|
||||
probe
|
||||
.get_entry(*id)
|
||||
.is_some_and(|e| e.status() == EntryStatus::Posted)
|
||||
})
|
||||
.collect();
|
||||
let own = if last_only {
|
||||
posted.last().map(std::slice::from_ref).unwrap_or(&[])
|
||||
} else {
|
||||
&posted[..]
|
||||
};
|
||||
for &id in own {
|
||||
let missing = probe.approvals_missing(id)?;
|
||||
if !missing.is_empty() {
|
||||
return Err(LedgerError::ApprovalNeedsDraft(
|
||||
missing
|
||||
.iter()
|
||||
.map(|(rule, approver)| format!("{rule} (approver: {approver})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Self::post_entry`] for entries the store makes and posts itself
|
||||
/// (depreciation, a disposal, whose own gate ran first).
|
||||
fn post_entry_unchecked(&mut self, entry: EntryId) -> Result<(), StoreError> {
|
||||
self.rehearse(|l| l.post_entry(entry))?;
|
||||
let snap = self
|
||||
.ledger
|
||||
@@ -1157,7 +1434,7 @@ impl Store {
|
||||
Side::Credit,
|
||||
Money::new(amount, self.base_currency),
|
||||
)?;
|
||||
self.post_entry(entry_id)?;
|
||||
self.post_entry_unchecked(entry_id)?;
|
||||
let dline = self.append_journal(
|
||||
"asset.depreciation_posted",
|
||||
json!({
|
||||
@@ -1396,12 +1673,27 @@ impl Store {
|
||||
}
|
||||
l.dispose_asset(asset_id, date, proceeds)
|
||||
})?;
|
||||
// An enforced approval rule covering the disposal entry (the last
|
||||
// posted) refuses it before anything is journaled.
|
||||
self.approval_gate(
|
||||
|l| {
|
||||
for (description, lines) in depreciation.iter().chain([&disposal]) {
|
||||
let e = l.try_create_entry(date, description)?;
|
||||
for (account, side, amount) in lines {
|
||||
l.add_line(e, *account, *side, Money::new(*amount, base))?;
|
||||
}
|
||||
l.post_entry(e)?;
|
||||
}
|
||||
l.dispose_asset(asset_id, date, proceeds)
|
||||
},
|
||||
true,
|
||||
)?;
|
||||
if let Some((description, lines)) = &depreciation {
|
||||
let eid = self.create_entry(date, description)?;
|
||||
for (account, side, amount) in lines {
|
||||
self.add_line(eid, *account, *side, Money::new(*amount, base))?;
|
||||
}
|
||||
self.post_entry(eid)?;
|
||||
self.post_entry_unchecked(eid)?;
|
||||
let dline = self.append_journal(
|
||||
"asset.depreciation_posted",
|
||||
json!({
|
||||
@@ -1417,7 +1709,7 @@ impl Store {
|
||||
for (account, side, amount) in &disposal.1 {
|
||||
self.add_line(entry_id, *account, *side, Money::new(*amount, base))?;
|
||||
}
|
||||
self.post_entry(entry_id)?;
|
||||
self.post_entry_unchecked(entry_id)?;
|
||||
let line = self.append_journal(
|
||||
"asset.disposed",
|
||||
json!({
|
||||
@@ -1741,6 +2033,10 @@ impl Store {
|
||||
lines: Vec<DocumentLine>,
|
||||
) -> Result<InvoiceId, StoreError> {
|
||||
self.rehearse(|l| l.create_invoice(customer_id, date, due_date, lines.clone()))?;
|
||||
self.approval_gate(
|
||||
|l| l.create_invoice(customer_id, date, due_date, lines.clone()),
|
||||
false,
|
||||
)?;
|
||||
// Pure pre-validation mirroring Ledger::create_invoice, so the
|
||||
// journal writes below cannot be followed by a failed mutation.
|
||||
// (The auto-posted entry balances by construction and its accounts
|
||||
@@ -1801,6 +2097,10 @@ impl Store {
|
||||
lines: Vec<DocumentLine>,
|
||||
) -> Result<BillId, StoreError> {
|
||||
self.rehearse(|l| l.create_bill(vendor_id, date, due_date, lines.clone()))?;
|
||||
self.approval_gate(
|
||||
|l| l.create_bill(vendor_id, date, due_date, lines.clone()),
|
||||
false,
|
||||
)?;
|
||||
if self.ledger.get_vendor(vendor_id).is_none() {
|
||||
return Err(LedgerError::UnknownVendor(vendor_id).into());
|
||||
}
|
||||
@@ -1852,6 +2152,10 @@ impl Store {
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, StoreError> {
|
||||
self.rehearse(|l| l.receive_payment(invoice_id, date, amount, cash_account))?;
|
||||
self.approval_gate(
|
||||
|l| l.receive_payment(invoice_id, date, amount, cash_account),
|
||||
false,
|
||||
)?;
|
||||
// The rehearsal above is the whole check: hand-written mirrors of
|
||||
// the engine's refusals drift from it (and were never reached).
|
||||
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
|
||||
@@ -1888,6 +2192,7 @@ impl Store {
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, StoreError> {
|
||||
self.rehearse(|l| l.pay_bill(bill_id, date, amount, cash_account))?;
|
||||
self.approval_gate(|l| l.pay_bill(bill_id, date, amount, cash_account), false)?;
|
||||
let ap = self.ensure_control_account(&ledger_core::PAYABLES)?;
|
||||
let description = format!("Payment on bill {bill_id}");
|
||||
let (entry_id, mut journaled) =
|
||||
@@ -2023,6 +2328,7 @@ impl Store {
|
||||
cash_account: AccountId,
|
||||
) -> Result<PayrollRunId, StoreError> {
|
||||
self.rehearse(|l| l.run_payroll(date, lines.clone(), cash_account))?;
|
||||
self.approval_gate(|l| l.run_payroll(date, lines.clone(), cash_account), false)?;
|
||||
// Pure pre-validation mirroring Ledger::run_payroll, so the journal
|
||||
// writes below cannot be followed by a failed mutation.
|
||||
self.ledger.validate_payroll(date, &lines, cash_account)?;
|
||||
@@ -2101,6 +2407,7 @@ impl Store {
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, StoreError> {
|
||||
self.rehearse(|l| l.remit_payroll_taxes(date, cash_account))?;
|
||||
self.approval_gate(|l| l.remit_payroll_taxes(date, cash_account), false)?;
|
||||
// Pure pre-validation mirroring Ledger::remit_payroll_taxes.
|
||||
if self.ledger.chart().get(cash_account).is_none() {
|
||||
return Err(LedgerError::UnknownAccount(cash_account).into());
|
||||
@@ -2943,7 +3250,7 @@ impl Store {
|
||||
.iter()
|
||||
.any(|b| b.abs() > ledger_core::kernel::MAX_LINE_AMOUNT)
|
||||
{
|
||||
return bad("balances are at most 1000000000000000");
|
||||
return bad("balances are at most one quadrillion");
|
||||
}
|
||||
if !ledger_core::calendar::in_calendar(st.period_start)
|
||||
|| !ledger_core::calendar::in_calendar(st.period_end)
|
||||
@@ -4538,6 +4845,82 @@ pub(crate) fn replay_line_into_ledger(
|
||||
p.get("class_id").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||
)?;
|
||||
}
|
||||
"settings.set" => {
|
||||
let (changes, source) = crate::meta::settings_set_of(p)?;
|
||||
// A key this version doesn't know: a newer one wrote it.
|
||||
if let Some(c) = changes
|
||||
.iter()
|
||||
.find(|c| ledger_core::settings::key(&c.key).is_none())
|
||||
{
|
||||
return Err(crate::format::newer_content(
|
||||
line,
|
||||
format!("the setting {}", c.key),
|
||||
));
|
||||
}
|
||||
ledger.apply_settings(&changes, &source, line.seq)?;
|
||||
}
|
||||
"convention.set" => {
|
||||
// A kind this version doesn't know: a newer one wrote it.
|
||||
if let Some(kind) = p.get("kind").and_then(Value::as_str)
|
||||
&& ledger_core::conventions::ConventionKind::parse(kind).is_none()
|
||||
{
|
||||
return Err(crate::format::newer_content(
|
||||
line,
|
||||
format!("the convention kind {kind}"),
|
||||
));
|
||||
}
|
||||
let c = crate::meta::convention_set_of(p)?;
|
||||
// A template or rule whose structure this version can't read:
|
||||
// a newer one wrote it (never skipped: a rule must not fail
|
||||
// open).
|
||||
let probe = ledger_core::conventions::Convention {
|
||||
id: c.id.clone(),
|
||||
kind: c.kind,
|
||||
title: String::new(),
|
||||
body_md: String::new(),
|
||||
structured: c.structured.clone(),
|
||||
source: c.source.clone(),
|
||||
seq: line.seq,
|
||||
revision: 1,
|
||||
retired: None,
|
||||
};
|
||||
let unreadable = match c.kind {
|
||||
ledger_core::conventions::ConventionKind::PostingTemplate => {
|
||||
probe.template().is_none()
|
||||
}
|
||||
ledger_core::conventions::ConventionKind::ApprovalRule => {
|
||||
probe.approval_rule().is_none()
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if unreadable {
|
||||
return Err(crate::format::newer_content(
|
||||
line,
|
||||
format!("the structure of convention {}", c.id),
|
||||
));
|
||||
}
|
||||
ledger.apply_convention(c, line.seq)?;
|
||||
}
|
||||
"convention.retired" => {
|
||||
let (id, reason, source) = crate::meta::convention_retired_of(p)?;
|
||||
ledger.apply_retire_convention(&id, reason, source, line.seq)?;
|
||||
}
|
||||
"decision.recorded" => {
|
||||
let (id, d) = crate::meta::decision_recorded_of(p)?;
|
||||
ledger.apply_decision(id, d, line.seq)?;
|
||||
}
|
||||
"open_item.raised" => {
|
||||
let (id, i) = crate::meta::open_item_raised_of(p)?;
|
||||
ledger.apply_open_item(id, i, line.seq)?;
|
||||
}
|
||||
"open_item.resolved" => {
|
||||
let (id, resolution, source) = crate::meta::open_item_resolved_of(p)?;
|
||||
ledger.apply_resolve_item(id, resolution, source, line.seq)?;
|
||||
}
|
||||
"entry.approved" => {
|
||||
let (entry, approval) = crate::meta::entry_approved_of(p, line.seq)?;
|
||||
ledger.apply_approval(entry, approval)?;
|
||||
}
|
||||
"journal.tail_repaired" => {}
|
||||
"journal.format_set" => {
|
||||
// No ledger state; a format this version can't read is
|
||||
@@ -5325,6 +5708,109 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"settings.set" => {
|
||||
let (changes, source) = crate::meta::settings_set_of(p)?;
|
||||
let source = crate::meta::source_json(&source).to_string();
|
||||
for c in &changes {
|
||||
match &c.value {
|
||||
Some(v) => conn.execute(
|
||||
"INSERT INTO settings(key, value_json, seq, source_json) VALUES (?,?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, seq=excluded.seq, source_json=excluded.source_json",
|
||||
params![c.key, v.to_string(), line.seq as i64, source],
|
||||
)?,
|
||||
None => conn.execute("DELETE FROM settings WHERE key = ?", params![c.key])?,
|
||||
};
|
||||
}
|
||||
}
|
||||
"convention.set" => {
|
||||
let c = crate::meta::convention_set_of(p)?;
|
||||
conn.execute(
|
||||
"INSERT INTO conventions(id, kind, title, body_md, structured_json, source_json, seq, revision) VALUES (?,?,?,?,?,?,?,1) ON CONFLICT(id) DO UPDATE SET kind=excluded.kind, title=excluded.title, body_md=excluded.body_md, structured_json=excluded.structured_json, source_json=excluded.source_json, seq=excluded.seq, revision=conventions.revision + 1",
|
||||
params![
|
||||
c.id,
|
||||
c.kind.key(),
|
||||
c.title,
|
||||
c.body_md,
|
||||
c.structured.as_ref().map(Value::to_string),
|
||||
crate::meta::source_json(&c.source).to_string(),
|
||||
line.seq as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"convention.retired" => {
|
||||
let (id, reason, source) = crate::meta::convention_retired_of(p)?;
|
||||
conn.execute(
|
||||
"UPDATE conventions SET retired_reason = ?, retired_source_json = ?, retired_seq = ? WHERE id = ?",
|
||||
params![
|
||||
reason,
|
||||
crate::meta::source_json(&source).to_string(),
|
||||
line.seq as i64,
|
||||
id
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"decision.recorded" => {
|
||||
let (id, d) = crate::meta::decision_recorded_of(p)?;
|
||||
conn.execute(
|
||||
"INSERT INTO decisions(id, subject, decision_md, decided_by_json, decided_on, evidence_attachment, supersedes, citation, seq) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
id as i64,
|
||||
d.subject,
|
||||
d.decision_md,
|
||||
serde_json::to_string(&d.decided_by)?,
|
||||
d.decided_on.to_string(),
|
||||
d.evidence_attachment.map(|a| a as i64),
|
||||
d.supersedes.map(|a| a as i64),
|
||||
d.citation,
|
||||
line.seq as i64,
|
||||
],
|
||||
)?;
|
||||
if let Some(old) = d.supersedes {
|
||||
conn.execute(
|
||||
"UPDATE decisions SET superseded_by = ? WHERE id = ?",
|
||||
params![id as i64, old as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
"open_item.raised" => {
|
||||
let (id, i) = crate::meta::open_item_raised_of(p)?;
|
||||
conn.execute(
|
||||
"INSERT INTO open_items(id, title, detail_md, due, source_json, seq) VALUES (?,?,?,?,?,?)",
|
||||
params![
|
||||
id as i64,
|
||||
i.title,
|
||||
i.detail_md,
|
||||
i.due.map(|d| d.to_string()),
|
||||
crate::meta::source_json(&i.source).to_string(),
|
||||
line.seq as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"open_item.resolved" => {
|
||||
let (id, resolution, source) = crate::meta::open_item_resolved_of(p)?;
|
||||
conn.execute(
|
||||
"UPDATE open_items SET resolution = ?, resolved_source_json = ?, resolved_seq = ? WHERE id = ?",
|
||||
params![
|
||||
resolution,
|
||||
crate::meta::source_json(&source).to_string(),
|
||||
line.seq as i64,
|
||||
id as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"entry.approved" => {
|
||||
let (entry, a) = crate::meta::entry_approved_of(p, line.seq)?;
|
||||
conn.execute(
|
||||
"INSERT INTO entry_approvals(seq, entry_id, approver, fingerprint, note, source_json) VALUES (?,?,?,?,?,?)",
|
||||
params![
|
||||
line.seq as i64,
|
||||
entry as i64,
|
||||
a.approver,
|
||||
a.fingerprint,
|
||||
a.note,
|
||||
crate::meta::source_json(&a.source).to_string(),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
_ => return Err(crate::format::unknown_type(line)),
|
||||
}
|
||||
conn.execute(
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
%PDF-1.4 fixture
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"assets": [],
|
||||
"bills": [],
|
||||
"entries": [
|
||||
{
|
||||
"date": "2026-10-05",
|
||||
"description": "Joint contribution",
|
||||
"id": 1,
|
||||
"lines": [
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "1000.01",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 3,
|
||||
"amount": {
|
||||
"amount": "500.01",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
},
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Draft"
|
||||
}
|
||||
],
|
||||
"entry_parties": [],
|
||||
"invoices": [],
|
||||
"payroll_runs": [],
|
||||
"periods": [],
|
||||
"recurring": [],
|
||||
"schedules": [],
|
||||
"snapshot": {
|
||||
"Ok": {
|
||||
"balance_sheet": {
|
||||
"assets": "0",
|
||||
"balanced": true,
|
||||
"current_earnings": "0",
|
||||
"equity": "0",
|
||||
"liabilities": "0"
|
||||
},
|
||||
"profit_and_loss": {
|
||||
"expenses": "0",
|
||||
"net_income": "0",
|
||||
"revenue": "0"
|
||||
},
|
||||
"trial_balance": {
|
||||
"lines": [],
|
||||
"total_credits": "0",
|
||||
"total_debits": "0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verify": {
|
||||
"Ok": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-17T19:53:25Z","type":"store.created"}
|
||||
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"d831941701fea09ffd8217f36cba5da99d1a45d0113dc50a47248dcb80280ff6","seq":2,"ts":"2026-10-17T19:53:25Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Liability","code":"2100","currency":"USD","id":2,"name":"Due to Members","parent":null,"placeholder":false},"prev":"b50cde605a07e1ea7f3d2706337b3c78c89e98059e31d2a7f8dcfb4263a544e4","seq":3,"ts":"2026-10-17T19:53:25Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":3,"name":"Capital A","parent":null,"placeholder":false},"prev":"a25661e16d53f331435bee703b2ec5025323c6f403bdf20d323209415108ac8d","seq":4,"ts":"2026-10-17T19:53:25Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3001","currency":"USD","id":4,"name":"Capital B","parent":null,"placeholder":false},"prev":"6cdb2d87b823654180c453e845b8352a029f9ec088950565a358edb5b1763395","seq":5,"ts":"2026-10-17T19:53:25Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":5,"name":"Software","parent":null,"placeholder":false},"prev":"909accf560d58465cc34052bf3f0142cc6125e8ad1a04597e7c4f833dbccee4e","seq":6,"ts":"2026-10-17T19:53:25Z","type":"account.added"}
|
||||
{"payload":{"features":[],"format":2},"prev":"5784dc3749592777a990411ece951993d547579bd25249b7f4bc10a48b0e1924","seq":7,"ts":"2026-10-17T19:53:25Z","type":"journal.format_set"}
|
||||
{"payload":{"changes":[{"key":"company.name","value":"Fixture Labs LLC"},{"key":"company.tax_classification","value":"partnership"},{"key":"company.ein","value":"98-7654321"},{"key":"company.timezone","value":"America/Denver"},{"key":"policy.evidence.threshold","value":"0.00"},{"key":"books.cash_accounts","value":["1000"]},{"key":"books.financing_accounts","value":["2100"]},{"key":"policy.parties","value":"require"},{"key":"company.members","value":[{"capital_account":"3000","files_jointly_with":"B","name":"A","share":"0.5"},{"capital_account":"3001","files_jointly_with":"A","name":"B","share":"0.5"}]}],"source":{"decided_by":"Stephen Waits","decided_on":"2026-10-17","note":"Imported from wanderbooks.toml."}},"prev":"3c4dc4a46cdb8ecf6c6dc453f1cabe68a4790a75c7ee8b5f9e300bd6599d384e","seq":8,"ts":"2026-10-17T19:53:25Z","type":"settings.set"}
|
||||
{"payload":{"body_md":"Cash in from both members, half each.","id":"joint-contribution","kind":"posting_template","source":{"decided_by":"A","decided_on":"2026-10-17"},"structured":{"lines":[{"account":"1000","share":"1","side":"debit"},{"account":"3000","share":"0.5","side":"credit"},{"account":"3001","share":"0.5","side":"credit"}]},"title":"Joint contribution"},"prev":"f8db15c21564855f32f6cee9124c1085861755ea585a4516803eca7d6b8d77bf","seq":9,"ts":"2026-10-17T19:53:25Z","type":"convention.set"}
|
||||
{"payload":{"body_md":"Cash in from both members, half each; one entry per member when they pay separately.","id":"joint-contribution","kind":"posting_template","source":{"decided_by":"A","decided_on":"2026-10-17"},"structured":{"lines":[{"account":"1000","share":"1","side":"debit"},{"account":"3000","share":"0.5","side":"credit"},{"account":"3001","share":"0.5","side":"credit"}]},"title":"Joint contribution"},"prev":"60d268b71739270907396ba0d43c6dd0af3333059148e6779b91f33454c31e3f","seq":10,"ts":"2026-10-17T19:53:25Z","type":"convention.set"}
|
||||
{"payload":{"body_md":"Software over 500 needs B.","id":"over-500","kind":"approval_rule","source":{"decided_by":"Stephen Waits","decided_on":"2026-10-17"},"structured":{"accounts":["6000"],"amount_over":"500","approver":"B"},"title":"Big spends"},"prev":"fa42802688f2f805b89c9c12cab7b605161372366f703ec26dcbe0bcec3546f6","seq":11,"ts":"2026-10-17T19:53:25Z","type":"convention.set"}
|
||||
{"payload":{"body_md":"Ask whether a member paid anything personally.","id":"close-ask","kind":"close_step","source":{"decided_by":"Stephen Waits","decided_on":"2026-10-17"},"title":"Ask about personal cards"},"prev":"1aa1e62869b877ab5dbc8a958e8cffbc06c2e3b718544b445ed32a260f42296b","seq":12,"ts":"2026-10-17T19:53:25Z","type":"convention.set"}
|
||||
{"payload":{"id":"close-ask","reason":"Folded into the checklist.","source":{"decided_by":"Stephen Waits","decided_on":"2026-10-17"}},"prev":"493312949ca1ec854bd2911a0b75d1aaf8831650c61184361c8310201c82469b","seq":13,"ts":"2026-10-17T19:53:25Z","type":"convention.retired"}
|
||||
{"payload":{"attachment_id":1,"blob_sha":"c5c2e8be6ad0825a56ded1f1a153ceaf11dc060ab44b120a94406a08207babc2","category":"legal","description":null,"entry_id":null,"file_name":"oa.pdf","mime_type":"application/pdf","size_bytes":16},"prev":"d9cf1fdee068162629a888391b8fb711b19121dc77cb931803ea05611511c760","seq":14,"ts":"2026-10-17T19:53:25Z","type":"document.filed"}
|
||||
{"payload":{"citation":"Treas. Reg. 1.263(a)-1(f)","decided_by":["Stephen Waits","B"],"decided_on":"2026-10-11","decision_md":"Expense tangible items up to 2,500.","evidence_attachment":1,"id":1,"subject":"Materiality"},"prev":"d50543fad98a8a4bee7be0d01d94e9c44d26193fb2e83797891f99e9ee8b29cd","seq":15,"ts":"2026-10-17T19:53:25Z","type":"decision.recorded"}
|
||||
{"payload":{"decided_by":["Stephen Waits"],"decided_on":"2026-10-16","decision_md":"Corrected: prepaid services follow the 12-month rule.","id":2,"subject":"Materiality","supersedes":1},"prev":"1c59d193f4686dbaa0436b27290965d19b75dfaf288bc315644a39e052908f09","seq":16,"ts":"2026-10-17T19:53:25Z","type":"decision.recorded"}
|
||||
{"payload":{"detail_md":"Signed; mailing pending.","due":"2026-10-24","id":1,"source":{"decided_by":"Stephen Waits","decided_on":"2026-10-17"},"title":"Mail the letter"},"prev":"4a6290f4a8edb803dd96829c0e6e2b23c36e4c93cb61d94d54c170d09080b78a","seq":17,"ts":"2026-10-17T19:53:25Z","type":"open_item.raised"}
|
||||
{"payload":{"id":2,"source":{"decided_by":"Stephen Waits","decided_on":"2026-10-17"},"title":"Ask the CPA"},"prev":"2e3ef6a21257e4313bbf65e665dafe9a8d80c1aa6539871d5eef1a996efa7988","seq":18,"ts":"2026-10-17T19:53:25Z","type":"open_item.raised"}
|
||||
{"payload":{"id":2,"resolution":"Asked.","source":{"decided_by":"Stephen Waits","decided_on":"2026-10-17"}},"prev":"4c372f8f1b3cd65c03034111795a484a575f10c6915c4e8d8fe0168680b9f331","seq":19,"ts":"2026-10-17T19:53:25Z","type":"open_item.resolved"}
|
||||
{"payload":{"date":"2026-10-05","description":"Joint contribution","id":1},"prev":"45654a1f4da8a087ae0fb0659948b24083d11c1d7462fa254f611028b5d99635","seq":20,"ts":"2026-10-17T19:53:25Z","type":"entry.created"}
|
||||
{"payload":{"account":1,"amount":"1000.01","currency":"USD","id":1,"side":"debit"},"prev":"177a36f401e11b3245f2e2a2461dd2d5032828a8cd7697413a7db9a681f65431","seq":21,"ts":"2026-10-17T19:53:25Z","type":"entry.line_added"}
|
||||
{"payload":{"account":3,"amount":"500.01","currency":"USD","id":1,"side":"credit"},"prev":"41a32391ae41eba2935ff0cecd69da86c85a442425ddca614ce826f8ddc20fcc","seq":22,"ts":"2026-10-17T19:53:25Z","type":"entry.line_added"}
|
||||
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":1,"side":"credit"},"prev":"273758a9fed94fd458248d1edc6380dd3e3af95a7cd6b4eeb94e0d7809855a63","seq":23,"ts":"2026-10-17T19:53:25Z","type":"entry.line_added"}
|
||||
{"payload":{"approver":"B","entry_id":1,"fingerprint":"807a5fdd769adedb","note":"ok","source":{"decided_by":"B","decided_on":"2026-10-17"}},"prev":"adeb163a99746991b5fb6e38423a5b5ee85ced58142d4baae8c9fa4760124b92","seq":24,"ts":"2026-10-17T19:53:25Z","type":"entry.approved"}
|
||||
{"payload":{"changes":[{"key":"policy.approval_rules","value":"enforce"},{"key":"company.timezone","value":null}],"source":{"decided_by":"Stephen Waits","decided_on":"2026-10-17"}},"prev":"a32a3b4605afc252393a76fa18f4db2321ac4bda475599a0003c75bf2da82696","seq":25,"ts":"2026-10-17T19:53:25Z","type":"settings.set"}
|
||||
@@ -0,0 +1,289 @@
|
||||
{
|
||||
"approvals": [
|
||||
{
|
||||
"approver": "B",
|
||||
"entry": 1,
|
||||
"fingerprint": "807a5fdd769adedb",
|
||||
"note": "ok",
|
||||
"seq": 24,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "B",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"conventions": [
|
||||
{
|
||||
"body_md": "Ask whether a member paid anything personally.",
|
||||
"id": "close-ask",
|
||||
"kind": "close_step",
|
||||
"retired": {
|
||||
"seq": 13,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": null
|
||||
},
|
||||
"text": "Folded into the checklist."
|
||||
},
|
||||
"revision": 1,
|
||||
"seq": 12,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": null
|
||||
},
|
||||
"structured": null,
|
||||
"title": "Ask about personal cards"
|
||||
},
|
||||
{
|
||||
"body_md": "Cash in from both members, half each; one entry per member when they pay separately.",
|
||||
"id": "joint-contribution",
|
||||
"kind": "posting_template",
|
||||
"retired": null,
|
||||
"revision": 2,
|
||||
"seq": 10,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "A",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": null
|
||||
},
|
||||
"structured": {
|
||||
"lines": [
|
||||
{
|
||||
"account": "1000",
|
||||
"share": "1",
|
||||
"side": "debit"
|
||||
},
|
||||
{
|
||||
"account": "3000",
|
||||
"share": "0.5",
|
||||
"side": "credit"
|
||||
},
|
||||
{
|
||||
"account": "3001",
|
||||
"share": "0.5",
|
||||
"side": "credit"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": "Joint contribution"
|
||||
},
|
||||
{
|
||||
"body_md": "Software over 500 needs B.",
|
||||
"id": "over-500",
|
||||
"kind": "approval_rule",
|
||||
"retired": null,
|
||||
"revision": 1,
|
||||
"seq": 11,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": null
|
||||
},
|
||||
"structured": {
|
||||
"accounts": [
|
||||
"6000"
|
||||
],
|
||||
"amount_over": "500",
|
||||
"approver": "B"
|
||||
},
|
||||
"title": "Big spends"
|
||||
}
|
||||
],
|
||||
"decisions": [
|
||||
{
|
||||
"citation": "Treas. Reg. 1.263(a)-1(f)",
|
||||
"decided_by": [
|
||||
"Stephen Waits",
|
||||
"B"
|
||||
],
|
||||
"decided_on": "2026-10-11",
|
||||
"decision_md": "Expense tangible items up to 2,500.",
|
||||
"evidence_attachment": 1,
|
||||
"id": 1,
|
||||
"seq": 15,
|
||||
"subject": "Materiality",
|
||||
"superseded_by": 2,
|
||||
"supersedes": null
|
||||
},
|
||||
{
|
||||
"citation": null,
|
||||
"decided_by": [
|
||||
"Stephen Waits"
|
||||
],
|
||||
"decided_on": "2026-10-16",
|
||||
"decision_md": "Corrected: prepaid services follow the 12-month rule.",
|
||||
"evidence_attachment": null,
|
||||
"id": 2,
|
||||
"seq": 16,
|
||||
"subject": "Materiality",
|
||||
"superseded_by": null,
|
||||
"supersedes": 1
|
||||
}
|
||||
],
|
||||
"open_items": [
|
||||
{
|
||||
"detail_md": "Signed; mailing pending.",
|
||||
"due": "2026-10-24",
|
||||
"id": 1,
|
||||
"resolved": null,
|
||||
"seq": 17,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": null
|
||||
},
|
||||
"title": "Mail the letter"
|
||||
},
|
||||
{
|
||||
"detail_md": null,
|
||||
"due": null,
|
||||
"id": 2,
|
||||
"resolved": {
|
||||
"seq": 19,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": null
|
||||
},
|
||||
"text": "Asked."
|
||||
},
|
||||
"seq": 18,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": null
|
||||
},
|
||||
"title": "Ask the CPA"
|
||||
}
|
||||
],
|
||||
"settings": [
|
||||
{
|
||||
"key": "company.name",
|
||||
"seq": 8,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": "Imported from wanderbooks.toml."
|
||||
},
|
||||
"value": "Fixture Labs LLC"
|
||||
},
|
||||
{
|
||||
"key": "company.tax_classification",
|
||||
"seq": 8,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": "Imported from wanderbooks.toml."
|
||||
},
|
||||
"value": "partnership"
|
||||
},
|
||||
{
|
||||
"key": "company.ein",
|
||||
"seq": 8,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": "Imported from wanderbooks.toml."
|
||||
},
|
||||
"value": "98-7654321"
|
||||
},
|
||||
{
|
||||
"key": "company.members",
|
||||
"seq": 8,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": "Imported from wanderbooks.toml."
|
||||
},
|
||||
"value": [
|
||||
{
|
||||
"capital_account": "3000",
|
||||
"files_jointly_with": "B",
|
||||
"name": "A",
|
||||
"share": "0.5"
|
||||
},
|
||||
{
|
||||
"capital_account": "3001",
|
||||
"files_jointly_with": "A",
|
||||
"name": "B",
|
||||
"share": "0.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "books.cash_accounts",
|
||||
"seq": 8,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": "Imported from wanderbooks.toml."
|
||||
},
|
||||
"value": [
|
||||
"1000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "books.financing_accounts",
|
||||
"seq": 8,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": "Imported from wanderbooks.toml."
|
||||
},
|
||||
"value": [
|
||||
"2100"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "policy.evidence.threshold",
|
||||
"seq": 8,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": "Imported from wanderbooks.toml."
|
||||
},
|
||||
"value": "0.00"
|
||||
},
|
||||
{
|
||||
"key": "policy.parties",
|
||||
"seq": 8,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": "Imported from wanderbooks.toml."
|
||||
},
|
||||
"value": "require"
|
||||
},
|
||||
{
|
||||
"key": "policy.approval_rules",
|
||||
"seq": 25,
|
||||
"source": {
|
||||
"citation": null,
|
||||
"decided_by": "Stephen Waits",
|
||||
"decided_on": "2026-10-17",
|
||||
"note": null
|
||||
},
|
||||
"value": "enforce"
|
||||
}
|
||||
],
|
||||
"settings_last_seq": 25
|
||||
}
|
||||
@@ -118,21 +118,21 @@ fn assert_corrupt<T>(got: Result<T, StoreError>) {
|
||||
|
||||
/// Oracle (plan/served.md 5.1): an older reader meets books a newer
|
||||
/// wanderbooks wrote. A valid line of an unknown type, and a declared
|
||||
/// format 2, are each NewerJournal, from the writer and the viewer alike,
|
||||
/// format 3, are each NewerJournal, from the writer and the viewer alike,
|
||||
/// and the refusal reads nothing further: the database isn't rebuilt and
|
||||
/// the journal is untouched.
|
||||
#[test]
|
||||
fn old_reader_gets_newer_journal() {
|
||||
let cases = [
|
||||
(
|
||||
"settings.set",
|
||||
"period.attested",
|
||||
json!({"key": "company.name", "value": "X"}),
|
||||
"an event type this version doesn't know",
|
||||
),
|
||||
(
|
||||
FORMAT_SET,
|
||||
json!({"format": 2, "features": []}),
|
||||
"journal format 2",
|
||||
json!({"format": 3, "features": []}),
|
||||
"journal format 3",
|
||||
),
|
||||
(
|
||||
FORMAT_SET,
|
||||
@@ -218,12 +218,12 @@ fn the_writer_and_the_viewer_agree_on_newer_books() {
|
||||
&[
|
||||
// Chained and of a known type, but replay refuses it.
|
||||
("entry.posted", json!({"id": 99})),
|
||||
("settings.set", json!({"key": "company.name"})),
|
||||
("period.attested", json!({"key": "company.name"})),
|
||||
],
|
||||
)[1];
|
||||
let need = "an event type this version doesn't know";
|
||||
assert_newer(Store::open(&dir), seq, "settings.set", need, false);
|
||||
assert_newer(Snapshot::load(&dir), seq, "settings.set", need, false);
|
||||
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
|
||||
}
|
||||
|
||||
/// Damage stays corruption, whatever the damaged line's type: the gate
|
||||
@@ -256,8 +256,8 @@ fn damaged_journals_are_still_corrupt() {
|
||||
}),
|
||||
];
|
||||
let newer = [
|
||||
(FORMAT_SET, json!({"format": 2, "features": []})),
|
||||
("settings.set", json!({"key": "company.name"})),
|
||||
(FORMAT_SET, json!({"format": 3, "features": []})),
|
||||
("period.attested", json!({"key": "company.name"})),
|
||||
];
|
||||
for (name, damage) in damages {
|
||||
for (i, last) in newer.iter().enumerate() {
|
||||
@@ -541,32 +541,32 @@ fn a_newer_format_frames_its_lines_its_own_way() {
|
||||
let nexts: [(&str, Next, Option<bool>); 6] = [
|
||||
(
|
||||
"float",
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "settings.set", "payload": {"rate": 0.5}, "prev": prev}),
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": prev}),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"string-seq",
|
||||
|seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "settings.set", "payload": {}, "prev": prev}),
|
||||
|seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "period.attested", "payload": {}, "prev": prev}),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"envelope-key",
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "settings.set", "payload": {}, "prev": prev, "actor": "agent"}),
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": prev, "actor": "agent"}),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"no-prev",
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "settings.set", "payload": {}, "parent": prev}),
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "parent": prev}),
|
||||
Some(false),
|
||||
),
|
||||
(
|
||||
"prev-not-text",
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "settings.set", "payload": {}, "prev": 1.5}),
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": 1.5}),
|
||||
Some(false),
|
||||
),
|
||||
(
|
||||
"wrong-prev",
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "settings.set", "payload": {"rate": 0.5}, "prev": "00"}),
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": "00"}),
|
||||
None,
|
||||
),
|
||||
];
|
||||
@@ -575,7 +575,7 @@ fn a_newer_format_frames_its_lines_its_own_way() {
|
||||
books(&dir);
|
||||
let seq = append(
|
||||
&dir,
|
||||
&[(FORMAT_SET, json!({"format": 2, "features": ["actors"]}))],
|
||||
&[(FORMAT_SET, json!({"format": 3, "features": ["actors"]}))],
|
||||
)[0];
|
||||
let (_, hash) = last_line(&dir);
|
||||
append_raw(
|
||||
@@ -584,8 +584,8 @@ fn a_newer_format_frames_its_lines_its_own_way() {
|
||||
);
|
||||
match verified {
|
||||
Some(v) => {
|
||||
assert_newer(Store::open(&dir), seq, FORMAT_SET, "journal format 2", v);
|
||||
assert_newer(Snapshot::load(&dir), seq, FORMAT_SET, "journal format 2", v);
|
||||
assert_newer(Store::open(&dir), seq, FORMAT_SET, "journal format 3", v);
|
||||
assert_newer(Snapshot::load(&dir), seq, FORMAT_SET, "journal format 3", v);
|
||||
}
|
||||
None => {
|
||||
assert_corrupt(Store::open(&dir));
|
||||
@@ -661,11 +661,11 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() {
|
||||
} else {
|
||||
// After a refused line, it is a next line without a readable
|
||||
// prev: unverified, for both.
|
||||
let seq = append(&dir, &[("settings.set", json!({}))])[0];
|
||||
let seq = append(&dir, &[("period.attested", json!({}))])[0];
|
||||
append_raw(&dir, b"{\"prev\":\"\xff\"}\n");
|
||||
let need = "an event type this version doesn't know";
|
||||
assert_newer(Store::open(&dir), seq, "settings.set", need, false);
|
||||
assert_newer(Snapshot::load(&dir), seq, "settings.set", need, false);
|
||||
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,12 +678,12 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() {
|
||||
fn an_old_reader_never_repairs_newer_books() {
|
||||
let dir = test_dir("newer-torn");
|
||||
books(&dir);
|
||||
let seq = append(&dir, &[("settings.set", json!({"key": "company.name"}))])[0];
|
||||
let seq = append(&dir, &[("period.attested", json!({"key": "company.name"}))])[0];
|
||||
append_raw(&dir, b"{\"seq\":");
|
||||
let path = dir.join(JOURNAL_FILE);
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let need = "an event type this version doesn't know";
|
||||
assert_newer(Store::open(&dir), seq, "settings.set", need, false);
|
||||
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
let set_aside: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -104,6 +104,23 @@ every complete line before it changes anything: a torn tail is set aside
|
||||
only when the gate passes, so an older version never alters newer books.
|
||||
Rule: `docs/spec/rules.md` B-27.
|
||||
|
||||
## How the books are kept, in the books (P1)
|
||||
|
||||
Settings, conventions, decisions, open items and approvals are journaled
|
||||
(journal format 2, declared with `journal.format_set` before the first
|
||||
such line, so a P0 reader stops there with "update wanderbooks"). The
|
||||
engine keeps them beside the ledger (`ledger_core::settings::Settings`,
|
||||
`ledger_core::conventions::Conventions`, fields of `Ledger`), so the store,
|
||||
its replay and the viewer's `Snapshot` share one reading; `Ledger::
|
||||
differs_from` and `verify` compare them, and the projection has a table
|
||||
for each (schema v12). `ledger-config` is the codec between them and a
|
||||
`wanderbooks.toml` (`codec::import`, `codec::export`) and still reads the
|
||||
file for books that predate P1 (the "file" source, until
|
||||
`books.import_settings`). The server (`settings::Live`) takes the books'
|
||||
settings whenever their last `settings.set` changes (on every lock of the
|
||||
store) and then never reads a file; "today" is the company's time zone's,
|
||||
set per request. Rule: `docs/spec/rules.md` B-29.
|
||||
|
||||
## The MCP server's store lock (`ledger-mcp`, P0)
|
||||
|
||||
One `Store` is shared by every session (`SharedStore`: a
|
||||
|
||||
+154
-48
@@ -23,45 +23,87 @@ layer.
|
||||
|
||||
## Company settings
|
||||
|
||||
`ledger-mcp` reads the books dir's `wanderbooks.toml` (default: next to
|
||||
`--data-dir`; override with `--books-toml PATH`): `[company] name` heads
|
||||
every report, `[company] fiscal_year_start` ("MM-DD", default "01-01";
|
||||
the year's end is derived, never stored) defines fiscal-year-to-date for the
|
||||
CPA package and the balance sheet's net income line, `[[company.members]]`
|
||||
(`name`, `share` as "50%" or "0.5", `capital_account` code; shares must sum
|
||||
to 100%) drives the statement of members' equity, `[books]
|
||||
accounting_basis` ("accrual" or "cash", default accrual) labels report
|
||||
footers, and `[books] receipt_threshold` sets the receipt policy unless
|
||||
`config.json` sets `receipt_policy`. Members may name a
|
||||
`distribution_account`, and `files_jointly_with` (spouses filing one joint
|
||||
return; the CPA package's README notes their two K-1s go on one Form 1040,
|
||||
and k1-members.csv ends with a members' total, not a K-1, that ties to
|
||||
Schedule L); single-owner businesses may set `[books] close_into`
|
||||
(the equity account a close puts net income in; default Retained Earnings)
|
||||
and `close_sweep` (draw, distribution, or dividend accounts closed into it).
|
||||
The cash flow statement's account roles are `[books] cash_accounts` (what
|
||||
it reconciles to), `financing_accounts` (loans, and money the owners
|
||||
advanced, such as a "Due to owners" liability: ASC 230-10-45-14(b)), and
|
||||
`investing_accounts` (loans the company makes, investments it buys: ASC
|
||||
230-10-20); the tools and the viewer both read them, and a call may pass
|
||||
its own (docs/spec/rules.md B-14).
|
||||
The books keep their own settings (journal format 2, plan/served.md P1):
|
||||
each change is a `settings.set` line with who decided it and when, replayed
|
||||
into `ledger_core::settings::Settings` by the server and the viewer alike
|
||||
(rules.md B-29). Every key is in `ledger_core::settings::KEYS`, with what it
|
||||
takes and means: the company's facts (`company.name`, `.entity_type`,
|
||||
`.tax_classification`, `.tax_form`, `.fiscal_year_start` ("MM-01"; the
|
||||
year's end is derived, never stored), `.timezone` (what "today" means for
|
||||
the books), `.formation_state`, `.formed`, `.state_entity_number`, `.ein`
|
||||
(the one identity number a setting keeps, shown masked: "**-***5893"),
|
||||
`.irs_name_control`, `.duns`, `.address`, `.registered_agent`,
|
||||
`.partnership_representative`, `.cpa`, `.members` (name, share as "50%" or
|
||||
"0.5", capital account, optional distributions account and
|
||||
`files_jointly_with`; shares sum to exactly 1)); the books'
|
||||
(`books.accounting_basis`, `.cash_accounts`, `.financing_accounts` (loans,
|
||||
and money the owners advanced: ASC 230-10-45-14(b)), `.investing_accounts`
|
||||
(ASC 230-10-20), `.close_into` (single owner only), `.close_sweep`,
|
||||
`.asset_tag_prefix`); and policy (`policy.evidence.threshold` (receipts
|
||||
strictly above it; "0.00": every entry), `.required_accounts`,
|
||||
`.exempt_accounts`, `policy.parties` (off, suggest, require),
|
||||
`policy.approval_rules` (off, the default, or enforce)). A value is checked
|
||||
by its key and kept in one spelling (a time zone as the database spells it,
|
||||
an amount without leading zeros, a return as `schedule_c`, `1065`, `1120s`
|
||||
or `1120`); a change is all or nothing, and the settings after it must hold
|
||||
together (members and `close_into` never both, a classification that names
|
||||
one return, a fiscal year that doesn't move under a closed period). No
|
||||
setting holds nine digits that read as an SSN or EIN (bare, or printed
|
||||
3-2-4 or 2-7) except `company.ein` and `company.duns`.
|
||||
|
||||
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 from before P1 hold no `settings.set` and still read the books dir's
|
||||
`wanderbooks.toml` (default: next to `--data-dir`; `--books-toml PATH`)
|
||||
and `config.json`'s `receipt_policy`, reloaded whenever either changes, as
|
||||
before. `books.import_settings` moves them in once; from then on no file
|
||||
is read, not even at start (a broken file beside the books can't stop the
|
||||
server). A vault key file is never a books setting: `--vault-key-file PATH`
|
||||
names it to the server and the viewer (it wins over a file's `[vault]
|
||||
key_file`).
|
||||
|
||||
- `books.settings {}` → `{ books_toml, books_toml_exists, config_json_present,
|
||||
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`.
|
||||
- `books.settings {}` → `{ source ("books" or "file"), timezone, today,
|
||||
approval_rules, keys: [{ key, doc, identity, value, set_by, seq }],
|
||||
books_toml, books_toml_exists, config_json_present, reload_error, loads,
|
||||
company, fiscal_year_start, tax_form, basis, receipt_rule,
|
||||
receipt_required_accounts, receipt_exempt_accounts, parties, members,
|
||||
cash_accounts, financing_accounts, investing_accounts, close_into,
|
||||
close_sweep, problems }`: the settings in force, every key the books know
|
||||
with its value (null: unset, its default applies) and who set it, and
|
||||
`problems`, settings that disagree with the chart, each saying what to
|
||||
change (conventions naming an account no longer in the chart among
|
||||
them); `books.digest` repeats them as `settings_problems`. Settings are
|
||||
named by key for books that keep their own, as the file spells them for
|
||||
books that still read one; for those, `keys` shows the file's values,
|
||||
with no journal line.
|
||||
- `books.set_setting { changes: [{ key, value }], decided_by, decided_on?,
|
||||
citation?, note? }` → `{ changed: [{ key, was, value }], problems }`: one
|
||||
or more changes, together or not at all (`value` null clears a key back to
|
||||
its default; a value already set changes nothing, idempotent;
|
||||
destructive, since it can clear or switch off a setting). Refused while
|
||||
the books still read a settings file (or `config.json`): import it first,
|
||||
so no setting is lost.
|
||||
- `books.import_settings { toml_text, config_json_text?, confirmed?,
|
||||
skip_unknown?, decided_by, decided_on?, citation?, note? }` → `{ status
|
||||
("preview" or "imported"), changes, notes, unknown, comments }`: exactly
|
||||
the changes
|
||||
that make the books' settings what the file (and `config.json`'s
|
||||
`receipt_policy`, which wins as it does for the server) says. `notes` are
|
||||
what isn't a books setting and where it goes (`[books] data_dir`, `[vault]
|
||||
key_file`, `[viewer]`, `base_currency`); `unknown` are entries the books
|
||||
don't know, not imported (confirm with `skip_unknown`); `comments` are
|
||||
the file's comments, in order, which are not settings and are not
|
||||
imported (record what matters in them as a decision, open item,
|
||||
convention or a setting's note). The text must be
|
||||
the file the server reads, whole (its import is compared with the
|
||||
server's own reading of its files, key by key), and the server must read
|
||||
every setting afterwards as it does now; the vault key must then come
|
||||
from `--vault-key-file` (on the viewer too). A file never turns approval
|
||||
rules on. Books that keep their own settings take no import that changes
|
||||
them (an identical one is no change). Without `confirmed`, a preview that
|
||||
records nothing. An identity value exported masked imports as
|
||||
"unchanged".
|
||||
- `books.export_settings {}` → `{ toml_text }`: the settings as a settings
|
||||
file, each with who decided it as a comment, the EIN masked; a copy, and
|
||||
importing it back changes nothing (`ledger_config::codec`).
|
||||
|
||||
The accounts invoices, bills, payroll, and closes post to are not settings:
|
||||
they are control roles in the journal (rules.md B-23).
|
||||
@@ -75,6 +117,66 @@ they are control roles in the journal (rules.md B-23).
|
||||
another role, and moving a balance sheet role while its account holds a
|
||||
balance. Journaled as `role.assigned`.
|
||||
|
||||
## Tools — how the books are kept
|
||||
|
||||
Conventions, decisions, open items and approvals live in the books too
|
||||
(`ledger_core::conventions`, rules.md B-29), each with who decided it and
|
||||
when; `books.start` lists them, and the handbook's
|
||||
settings-and-conventions section says how to use them.
|
||||
|
||||
- `books.start { focus? }` (read-only, at most 8 KB) → `{ book: { name,
|
||||
timezone, today, fiscal_year, base_currency, basis }, head: { seq, hash },
|
||||
state ("needs_onboarding" or "active"), settings_source, rules: {
|
||||
evidence, parties, approval_rules }, settings_summary: [{ key, value,
|
||||
set_by, seq }], conventions: [{ id, kind, title, summary }], open_items:
|
||||
[{ id, title, due }], truncated, handbook_sections, next_steps: [{ tool,
|
||||
args?, why }] }`. The connect text names it as the first call. Lists cut
|
||||
to fit say so in `truncated` ("conventions: 4 more (books.conventions)").
|
||||
`focus` adds steps: `onboarding` the onboarding step, `close` the
|
||||
company's close steps, `audit` `books.verify`. Not built yet (P5):
|
||||
`book.id`, `book.url`, `head.last_handoff`, the `agent` input, and
|
||||
loading it up front under tool search. Also `books://start`.
|
||||
- `books.conventions { kind?, include_retired? }` → every convention with
|
||||
its full text, structure, revision and source (`books://conventions`).
|
||||
`books.set_convention { id, kind, title, body_md, structured?,
|
||||
decided_by, ... }` sets or revises one (kinds: `posting_template`,
|
||||
structured `{ description?, lines: [{ account, side, share }] }`, each
|
||||
side's shares summing to 1, on accounts in the chart that take postings
|
||||
or on slots (`"{expense}"`) each draft names;
|
||||
`approval_rule`, structured `{ approver, accounts?, amount_over? }`; and
|
||||
`policy`, `close_step`, `calendar`, `mapping`, `note`, in words). An id is
|
||||
a slug, revised in place (same kind), never reused once retired:
|
||||
`books.retire_convention { id, reason, decided_by, ... }` (destructive).
|
||||
- `books.draft_from_template { template_id, date, amount, accounts?,
|
||||
description?, party?, no_party_reason? }` → `{ entry_id, description,
|
||||
lines }`: a draft whose lines split the amount (at most a line's) by the
|
||||
template's shares, each side summing to it exactly, odd cents by largest
|
||||
remainder; `accounts` names each slot's account (`{"expense": "6300"}`),
|
||||
every slot and nothing else. A refused draft journals nothing (a disk
|
||||
failing midway could leave a partial draft, as for any draft built line
|
||||
by line).
|
||||
- `books.record_decision { subject, decision_md, decided_by: [..],
|
||||
decided_on, evidence_attachment?, supersedes?, citation? }` → `{ id }`;
|
||||
`books.decisions { include_superseded? }`. A decision supersedes at most
|
||||
one live decision; its evidence is a document still in the books.
|
||||
- `books.raise_item { title, detail_md?, due?, decided_by, ... }` → `{ id }`,
|
||||
`books.resolve_item { id, resolution, decided_by, ... }`,
|
||||
`books.open_items { include_resolved? }`.
|
||||
- `books.approve_entry { entry_id, approver, note?, approved_on? }`: the
|
||||
approver approved the draft as it stands (its fingerprint: date,
|
||||
description, every line and class; a line added or a class changed
|
||||
afterwards voids it). Under `policy.approval_rules = enforce`,
|
||||
`books.post_entry`'s preview lists `approvals_missing` and a confirmed
|
||||
post is refused with `approval_required` until each covering rule's
|
||||
approver approved (the store refuses it too), and a tool that posts at
|
||||
once (an invoice, a bill, a payment, payroll, a remittance, a disposal)
|
||||
is refused when a rule covers the entry it would post: draft it instead.
|
||||
Reversals, closes, depreciation and amortization are exempt. A rule
|
||||
names accounts that take postings; an account a rule names can't be
|
||||
renumbered until the rule is revised, and `books.renumber_account` lists
|
||||
the conventions that name the old code. Under `off` (the default) rules
|
||||
block nothing.
|
||||
|
||||
## Transports
|
||||
|
||||
- **stdio** (default): `ledger-mcp --data-dir PATH`. For Claude Code,
|
||||
@@ -99,9 +201,10 @@ re-read first, so a client connecting after an edit is told the new rule).
|
||||
It is short on purpose: clients cut long instructions (one cut the old
|
||||
text at 4,096 characters), so it is at most **1,400 characters**, and its
|
||||
first paragraph, at most **512**, stands alone: the entry point
|
||||
(`books.handbook`), the rule that these tools are the only way to the books
|
||||
(never local files), decimal-string amounts, and the
|
||||
draft → lines → preview → confirmed post flow. The second paragraph adds
|
||||
(`books.start` first each session, and `books.handbook` for every
|
||||
procedure), the rule that these tools are the only way to the books (never
|
||||
local files), decimal-string amounts, and the draft → lines → preview →
|
||||
confirmed post flow. The second paragraph adds
|
||||
that every entry balances and uses real accounts from `books.chart`; the
|
||||
parties rule in force (`[books] parties`: under "require" every entry
|
||||
the agent posts names its party or a no-party reason, since closes,
|
||||
@@ -115,9 +218,10 @@ the books); that `books.create_invoice`, `books.create_bill`,
|
||||
`books.receive_payment`, `books.pay_bill`, `books.run_payroll` and
|
||||
`books.remit_payroll_taxes` post at once without a receipt check; TINs
|
||||
only into the vault; show the numbers, and after posting check
|
||||
`books.digest` and offer it to the human; and the
|
||||
one local file the agent may edit, the settings file `books.settings`
|
||||
names (until settings move into the journal, P1). When a long rule (many
|
||||
`books.digest` and offer it to the human; and where settings live: in the
|
||||
books, changed only through the tools, or, for books that still read a
|
||||
settings file, that file, the one local file the agent may edit until
|
||||
`books.import_settings` moves it in. When a long rule (many
|
||||
accounts, a huge threshold, a long name) would push it past 1,400
|
||||
characters, it drops the tracing hint first (the handbook's
|
||||
tracing-figures section has it), then the reasons in parentheses, then the
|
||||
@@ -164,8 +268,9 @@ Tool descriptions are at most 700 characters; eight longer ones are listed
|
||||
in `tool_descriptions_fit_700_characters`, to be shortened in P5.
|
||||
|
||||
**Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool
|
||||
that changes anything also has a `destructiveHint`, true for these 25:
|
||||
`books.post_entry`, `books.reverse_entry`, `books.unlink_receipt`,
|
||||
that changes anything also has a `destructiveHint`, true for these 28:
|
||||
`books.post_entry`, `books.reverse_entry`, `books.import_settings`,
|
||||
`books.set_setting`, `books.retire_convention`, `books.unlink_receipt`,
|
||||
`books.unlink_attachment`, `books.withdraw_attachment`,
|
||||
`books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`,
|
||||
`books.run_schedules`, `books.run_recurring`, `books.renumber_account`,
|
||||
@@ -174,10 +279,10 @@ that changes anything also has a `destructiveHint`, true for these 25:
|
||||
`books.set_tin` (both overwrite a vault record), `books.purge_tin`,
|
||||
`books.create_invoice`, `books.create_bill`, `books.receive_payment`,
|
||||
`books.pay_bill`, `books.run_payroll`, `books.remit_payroll_taxes`; and an
|
||||
`idempotentHint` on the five that a repeat with the same arguments leaves
|
||||
`idempotentHint` on the seven that a repeat with the same arguments leaves
|
||||
the journal and the vault as they were: `books.verify`,
|
||||
`books.run_depreciation`, `books.run_schedules`, `books.run_recurring` and
|
||||
`books.set_party_roles` (a setter such as `books.update_party`,
|
||||
`books.run_depreciation`, `books.run_schedules`, `books.run_recurring`,
|
||||
`books.set_party_roles`, `books.set_setting` and `books.import_settings` (a setter such as `books.update_party`,
|
||||
`books.set_fx_rate` or `books.set_tin` journals a line, and `books.set_tin`
|
||||
seals a new vault record, every time, so none carries it;
|
||||
`idempotent_tools_change_nothing_when_repeated` calls each candidate twice
|
||||
@@ -923,6 +1028,7 @@ posting.
|
||||
Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
|
||||
`list_resources` advertises, `read_resource` renders.
|
||||
|
||||
- `books://start`, `books://settings`, `books://conventions`
|
||||
- `books://chart`
|
||||
- `books://reports/trial-balance`, `books://reports/balance-sheet`,
|
||||
`books://reports/profit-and-loss`
|
||||
|
||||
@@ -828,3 +828,78 @@ stops, and the books reopen after; the inputs that once panicked,
|
||||
pinned; every tool over every old book) and `server.rs`
|
||||
`periods_are_a_year_and_a_month`, `dates_are_four_digit_years_and_real_days`;
|
||||
`ledger-gui` `tests/old_books.rs`.
|
||||
|
||||
## B-29 The books keep how they are kept
|
||||
|
||||
**SOURCE:** DECISION (plan/served.md I-3, owner decisions of 2026-10-16:
|
||||
"the journal holds all books state"; light governance: decisions are
|
||||
recorded with who made them and when, not as paperwork); IRS Pub. 538 and
|
||||
the rules of B-1 to B-28 for what each setting means.
|
||||
|
||||
A company's settings, its conventions (how it posts each kind of
|
||||
transaction, its policies and close steps), the decisions its owners made,
|
||||
what is open, and approvals of draft entries are facts about the books,
|
||||
kept in the journal like every other fact, each with who decided it and
|
||||
when (journal format 2: `settings.set`, `convention.set`,
|
||||
`convention.retired`, `decision.recorded`, `open_item.raised`,
|
||||
`open_item.resolved`, `entry.approved`). A settings file is only text to
|
||||
import from or export to; books that keep their own settings never read
|
||||
one.
|
||||
|
||||
- A setting is one of the known keys, checked by its key and kept in one
|
||||
spelling; a change is all or nothing, and the settings after it hold
|
||||
together. Replay takes any value of the right shape (B-28).
|
||||
- No setting, convention id, convention, decision, open item or approval
|
||||
holds a number that reads as a full TIN (nine digits standing alone,
|
||||
bare or printed 3-2-4 or 2-7 with a dash or a space), except the
|
||||
company's own EIN, shown masked everywhere but reports, packages and the
|
||||
viewer, and a D-U-N-S number in its own key.
|
||||
- A record of how the books are kept changes no entry, draft, period or
|
||||
account (proved for the model: `Step.record_changes_no_figure`; checked
|
||||
on the engine after every record in `settings_replay_is_pure`). Settings
|
||||
that say how the same entries are presented or later allocated (the
|
||||
fiscal year, the cash flow roles, the members) present them by the
|
||||
settings in force; the fiscal year can't move once a period is closed
|
||||
under it (a changed tax year needs the IRS's consent, Form 1128).
|
||||
- Moving a settings file in reads every setting exactly as the server read
|
||||
the file: the text must be the file the server reads, and books that
|
||||
keep their own settings take no import that changes them.
|
||||
- A setting key or convention kind this version doesn't know is a newer
|
||||
version's: the books are "newer", never "damaged" (B-27).
|
||||
- A posting template's lines split the amount exactly on each side, odd
|
||||
cents by largest remainder (B-13's rule); a slot (`{expense}`) takes the
|
||||
account each draft names.
|
||||
- Approval rules block anything only when the company enforces them
|
||||
(`policy.approval_rules = enforce`; off by default). Then a draft a rule
|
||||
covers posts only once its approver approved it as it stands (its
|
||||
fingerprint: date, description, every line and class), and an operation
|
||||
that would post such an entry at once (an invoice, a bill, a payment,
|
||||
payroll, a remittance, a disposal) is refused: it is drafted instead.
|
||||
Reversals, closes, depreciation and amortization follow from entries
|
||||
already posted and are exempt. A rule names accounts that take postings,
|
||||
and an account a rule names can't be renumbered until the rule is
|
||||
revised.
|
||||
|
||||
**Enforced:** `ledger_core::settings` (`check`, `Settings::check_changes`,
|
||||
`check_whole`, `shape`, `apply`), `ledger_core::conventions` (`check_new`,
|
||||
`check_decision`, `check_open_item`, `split`), `Ledger::check_settings`,
|
||||
`check_convention`, `approvals_missing`, `template_lines`; `ledger-store`
|
||||
`Store::change_settings`, `adopt_settings`, `set_convention`,
|
||||
`draft_from_template` (rehearsed whole), `record_decision`; the format gate
|
||||
(format 2 declared before the first such line); `ledger_config::codec`
|
||||
(`import`, `export`); `Store::post_entry` and `approval_gate`
|
||||
(`ApprovalRequired`, `ApprovalNeedsDraft`), `check_renumber_account`;
|
||||
`ledger-mcp` `books.import_settings` (the file the server reads, whole),
|
||||
`books.post_entry` (`approval_required`).
|
||||
**Checked:** `ledger-core` settings and conventions unit tests;
|
||||
`ledger-store` `tests/settings.rs` (`settings_replay_is_pure`, 256 cases
|
||||
per check, 2,000 by hand, with posts between records and every record
|
||||
checked to change no figure or entry; `approval_rules_off_by_default`,
|
||||
`approvals_cover_every_post`, `the_fiscal_year_stays_under_closed_periods`,
|
||||
`a_newer_key_or_kind_is_newer`;
|
||||
`books_written_under_p1_open_to_their_record`, fixture
|
||||
`old-books/p1_record`); `ledger-config` `tests/codec_props.rs`
|
||||
(`exports_round_trip`) and codec unit tests; `ledger-cli` `tests/init.rs`
|
||||
`the_toml_and_its_examples_import_and_round_trip`; `ledger-mcp`
|
||||
`tests/spec_kept.rs`; Lean `Step.record_changes_no_figure`,
|
||||
`Reachable.records`; fuzz `fuzz_settings_replay`.
|
||||
@@ -279,6 +279,81 @@ since `asset_long_life` alone takes three minutes a run):
|
||||
ran past 9999 never trued up and could depreciate past its salvage
|
||||
(`spec_replayed_extremes.rs` `a_life_past_9999_trues_up_to_the_cent`).
|
||||
|
||||
## P1: the books keep how they are kept (2026-10-17)
|
||||
|
||||
| Law | Test |
|
||||
|---|---|
|
||||
| Replay is pure: after any sequence of settings, convention, decision, open-item, template and approval commands, valid or refused, the live store, the store reopened from its journal and the viewer's snapshot hold the same settings and conventions, and the store verifies (database row for row) | `ledger-store` `tests/settings.rs` `settings_replay_is_pure` (proptest, 512 cases per check; 2,000 by hand, release, passed) |
|
||||
| The first such change declares journal format 2, once; a change to what is set journals nothing; refused changes journal nothing | `the_first_change_declares_format_2`, `refused_changes_journal_nothing` |
|
||||
| Approval rules block nothing until enforced; then an entry a rule covers posts only once its approver approved it as it stands | `approval_rules_off_by_default`; `ledger-mcp` `spec_kept.rs` `approval_rules_through_the_server` |
|
||||
| A template drafts exactly, odd cents by largest remainder, its slots named by each draft, all or nothing | `templates_draft_exactly_or_not_at_all`, `template_slots_take_the_account_each_draft_names`, `templates_draft_through_the_server`, core `splits_are_exact` |
|
||||
| Books written under P1 open to the same record forever | `books_written_under_p1_open_to_their_record` (fixture `old-books/p1_record`, also in every old-books test of the store, the viewer and the server) |
|
||||
| A settings file imports exactly, reads as it did, and an export round-trips; a masked EIN never imports | `ledger-config` codec unit tests, `tests/codec_props.rs` `exports_round_trip` (512 cases), `ledger-cli` `the_toml_and_its_examples_import_and_round_trip` |
|
||||
| Moving settings in: set refused before, the preview records nothing, the file never read after (not even to start), an import that reads differently or loses the vault key refused, the EIN only ever masked | `spec_kept.rs` `a_settings_file_moves_into_the_books`, `an_import_that_reads_differently_is_refused`, `an_import_needs_the_vault_key_on_the_command_line` |
|
||||
| books.start fits in 8 KB and says what it cut | `start_fits_in_8_kb` |
|
||||
| Values are canonical; no full TIN but the company's EIN; settings hold together; replay takes shapes | core `settings::tests` |
|
||||
| Every new tool on the happy path, schema-checked; read-only tools change nothing; idempotent tools repeat as no-ops | `spec_mcp.rs` `inv1_*`, `read_only_tools_change_nothing`; `spec_vault.rs` `idempotent_tools_change_nothing_when_repeated` |
|
||||
| The connect text names books.start in its lead and where settings live, in budget, journaled or not | `instructions_budget`, `a_typical_company_gets_the_whole_connect_text` |
|
||||
| Fuzzed journals of the new events open in the writer and the viewer alike, verify, and read every way without a panic | `fuzz_settings_replay` (and `fuzz_store_replay` with the new types) |
|
||||
| A record changes no figure | Lean `Step.record_changes_no_figure`, `Reachable.records` |
|
||||
|
||||
### P1 mutation testing (2026-10-17)
|
||||
|
||||
Every mutant in the P1 diff (`--in-diff`), per crate, against that
|
||||
crate's tests and its nearest callers' (the recipes' settings:
|
||||
`STORE_CASES=64`, `MCP_CASES=16`, no shrinking). First run, then after
|
||||
targeted tests:
|
||||
|
||||
| Crate | Mutants | First run: caught / timeouts / unviable / missed | Missed after |
|
||||
|---|---|---|---|
|
||||
| `ledger-core` (with `ledger-store`'s tests) | 486 | 318 / 5 / 41 / 122 | 1 (equivalent) |
|
||||
| `ledger-config` (with `ledger-cli`'s) | 72 | 44 / 0 / 2 / 26 | 0 |
|
||||
| `ledger-store` | 113 | 81 / 0 / 28 / 4 | 1 (equivalent) |
|
||||
| `ledger-mcp` | 260 | 156 / 0 / 96 / 8 | 0 |
|
||||
| `ledger-gui` | 25 | 7 / 12 / 3 / 3 | 0 |
|
||||
|
||||
No survivor was a bug; each was a test gap, now closed by a test of
|
||||
the law it broke:
|
||||
|
||||
- **Core.** `tests/settings_units.rs`: every settings accessor's exact
|
||||
value, the company profile and its order, every party-policy
|
||||
spelling, fiscal-year bounds off January, the TIN detector's joiner
|
||||
limits, each text, share and month-day boundary, every tax
|
||||
classification and the return it names, the one-quadrillion cap,
|
||||
today in the company's time zone, and the display of members, lists
|
||||
and the masked EIN. `tests/conventions_units.rs`: slot names (32 and
|
||||
33 characters), summaries cut at 160 characters, template bounds (2
|
||||
and 50 lines kept, 1 and 51 refused, a share out of (0, 1] named by
|
||||
its line), the approval fingerprint against an independent FNV-1a of
|
||||
the hashed text, approval rules at the limit and one cent over
|
||||
(debits only), and which fields make a convention's revision.
|
||||
- **Store.** An empty import still moves the books in (once); a
|
||||
posting template replay can't read is newer.
|
||||
- **Config.** The TOML comment scanner (a `#` in basic and literal
|
||||
strings, an escaped quote), comments carried by an import, a masked
|
||||
EIN matching, not matching and into empty books, an empty list
|
||||
changing nothing, `differences` naming each field, the export's
|
||||
provenance lines, `effective_receipt_policy` with and without
|
||||
`config.json` (`tests/effective_policy.rs`).
|
||||
- **MCP.** `Live::journaled` reads no file, `sync` reloads once per
|
||||
change; the books://start, settings and conventions resources; a
|
||||
renumber names exactly the conventions that name the old code.
|
||||
- **Viewer.** The Company page's cards (`tests/kept.rs`): live
|
||||
conventions and not retired ones, standing decisions and not
|
||||
superseded ones, open items and not resolved ones, who decided each
|
||||
and when, a late item saying so in words ("overdue since", added for
|
||||
this: colour alone told only the sighted), and the empty-profile copy
|
||||
of books that keep their settings. `--vault-key-file` wins over the
|
||||
settings and an unknown argument exits 2 (`tests/vault_key_flag.rs`).
|
||||
The first run's 12 timeouts were builds slowed by parallel work: the
|
||||
rerun caught them all.
|
||||
|
||||
Equivalent: `holds_printed_tin` mapping a full-width digit by `/`
|
||||
instead of `-` (every such digit becomes the digit 1: it asks only
|
||||
whether a character is a digit), and `approval_gate`'s fast path (`||`
|
||||
made `&&`: with rules off or none, the probe's `approvals_missing` is
|
||||
empty either way, so only the cost differs).
|
||||
|
||||
## Benchmarks (baseline 2026-10-13)
|
||||
|
||||
No budget, by decision (Stephen, 2026-10-13: the books do little work
|
||||
|
||||
+15
-1
@@ -87,7 +87,9 @@ Each law, and what proves it. Kani harnesses are in
|
||||
### The books over time
|
||||
|
||||
The Lean model (`Ledger.lean`) describes the engine as a state machine:
|
||||
add an account, start a draft, post, reverse, open a period, close one.
|
||||
add an account, start a draft, post, reverse, open a period, close one,
|
||||
and record how the books are kept (a setting, convention, decision, open
|
||||
item or approval: one step, `record`, each record abstracted to a number).
|
||||
Each step carries the checks the engine makes before it mutates. These
|
||||
theorems hold in **every state reachable from empty books, by any
|
||||
sequence of commands, of any length**:
|
||||
@@ -106,6 +108,18 @@ sequence of commands, of any length**:
|
||||
commands adds, removes, or changes an entry dated in it, and it stays
|
||||
closed.
|
||||
- Lean `Step.frozen`, `Steps.frozen`
|
||||
13a. **How the books are kept changes no entry**: in the model, a record
|
||||
(a setting, convention, decision, open item or approval) leaves the
|
||||
chart, every posted entry, every draft and every period exactly as they
|
||||
were, and any number of them keeps a reachable state reachable. The
|
||||
model's `record` step touches only the records by definition, so the
|
||||
proof says the model is sound, not the engine; the engine's side is
|
||||
checked by `settings_replay_is_pure`, which snapshots every figure and
|
||||
entry around each record (rules.md B-29). Settings that change how the
|
||||
same entries are presented (the fiscal year, cash flow roles, members)
|
||||
are outside this law by design.
|
||||
- Lean `Step.record_changes_no_figure`, `Reachable.records` (no
|
||||
axioms at all) · property `settings_replay_is_pure`
|
||||
|
||||
### Closing
|
||||
|
||||
|
||||
Generated
+101
-9
@@ -139,6 +139,12 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
@@ -477,6 +483,37 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.9"
|
||||
@@ -927,7 +964,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
"hayro-syntax",
|
||||
"kurbo",
|
||||
@@ -1324,6 +1361,51 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"jiff-core",
|
||||
"jiff-static",
|
||||
"jiff-tzdb",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-core"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
|
||||
dependencies = [
|
||||
"defmt",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
|
||||
dependencies = [
|
||||
"jiff-core",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-tzdb"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
@@ -1430,6 +1512,7 @@ name = "ledger-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"jiff",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1748,7 +1831,7 @@ version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
@@ -1863,7 +1946,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
@@ -1885,6 +1968,15 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
@@ -2108,7 +2200,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2203,7 +2295,7 @@ version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
@@ -2255,7 +2347,7 @@ version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"core_maths",
|
||||
"log",
|
||||
@@ -2843,7 +2935,7 @@ version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcee505dac6702dd1c5e65aa2e94a6179d19ee09e2e5637d7313db91765dc4e0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2933,7 +3025,7 @@ checksum = "12becd02464849f1507f3c4ef3f401a4d28d5938cc74b6acc6f1d703458bf393"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"az",
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
"bumpalo",
|
||||
"ciborium",
|
||||
"codex",
|
||||
@@ -3472,7 +3564,7 @@ version = "0.239.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -84,3 +84,10 @@ path = "fuzz_targets/fuzz_config_parse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_settings_replay"
|
||||
path = "fuzz_targets/fuzz_settings_replay.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,226 @@
|
||||
// Fuzz target: the books' own record of how they are kept (journal format
|
||||
// 2: settings.set, convention.*, decision.recorded, open_item.*,
|
||||
// entry.approved), journaled by the real JournalWriter with fuzz-chosen
|
||||
// keys, values and shapes after a real store and chart.
|
||||
// Oracles:
|
||||
// 1. Opening never panics or hangs (an Err is fine: a bad record is
|
||||
// refused as corrupt, never misread).
|
||||
// 2. Books that open verify (live = replay = projection, row for row).
|
||||
// 3. The viewer (Snapshot::load) accepts exactly the journals the writer
|
||||
// does, and then holds the same settings and conventions.
|
||||
// 4. Whatever opens can be read every way the server and viewer read it:
|
||||
// the company, the receipt policy, the dates, the approvals, the
|
||||
// settings file export, never a panic.
|
||||
#![no_main]
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
const TYPES: &[&str] = &[
|
||||
"settings.set",
|
||||
"convention.set",
|
||||
"convention.retired",
|
||||
"decision.recorded",
|
||||
"open_item.raised",
|
||||
"open_item.resolved",
|
||||
"entry.approved",
|
||||
"journal.format_set",
|
||||
];
|
||||
|
||||
const KEYS: &[&str] = &[
|
||||
"company.name", "company.fiscal_year_start", "company.members", "company.tax_form",
|
||||
"company.tax_classification", "company.timezone", "company.ein", "company.formed",
|
||||
"books.accounting_basis", "books.cash_accounts", "books.close_into", "books.close_sweep",
|
||||
"books.asset_tag_prefix", "policy.evidence.threshold", "policy.evidence.required_accounts",
|
||||
"policy.parties", "policy.approval_rules", "no.such.key",
|
||||
];
|
||||
|
||||
fn scalar(u: &mut Unstructured) -> arbitrary::Result<Value> {
|
||||
Ok(match u.int_in_range(0..=11)? {
|
||||
0 => json!(u.int_in_range(0u64..=5)?),
|
||||
1 => json!(format!("{}.{:02}", u.int_in_range(0u32..=999)?, u.int_in_range(0u32..=99)?)),
|
||||
11 => json!(*u.choose(&["0.5", "1", "0.3333333333333333333333333333", "79228162514264337593543950335", "-1", "0"])?),
|
||||
2 => json!(format!("{:02}-{:02}", u.int_in_range(0u32..=13)?, u.int_in_range(0u32..=32)?)),
|
||||
3 => json!(*u.choose(&["America/Denver", "Mars/Base", "UTC", ""])?),
|
||||
4 => json!(*u.choose(&["1065", "accrual", "cash", "enforce", "off", "require", "x"])?),
|
||||
5 => json!(*u.choose(&["1000", "2100", "3000", "3001", "6000", "9999"])?),
|
||||
6 => json!(format!("2026-{:02}-{:02}", u.int_in_range(0u32..=13)?, u.int_in_range(0u32..=32)?)),
|
||||
7 => Value::Null,
|
||||
8 => json!(bool::arbitrary(u)?),
|
||||
_ => json!(String::arbitrary(u)?),
|
||||
})
|
||||
}
|
||||
|
||||
fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result<Value> {
|
||||
Ok(match u.int_in_range(0..=4)? {
|
||||
0 if depth < 2 => Value::Array(
|
||||
(0..u.int_in_range(0..=3)?)
|
||||
.map(|_| value(u, depth + 1))
|
||||
.collect::<arbitrary::Result<_>>()?,
|
||||
),
|
||||
1 if depth < 2 => {
|
||||
let mut m = Map::new();
|
||||
for _ in 0..u.int_in_range(0..=5)? {
|
||||
let k = *u.choose(&[
|
||||
"name", "share", "capital_account", "files_jointly_with", "lines",
|
||||
"account", "side", "approver", "accounts", "amount_over", "description",
|
||||
"share",
|
||||
])?;
|
||||
m.insert(k.into(), value(u, depth + 1)?);
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
_ => scalar(u)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn source(u: &mut Unstructured) -> arbitrary::Result<Value> {
|
||||
Ok(match u.int_in_range(0..=5)? {
|
||||
0 => value(u, 1)?,
|
||||
_ => json!({"decided_by": String::arbitrary(u)?, "decided_on": scalar(u)?}),
|
||||
})
|
||||
}
|
||||
|
||||
fn payload(u: &mut Unstructured, t: &str) -> arbitrary::Result<Value> {
|
||||
let mut m = Map::new();
|
||||
match t {
|
||||
"settings.set" => {
|
||||
let changes: Vec<Value> = (0..u.int_in_range(0..=4)?)
|
||||
.map(|_| Ok(json!({"key": *u.choose(KEYS)?, "value": value(u, 0)?})))
|
||||
.collect::<arbitrary::Result<_>>()?;
|
||||
m.insert("changes".into(), json!(changes));
|
||||
}
|
||||
"convention.set" => {
|
||||
m.insert("id".into(), json!(format!("c{}", u.int_in_range(0..=3)?)));
|
||||
m.insert(
|
||||
"kind".into(),
|
||||
json!(*u.choose(&["posting_template", "approval_rule", "note", "nope"])?),
|
||||
);
|
||||
m.insert("title".into(), json!(String::arbitrary(u)?));
|
||||
m.insert("body_md".into(), json!(String::arbitrary(u)?));
|
||||
m.insert("structured".into(), value(u, 0)?);
|
||||
}
|
||||
"convention.retired" => {
|
||||
m.insert("id".into(), json!(format!("c{}", u.int_in_range(0..=3)?)));
|
||||
m.insert("reason".into(), json!(String::arbitrary(u)?));
|
||||
}
|
||||
"decision.recorded" => {
|
||||
m.insert("id".into(), json!(u.int_in_range(0u64..=4)?));
|
||||
m.insert("subject".into(), json!(String::arbitrary(u)?));
|
||||
m.insert("decision_md".into(), json!(String::arbitrary(u)?));
|
||||
m.insert("decided_by".into(), value(u, 0)?);
|
||||
m.insert("decided_on".into(), scalar(u)?);
|
||||
m.insert("supersedes".into(), scalar(u)?);
|
||||
}
|
||||
"open_item.raised" | "open_item.resolved" => {
|
||||
m.insert("id".into(), json!(u.int_in_range(0u64..=4)?));
|
||||
m.insert("title".into(), json!(String::arbitrary(u)?));
|
||||
m.insert("resolution".into(), json!(String::arbitrary(u)?));
|
||||
m.insert("due".into(), scalar(u)?);
|
||||
}
|
||||
"entry.approved" => {
|
||||
m.insert("entry_id".into(), json!(u.int_in_range(0u64..=3)?));
|
||||
m.insert("approver".into(), json!(String::arbitrary(u)?));
|
||||
m.insert("fingerprint".into(), scalar(u)?);
|
||||
}
|
||||
_ => {
|
||||
m.insert("format".into(), json!(u.int_in_range(0u64..=3)?));
|
||||
m.insert("features".into(), json!([]));
|
||||
}
|
||||
}
|
||||
if u.ratio(4, 5)? {
|
||||
m.insert("source".into(), source(u)?);
|
||||
}
|
||||
Ok(Value::Object(m))
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let mut u = Unstructured::new(data);
|
||||
let dir = std::env::temp_dir().join(format!("wb-fuzz-settings-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
{
|
||||
// Real books first: a chart and a draft for approvals and
|
||||
// templates to name.
|
||||
let mut s = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
|
||||
for (code, t) in [
|
||||
("1000", ledger_core::AccountType::Asset),
|
||||
("3000", ledger_core::AccountType::Equity),
|
||||
("3001", ledger_core::AccountType::Equity),
|
||||
("6000", ledger_core::AccountType::Expense),
|
||||
] {
|
||||
s.add_account(code, code, t, None, false).unwrap();
|
||||
}
|
||||
s.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(), "draft")
|
||||
.unwrap();
|
||||
// Members the settings may name, for the figures that use shares.
|
||||
for name in ["A", "B"] {
|
||||
s.add_party(name, &[ledger_core::Role::Member]).unwrap();
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut w =
|
||||
ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for _ in 0..u.int_in_range(0..=16).unwrap_or(0) {
|
||||
let Ok(t) = u.choose(TYPES) else { break };
|
||||
let Ok(p) = payload(&mut u, t) else { break };
|
||||
w.append(t, p).unwrap();
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
|
||||
let store = ledger_store::Store::open(&dir);
|
||||
let snap = ledger_store::Snapshot::load(&dir);
|
||||
match (store, snap) {
|
||||
(Ok(store), Ok(snap)) => {
|
||||
store.verify().expect("books that open must verify");
|
||||
let l = store.ledger();
|
||||
assert_eq!(l.settings(), snap.ledger().settings());
|
||||
assert_eq!(l.conventions(), snap.ledger().conventions());
|
||||
// Every way the books' record is read.
|
||||
let s = l.settings();
|
||||
let c = s.company("USD");
|
||||
let _ = c.fiscal_year_containing(chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap());
|
||||
let _ = c.party_members(l);
|
||||
// The statement of members' equity over the members as set.
|
||||
let caps: Vec<ledger_core::MemberCapital> = c
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
Some(ledger_core::MemberCapital {
|
||||
name: m.name.clone(),
|
||||
capital_account: l.chart().find_by_code(&m.capital_account)?.id,
|
||||
distribution_account: None,
|
||||
share: m.share,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let d = |y, m, day| chrono::NaiveDate::from_ymd_opt(y, m, day).unwrap();
|
||||
let _ = ledger_core::members_equity(l, d(2026, 1, 1), d(2026, 12, 31), &caps);
|
||||
let _ = ledger_config::codec::books_settings(s, "USD").asset_tag_prefix();
|
||||
let _ = ledger_config::codec::receipt_policy(s).threshold();
|
||||
let _ = ledger_config::codec::export(s, "USD");
|
||||
let _ = s.today();
|
||||
let _ = s.check_whole();
|
||||
for (k, r) in s.iter() {
|
||||
let _ = ledger_core::settings::display(k, &r.value, false);
|
||||
}
|
||||
for e in 1..l.next_entry_id() {
|
||||
let _ = l.approvals_missing(e);
|
||||
}
|
||||
for conv in l.conventions().conventions() {
|
||||
let _ = conv.summary();
|
||||
let slots = [("expense".to_string(), "6000".to_string())].into();
|
||||
let _ = l.template_lines(&conv.id, rust_decimal::Decimal::new(10001, 2), &slots);
|
||||
let _ = l.template_lines(&conv.id, rust_decimal::Decimal::new(10001, 2), &Default::default());
|
||||
}
|
||||
}
|
||||
(Err(a), Err(b)) => {
|
||||
let newer = |e: &ledger_store::StoreError| {
|
||||
matches!(e, ledger_store::StoreError::NewerJournal { .. })
|
||||
};
|
||||
assert_eq!(newer(&a), newer(&b), "the writer says {a}; the viewer says {b}");
|
||||
}
|
||||
(Ok(_), Err(e)) => panic!("the writer opens books the viewer refuses: {e}"),
|
||||
(Err(e), Ok(_)) => panic!("the viewer loads books the writer refuses: {e}"),
|
||||
}
|
||||
});
|
||||
@@ -30,7 +30,8 @@ const TYPES: &[&str] = &[
|
||||
"line.cleared", "payroll.run", "period.closed", "period.created", "receipt.attached",
|
||||
"receipt.linked", "receipt.unlinked", "recurring.generated", "recurring.registered",
|
||||
"schedule.posted", "schedule.registered", "vendor.1099_set", "vendor.added",
|
||||
"journal.format_set", "settings.set",
|
||||
"journal.format_set", "settings.set", "convention.set", "convention.retired",
|
||||
"decision.recorded", "open_item.raised", "open_item.resolved", "entry.approved",
|
||||
];
|
||||
|
||||
const FIELDS: &[&str] = &[
|
||||
@@ -44,7 +45,9 @@ const FIELDS: &[&str] = &[
|
||||
"rate", "reason", "recurring_id", "reversal_of", "reverses", "salvage_value",
|
||||
"schedule_id", "share", "side", "size_bytes", "start", "tax_category", "tin_on_file",
|
||||
"total", "useful_life_months", "vendor_id", "type", "account_type", "rounding",
|
||||
"members", "distributions", "sweep", "format", "features",
|
||||
"members", "distributions", "sweep", "format", "features", "changes", "key", "value",
|
||||
"source", "decided_by", "decided_on", "kind", "title", "body_md", "structured", "subject",
|
||||
"decision_md", "supersedes", "resolution", "approver",
|
||||
];
|
||||
|
||||
/// A value replay might plausibly see: small ids, amounts as decimal
|
||||
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
# STATUS — 2026-10-17: "Served" P0 landed and live; P1 next
|
||||
# STATUS — 2026-10-17: "Served" P0 live; P1 landed, cutover next
|
||||
|
||||
## Now: Served (plan/served.md), P0 done, P1 next
|
||||
## Now: Served (plan/served.md), P0 live, P1 landed
|
||||
|
||||
The 2026-10-16 audit of the first company's books (62 verified findings,
|
||||
20 engine defects) and the design discussion that followed produced
|
||||
@@ -29,6 +29,18 @@ Cutover on the first company's books, 2026-10-17: `books.verify` ok
|
||||
books dir, `goldens/p0-cutover-2026-10-17/`). Connect text with their
|
||||
settings: 1,396 characters.
|
||||
|
||||
**P1 (journal-native settings and conventions): landed.** Settings,
|
||||
conventions (posting templates, approval rules, policies, close steps,
|
||||
calendar, mappings, notes), decisions, open items and approvals are
|
||||
journal events (format 2); books that keep their settings never read
|
||||
`wanderbooks.toml` or `config.json`, and `books.import_settings` moves a
|
||||
file in exactly once. Two review rounds (all findings fixed), `just
|
||||
check`, `just prove` (Kani 20/20, Lean 31 theorems, standard axioms
|
||||
only), and mutation on the whole diff (956 mutants; every survivor
|
||||
killed by a test but two equivalent: `docs/testing.md`). **Next:** the
|
||||
first company's cutover (goldens before and after, import, time zone,
|
||||
conventions from its AGENTS.md), then P2.
|
||||
|
||||
**Before-snapshots for later phases:** the first company's reports as of
|
||||
2026-10-11 and 2026-10-16 (journal head `d1ccd2fd…`, 355 events) are
|
||||
saved in that books dir under `goldens/p0-2026-10-16/` (exported through
|
||||
@@ -36,7 +48,7 @@ the MCP, no TINs), privately, by decision, never in this public repo
|
||||
(plan 5.6). P1+ must reproduce them except where the P3 change log
|
||||
adjudicates a difference (cash flow, defect 1).
|
||||
|
||||
**Then:** P1 (journal-native settings and conventions), P2 (serve locally,
|
||||
**Then:** P2 (serve locally,
|
||||
cutover; ask the owner the three plain-English cutover questions in
|
||||
plan section 10.3 first), P3-P11 as planned (P11, added 2026-10-17: one app for Windows, Mac and
|
||||
Linux; P2 gained backup restore and the viewer's menu).
|
||||
|
||||
@@ -743,6 +743,18 @@ Wanderland keeps working between phases.
|
||||
- The GUI is correct with the toml renamed away.
|
||||
- `books.start` v1 is at most 8 KB.
|
||||
|
||||
**As built (2026-10-17), and where it departs from the scope above (engineering calls):**
|
||||
- One event per change set: `settings.set {changes: [{key, value|null}], source}` is all or nothing (an import is one line); there is no separate `settings.cleared` (a null value clears). `effective_from` is not built: no key needs it yet, and effective-dated members are the varying-interest rule (§706(d)), which changes figures and belongs with the P3 allocation work. A later format adds it.
|
||||
- Keys are flat (`company.formation_state`, `company.irs_name_control`, ...), not one `company.legal{}` object: each fact gets its own source, which is what "facts carry their source" asks. `company.members` keeps member names (matched to parties as before); `company.cpa` is text. `policy.capitalization`, `require_tax_category`, `attestation_bar`, `cpa_package_requires_audit`, `audit_cadence` and `books.schedule_convention` arrive with the phases that use them (P3, P8).
|
||||
- `ledger-view` (`attention::find` moved out of the viewer) moves to P5, where its first user (`books.start`'s needs_attention, `next_steps`) lands; P1 has no server-side consumer, and moving 700 lines of the viewer for nothing would be risk without use. `attention_server_equals_gui` goes with it.
|
||||
- The "due to" heuristic keeps reading account names: `financing_accounts` also holds bank loans, which aren't "paid personally".
|
||||
- Approval rules are checked by the engine (`Ledger::approvals_missing`) and enforced by the store: `Store::post_entry` refuses a covered draft until approved, and the operations that post at once (invoices, bills, payments, payroll, remittances, disposals) are refused when a rule covers the entry they would post (`approval_gate`: draft it instead). Reversals, closes, depreciation and amortization are exempt. An approval records the draft's fingerprint (date, description, lines, classes); any change voids it. A rule names accounts that take postings, and an account a rule names can't be renumbered until the rule is revised.
|
||||
- `books.import_settings` takes only the file the server reads, whole (compared key by key with the server's own reading), never turns approval rules on, and on books that keep their own settings changes nothing (review round 1: an agent-transcribed text could drop the EIN).
|
||||
- `books.start` v1 leaves `book.id`, `book.url`, `head.last_handoff`, the `agent` input and `alwaysLoad` for P5 (they need the served book identity and tool search).
|
||||
- Posting templates take slots (`{expense}`): an account each draft names, so "an expense a member paid: Dr <expense> / Cr 2100" is one template.
|
||||
- A vault key file is never a books setting: `--vault-key-file` on the server and the viewer, and an import is refused unless the key in force stays the same.
|
||||
- The connect text's lead names `books.start` first, then the handbook.
|
||||
|
||||
### P2: Serve locally, hidden away, and the Wanderland cutover
|
||||
|
||||
**Scope:**
|
||||
|
||||
@@ -35,3 +35,5 @@ open Wanderbooks
|
||||
#print axioms Apportion.ranks_perm
|
||||
#print axioms Apportion.apportion_sum
|
||||
#print axioms Apportion.apportion_sum_of_shortfall
|
||||
#print axioms Step.record_changes_no_figure
|
||||
#print axioms Reachable.records
|
||||
@@ -10,7 +10,12 @@ theorems below hold in *every* state reachable from empty books, by any
|
||||
sequence of commands, of any length.
|
||||
|
||||
Mirrors `Ledger::add_account`, `try_create_entry`, `post_entry`,
|
||||
`reverse_entry`, `create_period`, and `close_period_to`.
|
||||
`reverse_entry`, `create_period`, and `close_period_to`; and, as one step
|
||||
`record`, everything the books keep about how they are kept: settings,
|
||||
conventions, decisions, open items and approvals (`apply_settings`,
|
||||
`apply_convention`, `apply_decision`, `apply_open_item`,
|
||||
`apply_approval`, and their retirements and resolutions), each a record
|
||||
the step adds and nothing else.
|
||||
-/
|
||||
|
||||
namespace Wanderbooks
|
||||
@@ -57,15 +62,18 @@ structure Period where
|
||||
/-- Whether a day falls in a period. -/
|
||||
def Period.contains (p : Period) (d : Nat) : Bool := decide (p.start ≤ d ∧ d ≤ p.stop)
|
||||
|
||||
/-- The books: chart, posted entries in posting order, drafts, periods. -/
|
||||
/-- The books: chart, posted entries in posting order, drafts, periods,
|
||||
and the record of how they are kept (settings, conventions, decisions,
|
||||
open items, approvals), each record abstracted to a number. -/
|
||||
structure Ledger where
|
||||
chart : Chart
|
||||
posted : List Entry
|
||||
drafts : List Entry
|
||||
periods : List Period
|
||||
records : List Nat
|
||||
|
||||
/-- Empty books. -/
|
||||
def Ledger.empty : Ledger := ⟨[], [], [], []⟩
|
||||
def Ledger.empty : Ledger := ⟨[], [], [], [], []⟩
|
||||
|
||||
/-- Whether a closed period contains a day. -/
|
||||
def Ledger.closedOn (L : Ledger) (d : Nat) : Prop :=
|
||||
@@ -119,6 +127,10 @@ inductive Step : Ledger → Ledger → Prop
|
||||
= net (closings (nominalCodes L.chart) (allLines (through p.stop L.posted))))
|
||||
(id : Nat) :
|
||||
Step L { L with posted := L.posted ++ closingFor L p ts id, periods := closeIn p L.periods }
|
||||
/-- `apply_settings`, `apply_convention`, `apply_decision`,
|
||||
`apply_open_item`, `apply_approval` and their retirements and
|
||||
resolutions: a record of how the books are kept, and nothing else. -/
|
||||
| record (L : Ledger) (r : Nat) : Step L { L with records := r :: L.records }
|
||||
|
||||
/-- States reachable from empty books. -/
|
||||
inductive Reachable : Ledger → Prop
|
||||
@@ -225,6 +237,7 @@ theorem Step.inv {L L' : Ledger} (s : Step L L') (h : Inv L) : Inv L' := by
|
||||
rcases closedOn_closeIn L p d.date hcl with hcl | ⟨-, hc⟩
|
||||
· exact h.draftsOpen d hd hcl
|
||||
· simp [hdrafts d hd] at hc
|
||||
| record r => exact ⟨h.distinct, h.accepted, h.known, h.draftsOpen⟩
|
||||
|
||||
/-- **The invariant holds in every reachable state.** -/
|
||||
theorem Reachable.inv {L : Ledger} (r : Reachable L) : Inv L := by
|
||||
@@ -292,6 +305,7 @@ theorem Step.extends {L L' : Ledger} (s : Step L L') : ∃ t, L'.posted = L.post
|
||||
| reverse e _ r => exact ⟨[r], rfl⟩
|
||||
| openPeriod => exact ⟨[], by simp⟩
|
||||
| close p _ _ _ _ _ ts _ _ id => exact ⟨closingFor L p ts id, rfl⟩
|
||||
| record => exact ⟨[], by simp⟩
|
||||
|
||||
/-- Any number of steps. -/
|
||||
inductive Steps : Ledger → Ledger → Prop
|
||||
@@ -443,4 +457,27 @@ theorem statements_add_up (t : AccountType) (b c : Nat) (h : b ≤ c) (es : List
|
||||
+ typeTotal t (allLines (between b c es)) chart :=
|
||||
typeTotal_split t _ _ _ (fun a => movement_split a b c h es) chart
|
||||
|
||||
/-! ## How the books are kept changes no figure -/
|
||||
|
||||
/-- **A record changes nothing the books hold**: a settings change, a
|
||||
convention, a decision, an open item or an approval leaves the chart, every
|
||||
posted entry, every draft and every period exactly as they were. (The
|
||||
`record` step touches only `records` by definition: this pins the model.
|
||||
The engine's side is checked in Rust by `settings_replay_is_pure`.
|
||||
Settings that change how the same entries are presented, such as the
|
||||
fiscal year, are outside the model.) -/
|
||||
theorem Step.record_changes_no_figure (L : Ledger) (r : Nat) :
|
||||
let L' : Ledger := { L with records := r :: L.records }
|
||||
L'.chart = L.chart ∧ L'.posted = L.posted ∧ L'.drafts = L.drafts ∧ L'.periods = L.periods :=
|
||||
⟨rfl, rfl, rfl, rfl⟩
|
||||
|
||||
/-- **Any number of records changes no figure**, and they keep the
|
||||
invariant: a reachable state plus records is reachable, with the same
|
||||
books. -/
|
||||
theorem Reachable.records {L : Ledger} (h : Reachable L) (rs : List Nat) :
|
||||
Reachable { L with records := rs ++ L.records } := by
|
||||
induction rs with
|
||||
| nil => simpa using h
|
||||
| cons r rs ih => exact Reachable.step ih (Step.record _ r)
|
||||
|
||||
end Wanderbooks
|
||||
Reference in new issue
Block a user